baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SalesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.sales"
|
||||
verbose_name = "销售"
|
||||
@@ -0,0 +1,139 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-06 17:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('catalog', '0001_initial'),
|
||||
('core', '0001_initial'),
|
||||
('inventory', '0001_initial'),
|
||||
('partner', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SalesBill',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('bill_no', models.CharField(max_length=64)),
|
||||
('bill_date', models.DateField()),
|
||||
('total_amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('state', models.CharField(choices=[('draft', '草稿'), ('confirmed', '已过账'), ('cancelled', '已取消')], default='draft', max_length=16)),
|
||||
('remark', models.TextField(blank=True, default='')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_bills', to='partner.customer')),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_bills', to='inventory.warehouse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '销售单',
|
||||
'db_table': 'sales_bill',
|
||||
'ordering': ['-bill_date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SalesBillLine',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('quantity', models.DecimalField(decimal_places=4, max_digits=18)),
|
||||
('unit_price', models.DecimalField(decimal_places=4, max_digits=18)),
|
||||
('amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='sales.salesbill')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sb_lines', to='catalog.product')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'sales_bill_line',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SalesOrder',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('bill_no', models.CharField(max_length=64)),
|
||||
('bill_date', models.DateField()),
|
||||
('expected_date', models.DateField(blank=True, null=True)),
|
||||
('total_amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('state', models.CharField(choices=[('draft', '草稿'), ('confirmed', '已确认'), ('delivered', '已发货'), ('cancelled', '已取消')], default='draft', max_length=16)),
|
||||
('remark', models.TextField(blank=True, default='')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='partner.customer')),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='inventory.warehouse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '销售订单',
|
||||
'db_table': 'sales_order',
|
||||
'ordering': ['-bill_date', '-id'],
|
||||
'unique_together': {('tenant', 'bill_no')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesbill',
|
||||
name='order',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bills', to='sales.salesorder'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SalesOrderLine',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('quantity', models.DecimalField(decimal_places=4, max_digits=18)),
|
||||
('unit_price', models.DecimalField(decimal_places=4, max_digits=18)),
|
||||
('amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('delivered_qty', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='sales.salesorder')),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='so_lines', to='catalog.product')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'sales_order_line',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='salesbill',
|
||||
unique_together={('tenant', 'bill_no')},
|
||||
),
|
||||
]
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-08 08:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('catalog', '0002_product_is_batch_managed_product_min_sale_price_and_more'),
|
||||
('sales', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='salesbill',
|
||||
name='round_off',
|
||||
field=models.DecimalField(decimal_places=4, default=0, help_text='抹零金额(行合计-净额)', max_digits=18),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesbillline',
|
||||
name='source_quantity',
|
||||
field=models.DecimalField(blank=True, decimal_places=4, help_text='录入数量(按录入单位;quantity 恒为基本单位数量)', max_digits=18, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesbillline',
|
||||
name='source_unit',
|
||||
field=models.ForeignKey(blank=True, help_text='录入单位(空=按基本单位)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_lines', to='catalog.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesorderline',
|
||||
name='source_quantity',
|
||||
field=models.DecimalField(blank=True, decimal_places=4, help_text='录入数量(按录入单位;quantity 恒为基本单位数量)', max_digits=18, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesorderline',
|
||||
name='source_unit',
|
||||
field=models.ForeignKey(blank=True, help_text='录入单位(空=按基本单位)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_lines', to='catalog.unit'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-10 15:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sales', '0002_salesbill_round_off_salesbillline_source_quantity_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='salesbillline',
|
||||
name='tax_amount',
|
||||
field=models.DecimalField(decimal_places=4, default=0, help_text='行税额(从含税额中拆出)', max_digits=18),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesbillline',
|
||||
name='tax_rate',
|
||||
field=models.DecimalField(decimal_places=4, default=0, help_text='行税率(0.13 = 13%);价内口径:amount 为含税额', max_digits=6),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""销售模型:SalesOrder(销售订单)+ SalesBill(销售单/出库单)。"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
|
||||
from apps.core.base_models import TenantScopedModel
|
||||
|
||||
|
||||
class SalesOrder(TenantScopedModel):
|
||||
"""销售订单(可选,预先订货流程)。"""
|
||||
|
||||
STATE_CHOICES = [
|
||||
("draft", "草稿"),
|
||||
("confirmed", "已确认"),
|
||||
("delivered", "已发货"),
|
||||
("cancelled", "已取消"),
|
||||
]
|
||||
|
||||
bill_no = models.CharField(max_length=64)
|
||||
customer = models.ForeignKey(
|
||||
"partner.Customer",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="sales_orders",
|
||||
)
|
||||
warehouse = models.ForeignKey(
|
||||
"inventory.Warehouse",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="sales_orders",
|
||||
)
|
||||
bill_date = models.DateField()
|
||||
expected_date = models.DateField(null=True, blank=True)
|
||||
total_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
state = models.CharField(max_length=16, choices=STATE_CHOICES, default="draft")
|
||||
remark = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
db_table = "sales_order"
|
||||
unique_together = [("tenant", "bill_no")]
|
||||
ordering = ["-bill_date", "-id"]
|
||||
verbose_name_plural = "销售订单"
|
||||
|
||||
|
||||
class SalesOrderLine(TenantScopedModel):
|
||||
order = models.ForeignKey(
|
||||
SalesOrder, on_delete=models.CASCADE, related_name="lines"
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"catalog.Product", on_delete=models.PROTECT, related_name="so_lines"
|
||||
)
|
||||
source_unit = models.ForeignKey(
|
||||
"catalog.Unit",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="%(class)s_lines",
|
||||
help_text="录入单位(空=按基本单位)",
|
||||
)
|
||||
source_quantity = models.DecimalField(
|
||||
max_digits=18, decimal_places=4, null=True, blank=True,
|
||||
help_text="录入数量(按录入单位;quantity 恒为基本单位数量)",
|
||||
)
|
||||
|
||||
quantity = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
unit_price = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
delivered_qty = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
|
||||
class Meta:
|
||||
db_table = "sales_order_line"
|
||||
ordering = ["id"]
|
||||
|
||||
|
||||
class SalesBill(TenantScopedModel):
|
||||
"""销售单(实际出库时生成)。"""
|
||||
|
||||
STATE_CHOICES = [
|
||||
("draft", "草稿"),
|
||||
("confirmed", "已过账"),
|
||||
("cancelled", "已取消"),
|
||||
]
|
||||
|
||||
bill_no = models.CharField(max_length=64)
|
||||
customer = models.ForeignKey(
|
||||
"partner.Customer",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="sales_bills",
|
||||
)
|
||||
warehouse = models.ForeignKey(
|
||||
"inventory.Warehouse",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="sales_bills",
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
SalesOrder,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="bills",
|
||||
)
|
||||
bill_date = models.DateField()
|
||||
total_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
round_off = models.DecimalField(
|
||||
max_digits=18, decimal_places=4, default=0, help_text="抹零金额(行合计-净额)"
|
||||
)
|
||||
state = models.CharField(max_length=16, choices=STATE_CHOICES, default="draft")
|
||||
remark = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
db_table = "sales_bill"
|
||||
unique_together = [("tenant", "bill_no")]
|
||||
ordering = ["-bill_date", "-id"]
|
||||
verbose_name_plural = "销售单"
|
||||
|
||||
|
||||
class SalesBillLine(TenantScopedModel):
|
||||
bill = models.ForeignKey(
|
||||
SalesBill, on_delete=models.CASCADE, related_name="lines"
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"catalog.Product", on_delete=models.PROTECT, related_name="sb_lines"
|
||||
)
|
||||
source_unit = models.ForeignKey(
|
||||
"catalog.Unit",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="%(class)s_lines",
|
||||
help_text="录入单位(空=按基本单位)",
|
||||
)
|
||||
source_quantity = models.DecimalField(
|
||||
max_digits=18, decimal_places=4, null=True, blank=True,
|
||||
help_text="录入数量(按录入单位;quantity 恒为基本单位数量)",
|
||||
)
|
||||
|
||||
quantity = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
unit_price = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
|
||||
# 税率(D4):行级快照,避免事后改商品税率影响历史单据
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=6, decimal_places=4, default=0,
|
||||
help_text="行税率(0.13 = 13%);价内口径:amount 为含税额",
|
||||
)
|
||||
tax_amount = models.DecimalField(
|
||||
max_digits=18, decimal_places=4, default=0,
|
||||
help_text="行税额(从含税额中拆出)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "sales_bill_line"
|
||||
ordering = ["id"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""sales 序列化器(async 兼容)。"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.core.serializers import AsyncModelSerializer
|
||||
from .models import SalesOrder, SalesOrderLine, SalesBill, SalesBillLine
|
||||
|
||||
|
||||
class SalesOrderLineSerializer(AsyncModelSerializer):
|
||||
product_code = serializers.CharField(source="product.code", read_only=True)
|
||||
product_name = serializers.CharField(source="product.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderLine
|
||||
fields = ["id", "product", "product_code", "product_name",
|
||||
"quantity", "unit_price", "amount", "delivered_qty"]
|
||||
read_only_fields = ["id", "amount", "delivered_qty"]
|
||||
|
||||
|
||||
class SalesOrderSerializer(AsyncModelSerializer):
|
||||
customer_code = serializers.CharField(source="customer.code", read_only=True)
|
||||
warehouse_code = serializers.CharField(source="warehouse.code", read_only=True)
|
||||
lines = SalesOrderLineSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrder
|
||||
fields = [
|
||||
"id", "bill_no", "customer", "customer_code",
|
||||
"warehouse", "warehouse_code",
|
||||
"bill_date", "expected_date", "total_amount", "state", "remark",
|
||||
"lines",
|
||||
]
|
||||
read_only_fields = ["id", "bill_no", "total_amount", "state"]
|
||||
|
||||
|
||||
class SalesBillLineSerializer(AsyncModelSerializer):
|
||||
product_code = serializers.CharField(source="product.code", read_only=True)
|
||||
product_name = serializers.CharField(source="product.name", read_only=True)
|
||||
unit_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = SalesBillLine
|
||||
fields = ["id", "product", "product_code", "product_name",
|
||||
"quantity", "source_unit", "source_quantity", "unit_name",
|
||||
"unit_price", "amount", "tax_rate", "tax_amount"]
|
||||
read_only_fields = ["id", "amount", "tax_amount"]
|
||||
|
||||
def get_unit_name(self, obj):
|
||||
unit = obj.source_unit or getattr(obj.product, "base_unit", None)
|
||||
return unit.name if unit else ""
|
||||
|
||||
|
||||
class SalesBillSerializer(AsyncModelSerializer):
|
||||
customer_code = serializers.CharField(source="customer.code", read_only=True)
|
||||
customer_name = serializers.CharField(source="customer.name", read_only=True)
|
||||
warehouse_code = serializers.CharField(source="warehouse.code", read_only=True)
|
||||
round_off = serializers.DecimalField(max_digits=18, decimal_places=4, read_only=True)
|
||||
# 单据税额合计 = 各行 tax_amount 之和(D4;前端展示"其中税额")
|
||||
tax_total = serializers.SerializerMethodField()
|
||||
lines = SalesBillLineSerializer(many=True, required=False)
|
||||
|
||||
def get_tax_total(self, obj):
|
||||
"""在内存里汇总已预取的行,避免每张单一次 aggregate(N+1)。
|
||||
|
||||
旧实现 `obj.lines.aggregate(Sum("tax_amount"))` 对列表页的每一行都发一条 SQL
|
||||
(实测 50 张单 = 50 次聚合)。列表接口已 prefetch `lines`,
|
||||
这里直接遍历内存对象即可。详情接口若未预取,all() 也只多一次查询。
|
||||
"""
|
||||
return str(sum((ln.tax_amount or 0) for ln in obj.lines.all()))
|
||||
|
||||
class Meta:
|
||||
model = SalesBill
|
||||
fields = [
|
||||
"id", "bill_no", "customer", "customer_code", "customer_name",
|
||||
"warehouse", "warehouse_code", "order",
|
||||
"bill_date", "total_amount", "round_off", "tax_total", "state", "remark",
|
||||
"lines",
|
||||
]
|
||||
read_only_fields = ["id", "bill_no", "total_amount", "state"]
|
||||
@@ -0,0 +1,303 @@
|
||||
"""销售业务服务:销售单过账(扣库存 + 生成应收)。
|
||||
|
||||
P0 增强:
|
||||
- 自动取价(客户专属价 > 价格等级价 > 最近成交价 > 默认售价)
|
||||
- 多单位换算(行可带 source_unit/source_quantity,quantity 恒为基本单位)
|
||||
- 最低售价校验(allow_below_min=True 视为审批放行)
|
||||
- 抹零(round_to=0.01/0.1/1,向下取整,差额记 round_off)
|
||||
- 信用额度校验(confirm 时,超限拒绝或 force 放行并预警)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from apps.inventory import services as inv_services
|
||||
from apps.finance import services as fin_services
|
||||
from apps.core.services import (
|
||||
resolve_line_quantity,
|
||||
resolve_line_price,
|
||||
check_min_price,
|
||||
compute_round_off,
|
||||
compute_tax,
|
||||
resolve_tax_rate,
|
||||
resolve_round_to,
|
||||
BelowMinPrice,
|
||||
InvalidLinePrice,
|
||||
InvalidLineQuantity,
|
||||
)
|
||||
|
||||
from .models import SalesOrder, SalesBill, SalesBillLine
|
||||
|
||||
|
||||
class CreditLimitExceeded(Exception):
|
||||
"""客户信用额度不足(force=True 可放行)。"""
|
||||
|
||||
def __init__(self, customer_code: str, outstanding: Decimal, limit: Decimal,
|
||||
extra: Decimal):
|
||||
self.customer_code = customer_code
|
||||
self.outstanding = outstanding
|
||||
self.limit = limit
|
||||
self.extra = extra
|
||||
super().__init__(
|
||||
f"credit limit exceeded for {customer_code}: "
|
||||
f"outstanding={outstanding} + extra={extra} > limit={limit}"
|
||||
)
|
||||
|
||||
|
||||
def _generate_bill_no(tenant, prefix: str) -> str:
|
||||
"""取一个候选单号(真正保证唯一由 create_with_unique_bill_no 的重试负责)。
|
||||
|
||||
保留本函数是为了兼容既有调用(storesfront 的 confirm_order 会用)。
|
||||
"""
|
||||
Model = SalesOrder if prefix == "SO" else SalesBill
|
||||
from apps.core.services import next_bill_no
|
||||
|
||||
return next_bill_no(tenant, prefix, Model)
|
||||
|
||||
|
||||
def _resolve_line(product, ln, customer=None):
|
||||
"""解析销售行:换算 + 取价 + 最低售价校验。
|
||||
|
||||
返回 dict(quantity, source_unit, source_quantity, unit_price, amount)。
|
||||
"""
|
||||
parsed = resolve_line_quantity(product, ln)
|
||||
qty = parsed["quantity"]
|
||||
src_unit = parsed["source_unit"]
|
||||
src_qty = parsed["source_quantity"]
|
||||
|
||||
rate = None
|
||||
if src_unit is not None:
|
||||
from apps.catalog.models import UnitConversion
|
||||
conv = UnitConversion.objects.filter(product=product, unit=src_unit).first()
|
||||
rate = conv.rate if conv else None
|
||||
|
||||
# 取价:显式传入优先;否则按基本单位报价 × 换算率
|
||||
if ln.get("unit_price") in (None, ""):
|
||||
if customer is None:
|
||||
base_price = product.sale_price
|
||||
source = "default"
|
||||
else:
|
||||
from apps.partner.services import quote_price
|
||||
q = quote_price(tenant=product.tenant, customer=customer, product=product)
|
||||
base_price = q["price"]
|
||||
source = q["source"]
|
||||
price = resolve_line_price(product, ln, base_price=base_price, rate=rate)
|
||||
else:
|
||||
price = resolve_line_price(product, ln)
|
||||
|
||||
check_min_price(
|
||||
product, unit_price=price, rate=rate,
|
||||
allow_below_min=bool(ln.get("allow_below_min")),
|
||||
)
|
||||
|
||||
if src_unit is not None:
|
||||
amount = src_qty * price
|
||||
else:
|
||||
amount = qty * price
|
||||
|
||||
# 税率(D4):价内口径——price/amount 视为含税,税额从行金额中拆分
|
||||
tax_rate = resolve_tax_rate(product, ln)
|
||||
tax = compute_tax(amount, tax_rate)
|
||||
return {
|
||||
"quantity": qty,
|
||||
"source_unit": src_unit,
|
||||
"source_quantity": src_qty,
|
||||
"unit_price": price,
|
||||
"amount": amount,
|
||||
"tax_rate": tax_rate,
|
||||
"tax_amount": tax["tax"],
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_sales_bill(
|
||||
*, tenant, customer, warehouse, bill_date=None, remark="", lines=None,
|
||||
order=None, round_to=None,
|
||||
) -> SalesBill:
|
||||
"""新建一张销售单(草稿态)。
|
||||
|
||||
lines: list of dict(product, quantity[, unit_price][, source_unit][, allow_below_min])
|
||||
round_to: 抹零精度("1"元 / "0.1"角 / "0.01"分;None=不抹零)
|
||||
"""
|
||||
bill_date = bill_date or date.today()
|
||||
# 抹零精度白名单校验(非法值此前会被当成任意精度参与计算)
|
||||
round_to = resolve_round_to(round_to)
|
||||
lines_total = Decimal("0")
|
||||
resolved = []
|
||||
for ln in lines or []:
|
||||
product = ln["product"]
|
||||
parsed = _resolve_line(product, ln, customer=customer)
|
||||
resolved.append((product, parsed, ln))
|
||||
lines_total += parsed["amount"]
|
||||
|
||||
# 单据必须有实际金额:防止"0.0001 元单据"这类无效数据落库
|
||||
if lines_total <= 0:
|
||||
raise ValueError(
|
||||
f"销售单金额必须大于 0(行合计 {lines_total});请检查数量与单价"
|
||||
)
|
||||
|
||||
round_off = compute_round_off(lines_total, round_to) if round_to else Decimal("0")
|
||||
if round_off > 0:
|
||||
# 抹零额不得超过一个精度单位(防御)
|
||||
if round_off > Decimal(str(round_to)):
|
||||
raise ValueError("round_off exceeds round_to precision")
|
||||
remark = (remark + "\n" if remark else "") + f"抹零 {round_off} 元"
|
||||
|
||||
# 并发安全建单:单号冲突时自动换号重试(count()+1 在并发下会撞车)
|
||||
from apps.core.services import create_with_unique_bill_no
|
||||
|
||||
bill = create_with_unique_bill_no(
|
||||
SalesBill,
|
||||
tenant=tenant,
|
||||
prefix="XS",
|
||||
defaults=dict(
|
||||
customer=customer,
|
||||
warehouse=warehouse,
|
||||
order=order,
|
||||
bill_date=bill_date,
|
||||
total_amount=lines_total - round_off,
|
||||
round_off=round_off,
|
||||
state="draft",
|
||||
remark=remark,
|
||||
),
|
||||
)
|
||||
for product, parsed, ln in resolved:
|
||||
SalesBillLine.objects.create(
|
||||
tenant=tenant,
|
||||
bill=bill,
|
||||
product=product,
|
||||
quantity=parsed["quantity"],
|
||||
unit_price=parsed["unit_price"],
|
||||
amount=parsed["amount"],
|
||||
source_unit=parsed["source_unit"],
|
||||
source_quantity=parsed["source_quantity"],
|
||||
tax_rate=parsed["tax_rate"],
|
||||
tax_amount=parsed["tax_amount"],
|
||||
)
|
||||
return bill
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def confirm_sales_bill(bill: SalesBill, *, user=None, force: bool = False) -> SalesBill:
|
||||
"""销售单过账:信用额度校验 + 扣库存(批次 FEFO)+ 生成应收单 + 结转成本凭证。
|
||||
|
||||
库存不足抛 InsufficientStock;信用超限抛 CreditLimitExceeded(force=True 放行)。
|
||||
"""
|
||||
if bill.state != "draft":
|
||||
raise ValueError(f"sales bill {bill.bill_no} not in draft state")
|
||||
# 0. 信用额度校验
|
||||
_enforce_credit_limit(bill, force=force)
|
||||
# 1. 扣库存并累加出库成本
|
||||
total_cost = Decimal("0")
|
||||
for line in bill.lines.select_related("product").all():
|
||||
res = inv_services.outbound(
|
||||
tenant=bill.tenant,
|
||||
warehouse=bill.warehouse,
|
||||
product=line.product,
|
||||
quantity=line.quantity,
|
||||
source_type="sale",
|
||||
source_ref=bill.bill_no,
|
||||
)
|
||||
total_cost += line.quantity * res.movement.unit_cost
|
||||
# 2. 生成应收(若财务已就绪,自动生成收入凭证)
|
||||
# 税额合计从行上取(D4):行税率为 0 时合计为 0,凭证保持原两分录
|
||||
from django.db.models import Sum as _Sum
|
||||
|
||||
tax_total = bill.lines.aggregate(t=_Sum("tax_amount"))["t"] or Decimal("0")
|
||||
fin_services.create_receivable_from_sale(
|
||||
tenant=bill.tenant,
|
||||
customer=bill.customer,
|
||||
total_amount=bill.total_amount,
|
||||
source_ref=bill.bill_no,
|
||||
bill_date=bill.bill_date,
|
||||
tax_amount=tax_total,
|
||||
)
|
||||
# 3. 结转销售成本凭证(借:主营业务成本 贷:库存商品)
|
||||
if total_cost > 0:
|
||||
try:
|
||||
fin_services.voucher_for_sale_cogs(
|
||||
tenant=bill.tenant,
|
||||
sales_bill_no=bill.bill_no,
|
||||
bill_date=bill.bill_date,
|
||||
cost_amount=total_cost,
|
||||
auto_post=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# 4. 改状态
|
||||
bill.state = "confirmed"
|
||||
bill.save(update_fields=["state", "updated_at"])
|
||||
|
||||
# 审计:过账是有后果的关键操作,必须留痕
|
||||
from apps.core import audit as audit_log
|
||||
|
||||
audit_log.log_bill_posted(
|
||||
tenant=bill.tenant, bill=bill, user=user,
|
||||
extra={"lines": bill.lines.count()},
|
||||
)
|
||||
return bill
|
||||
|
||||
|
||||
def _enforce_credit_limit(bill: SalesBill, *, force: bool) -> None:
|
||||
"""信用额度校验:outstanding + 本单 > limit 时拒绝(force 放行并发预警)。"""
|
||||
from apps.partner.services import credit_usage
|
||||
from apps.notify.services import send_notification
|
||||
|
||||
usage = credit_usage(tenant=bill.tenant, customer=bill.customer)
|
||||
if usage["limit"] <= 0:
|
||||
return # 0 = 不启用额度管控
|
||||
after = usage["outstanding"] + bill.total_amount
|
||||
if after <= usage["limit"]:
|
||||
# 接近上限(≥90%)预警
|
||||
if usage["limit"] > 0 and after >= usage["limit"] * Decimal("0.9"):
|
||||
send_notification(
|
||||
tenant=bill.tenant,
|
||||
title=f"【信用预警】客户 {bill.customer.name} 信用额度即将用尽",
|
||||
content=(
|
||||
f"销售单 {bill.bill_no} 过账后额度占用 "
|
||||
f"{after}/{usage['limit']},占用率 "
|
||||
f"{(after / usage['limit'] * 100):.1f}%,请关注回款。"
|
||||
),
|
||||
category="warning",
|
||||
link="/finance/receivables/",
|
||||
extra_data={"customer_id": bill.customer_id, "bill_no": bill.bill_no},
|
||||
)
|
||||
return
|
||||
if not force:
|
||||
raise CreditLimitExceeded(
|
||||
bill.customer.code, usage["outstanding"], usage["limit"], bill.total_amount
|
||||
)
|
||||
# 审计:越过管控是高风险动作,必须留痕(谁批的、超了多少)
|
||||
from apps.core import audit as audit_log
|
||||
|
||||
audit_log.log_force_release(
|
||||
tenant=bill.tenant,
|
||||
kind="credit_limit",
|
||||
target=bill.bill_no,
|
||||
detail={
|
||||
# bill_no 必须出现在 detail 里:轨迹查询要按它把
|
||||
# "同一单据的所有动作"(过账 + 放行)串成一条时间线
|
||||
"bill_no": bill.bill_no,
|
||||
"customer_code": bill.customer.code,
|
||||
"outstanding": str(usage["outstanding"]),
|
||||
"limit": str(usage["limit"]),
|
||||
"bill_amount": str(bill.total_amount),
|
||||
},
|
||||
)
|
||||
|
||||
send_notification(
|
||||
tenant=bill.tenant,
|
||||
title=f"【信用超限】客户 {bill.customer.name} 赊销超出信用额度(已放行)",
|
||||
content=(
|
||||
f"销售单 {bill.bill_no} 金额 {bill.total_amount},过账后占用 "
|
||||
f"{after}/{usage['limit']},已由审批放行,请尽快跟进回款。"
|
||||
),
|
||||
category="warning",
|
||||
link="/finance/receivables/",
|
||||
extra_data={"customer_id": bill.customer_id, "bill_no": bill.bill_no,
|
||||
"forced": True},
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""sales URL。"""
|
||||
|
||||
from django.urls import path, include
|
||||
from adrf.routers import DefaultRouter as ADRouter
|
||||
from .views import SalesOrderViewSet, SalesBillViewSet
|
||||
|
||||
|
||||
router = ADRouter()
|
||||
router.register("orders", SalesOrderViewSet, basename="so")
|
||||
router.register("bills", SalesBillViewSet, basename="sb")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""sales async ViewSets。
|
||||
|
||||
除标准 CRUD 外,提供开单页需要的动作接口:
|
||||
- `create_bill`(POST /sales/bills/create-bill/):按行解析单位/取价/抹零后建草稿单
|
||||
- `confirm_bill`(POST /sales/bills/<id>/confirm/):过账(402 = 信用超限,可 force 放行)
|
||||
|
||||
两个接口都把 services 层的业务异常翻译成前端可判别的错误码/字段:
|
||||
- 402 + `{code: "credit_limit_exceeded", ...}` → 前端弹"强制过账"确认框
|
||||
- 400 + `{code: "below_min_price", ...}` → 前端弹"低于最低售价"审批放行确认框
|
||||
"""
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.core.services import BelowMinPrice, InvalidLinePrice, InvalidLineQuantity
|
||||
from apps.core.viewset import BaseTenantViewSet
|
||||
|
||||
from .models import SalesOrder, SalesBill
|
||||
from .serializers import SalesOrderSerializer, SalesBillSerializer
|
||||
from . import services as sales_services
|
||||
|
||||
|
||||
class SalesOrderViewSet(BaseTenantViewSet):
|
||||
model = SalesOrder
|
||||
serializer_class = SalesOrderSerializer
|
||||
search_fields = ["bill_no", "customer__code", "customer__name"]
|
||||
select_related_fields = ("customer", "warehouse")
|
||||
prefetch_related_fields = (
|
||||
"lines", "lines__product", "lines__source_unit", "lines__product__base_unit",
|
||||
)
|
||||
|
||||
|
||||
class SalesBillViewSet(BaseTenantViewSet):
|
||||
model = SalesBill
|
||||
serializer_class = SalesBillSerializer
|
||||
search_fields = ["bill_no", "customer__code", "customer__name"]
|
||||
# 列表要展示客户名、仓库、明细及每行商品/单位 → 全部预取,消除 N+1
|
||||
# 列表要展示客户名、仓库、明细及每行商品/单位 → 全部预取,消除 N+1
|
||||
select_related_fields = ("customer", "warehouse")
|
||||
prefetch_related_fields = (
|
||||
"lines", "lines__product", "lines__source_unit", "lines__product__base_unit",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 建单
|
||||
# ------------------------------------------------------------
|
||||
@action(detail=False, methods=["post"], url_path="create-bill")
|
||||
async def create_bill(self, request):
|
||||
"""POST /api/v1/sales/bills/create-bill/
|
||||
|
||||
body: {customer, warehouse, bill_date?, remark?, round_to?, lines: [
|
||||
{product, quantity, unit_price?, source_unit?, allow_below_min?}]}
|
||||
"""
|
||||
tenant = await self.get_tenant()
|
||||
if tenant is None:
|
||||
raise ValidationError({"tenant": "无法识别租户"})
|
||||
payload = request.data or {}
|
||||
|
||||
# 套餐配额:月度单据量(批次 C1)
|
||||
from apps.billing import quota as billing_quota
|
||||
|
||||
def _quota_check():
|
||||
return billing_quota.check_and_count(tenant, "bills_monthly", delta=1)
|
||||
|
||||
try:
|
||||
await sync_to_async(_quota_check)()
|
||||
except billing_quota.QuotaExceeded as exc:
|
||||
return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
customer_id, warehouse_id = payload.get("customer"), payload.get("warehouse")
|
||||
lines = payload.get("lines") or []
|
||||
if not customer_id or not warehouse_id:
|
||||
raise ValidationError({"detail": "customer 与 warehouse 必填"})
|
||||
if not lines:
|
||||
raise ValidationError({"detail": "lines 不能为空"})
|
||||
if not isinstance(lines, list):
|
||||
raise ValidationError({"detail": "lines 必须是数组"})
|
||||
# 逐行结构校验:非 dict 或缺 product 直接 400(否则下游 .get() 会 500)
|
||||
for idx, ln in enumerate(lines, start=1):
|
||||
if not isinstance(ln, dict):
|
||||
raise ValidationError({"detail": f"第 {idx} 行明细格式错误(应为对象)"})
|
||||
if not ln.get("product"):
|
||||
raise ValidationError({"detail": f"第 {idx} 行缺少 product"})
|
||||
|
||||
from apps.partner.models import Customer
|
||||
from apps.inventory.models import Warehouse
|
||||
from apps.catalog.models import Product
|
||||
|
||||
def _load_refs():
|
||||
customer = Customer.objects.filter(tenant=tenant, pk=customer_id).first()
|
||||
warehouse = Warehouse.objects.filter(tenant=tenant, pk=warehouse_id).first()
|
||||
products = {
|
||||
p.id: p for p in Product.objects.filter(
|
||||
tenant=tenant, pk__in=[ln.get("product") for ln in lines]
|
||||
)
|
||||
}
|
||||
return customer, warehouse, products
|
||||
|
||||
customer, warehouse, products = await sync_to_async(_load_refs)()
|
||||
if customer is None:
|
||||
raise ValidationError({"customer": "客户不存在"})
|
||||
if warehouse is None:
|
||||
raise ValidationError({"warehouse": "仓库不存在"})
|
||||
|
||||
resolved_lines = []
|
||||
for ln in lines:
|
||||
product = products.get(ln.get("product"))
|
||||
if product is None:
|
||||
raise ValidationError({"lines": f"商品 {ln.get('product')} 不存在"})
|
||||
resolved_lines.append({
|
||||
"product": product,
|
||||
"quantity": ln.get("quantity"),
|
||||
"unit_price": ln.get("unit_price"),
|
||||
"source_unit": ln.get("source_unit"),
|
||||
"allow_below_min": bool(ln.get("allow_below_min")),
|
||||
})
|
||||
|
||||
from datetime import date as _date
|
||||
|
||||
kwargs = {
|
||||
"tenant": tenant,
|
||||
"customer": customer,
|
||||
"warehouse": warehouse,
|
||||
"lines": resolved_lines,
|
||||
"remark": payload.get("remark") or "",
|
||||
"round_to": payload.get("round_to") or None,
|
||||
}
|
||||
bill_date = payload.get("bill_date")
|
||||
if bill_date:
|
||||
kwargs["bill_date"] = _date.fromisoformat(bill_date)
|
||||
|
||||
# 业务异常(BelowMinPrice / InvalidLine* / InsufficientStock)由全局
|
||||
# 异常处理器统一翻译为 4xx + code,见 apps.core.exceptions
|
||||
try:
|
||||
bill = await sync_to_async(sales_services.create_sales_bill)(**kwargs)
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": "invalid_line", "detail": str(exc)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
data = await sync_to_async(lambda: SalesBillSerializer(bill).data)()
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 过账
|
||||
# ------------------------------------------------------------
|
||||
@action(detail=True, methods=["post"], url_path="confirm")
|
||||
async def confirm_bill(self, request, pk=None):
|
||||
"""POST /api/v1/sales/bills/<id>/confirm/ body: {force?: bool}"""
|
||||
bill = await self.aget_object()
|
||||
force = bool((request.data or {}).get("force"))
|
||||
|
||||
def _confirm():
|
||||
return sales_services.confirm_sales_bill(
|
||||
bill, user=request.user if request.user.is_authenticated else None,
|
||||
force=force,
|
||||
)
|
||||
|
||||
# CreditLimitExceeded → 402 / InsufficientStock → 400 均由全局处理器映射
|
||||
try:
|
||||
await sync_to_async(_confirm)()
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": "invalid_state", "detail": str(exc)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _reload():
|
||||
bill.refresh_from_db()
|
||||
return SalesBillSerializer(bill).data
|
||||
|
||||
data = await sync_to_async(_reload)()
|
||||
return Response(data)
|
||||
Reference in New Issue
Block a user