Files
dealerhub/backend/apps/sales/services.py
T

304 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""销售业务服务:销售单过账(扣库存 + 生成应收)。
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},
)