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