"""销售业务服务:销售单过账(扣库存 + 生成应收)。 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}, )