baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
View File
+7
View File
@@ -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')},
),
]
@@ -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),
),
]
+152
View File
@@ -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"]
+78
View File
@@ -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"]
+303
View File
@@ -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},
)
+15
View File
@@ -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)),
]
+177
View File
@@ -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)