153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
"""采购业务服务:确认订单 / 进货单过账(写库存 + 生成应付)。"""
|
||
|
||
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
|