"""采购业务服务:确认订单 / 进货单过账(写库存 + 生成应付)。""" 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 ( compute_tax, parse_price, resolve_line_quantity, resolve_tax_rate, ) from .models import PurchaseOrder, PurchaseOrderLine, PurchaseBill, PurchaseBillLine def _resolve_line(product, ln): """解析采购行:换算基本单位数量 + 录入单位/数量;价格按录入单位。 返回 (qty, price, src_unit, src_qty, amount, tax_rate, tax_amount)(D4 加税)。 """ parsed = resolve_line_quantity(product, ln) qty = parsed["quantity"] src_unit = parsed["source_unit"] src_qty = parsed["source_quantity"] # 单价校验(与销售侧同口径):必须 > 0,否则拒绝 # (此前直接 Decimal(str(...)) 未校验,0 价只能靠下游"金额>0"兜住, # 错误码不精确;0 成本入库还会污染加权平均成本) price = parse_price(product, ln.get("unit_price")) 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 amount = src_qty * price else: rate = None amount = qty * price tax_rate = resolve_tax_rate(product, ln) tax = compute_tax(amount, tax_rate) return qty, price, src_unit, src_qty, amount, tax_rate, tax["tax"] def _generate_bill_no(tenant, prefix: str) -> str: """取候选单号(唯一性由 create_with_unique_bill_no 的重试保证)。""" Model = PurchaseOrder if prefix == "PO" else PurchaseBill from apps.core.services import next_bill_no return next_bill_no(tenant, prefix, Model) @transaction.atomic def create_purchase_bill( *, tenant, supplier, warehouse, bill_date=None, remark="", lines=None, order=None, ) -> PurchaseBill: """新建一张进货单(草稿态)。lines: list of dict(product, quantity, unit_price)。""" bill_date = bill_date or date.today() from apps.core.services import create_with_unique_bill_no bill = create_with_unique_bill_no( PurchaseBill, tenant=tenant, prefix="PB", defaults=dict( supplier=supplier, warehouse=warehouse, order=order, bill_date=bill_date, total_amount=Decimal("0"), state="draft", remark=remark, ), ) total = Decimal("0") for ln in lines or []: product = ln["product"] qty, price, src_unit, src_qty, amount, tax_rate, tax_amount = _resolve_line(product, ln) PurchaseBillLine.objects.create( tenant=tenant, bill=bill, product=product, quantity=qty, unit_price=price, amount=amount, batch_no=str(ln.get("batch_no") or ""), production_date=ln.get("production_date"), expiry_date=ln.get("expiry_date"), source_unit=src_unit, source_quantity=src_qty, tax_rate=tax_rate, tax_amount=tax_amount, ) total += amount if total <= 0: raise ValueError(f"进货单金额必须大于 0(行合计 {total});请检查数量与单价") bill.total_amount = total bill.save(update_fields=["total_amount", "updated_at"]) return bill @transaction.atomic def confirm_purchase_bill(bill: PurchaseBill, *, user=None) -> PurchaseBill: """进货单过账:写库存 + 生成应付单。 必须状态是 draft;过账后变 confirmed。 """ if bill.state != "draft": raise ValueError(f"purchase bill {bill.bill_no} not in draft state") # 1. 写库存 for line in bill.lines.select_related("product").all(): inv_services.inbound( tenant=bill.tenant, warehouse=bill.warehouse, product=line.product, quantity=line.quantity, unit_cost=line.unit_price, source_type="purchase", source_ref=bill.bill_no, batch_no=line.batch_no, production_date=line.production_date, expiry_date=line.expiry_date, ) # 2. 生成应付(D4:把行税额合计传给凭证做进项税拆分) from django.db.models import Sum as _Sum tax_total = bill.lines.aggregate(t=_Sum("tax_amount"))["t"] or Decimal("0") fin_services.create_payable_from_purchase( tenant=bill.tenant, supplier=bill.supplier, total_amount=bill.total_amount, source_ref=bill.bill_no, bill_date=bill.bill_date, tax_amount=tax_total, ) # 3. 改状态 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