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
+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},
)