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
+152
View File
@@ -0,0 +1,152 @@
"""采购业务服务:确认订单 / 进货单过账(写库存 + 生成应付)。"""
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