baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -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},
|
||||
)
|
||||
Reference in New Issue
Block a user