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
+349
View File
@@ -0,0 +1,349 @@
"""跨 app 共享的单据行解析:录入单位换算 + 价格。
供 sales / purchase services 复用,保证"单位换算 + 取价 + 最低售价"语义一致。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_DOWN
from apps.catalog.services import to_base
from apps.catalog.services import UnitConversionNotFound
class InvalidLinePrice(Exception):
"""行单价非法(非数字 / 负数 / 超上限)。负价等于倒贴出货,必须拦住。"""
def __init__(self, product_code: str, raw, reason: str):
self.product_code = product_code
self.raw = raw
self.reason = reason
super().__init__(f"invalid unit_price for {product_code}: {raw!r} ({reason})")
MAX_LINE_PRICE = Decimal("1000000000") # 10 亿
class InvalidLineQuantity(Exception):
"""行数量非法(非数字 / ≤0 / 超出上限)。
这是资损级校验:负数量会反向出库(凭空增库存),必须在此拦住。
"""
def __init__(self, product_code: str, raw, reason: str):
self.product_code = product_code
self.raw = raw
self.reason = reason
super().__init__(f"invalid quantity for {product_code}: {raw!r} ({reason})")
# 单行数量上下界
# - 上限:防止误输天文数字撑爆 Decimal 精度与库存计算
# - 下限:数量以 0.0001 为最小精度(数据库 decimal_places=4),
# 但业务上「0.0001 瓶」毫无意义——会产出 0.0016 元的荒谬单据。
# 称重类商品(kg)实际也需要 ≥0.001,这里统一取 0.0001 之上的一档。
MAX_LINE_QUANTITY = Decimal("1000000000") # 10 亿
MIN_LINE_QUANTITY = Decimal("0.001") # 0.001(称重最小实用量)
class BelowMinPrice(Exception):
"""成交价低于商品最低售价(需审批放行)。"""
def __init__(self, product_code: str, unit_price: Decimal, min_price: Decimal):
self.product_code = product_code
self.unit_price = unit_price
self.min_price = min_price
super().__init__(
f"price {unit_price} below min_sale_price {min_price} for {product_code}"
)
def resolve_unit(product, ln):
"""从行 dict 里解析录入单位(支持 Unit 实例 / pk / code)。返回 Unit 或 None。"""
unit = ln.get("source_unit") or ln.get("unit")
if unit in (None, ""):
return None
if hasattr(unit, "pk"):
return unit
from apps.catalog.models import Unit
# 先按 pk 再按 code
u = Unit.objects.filter(tenant=product.tenant, pk=unit).first()
if u is None and isinstance(unit, str):
u = Unit.objects.filter(tenant=product.tenant, code=unit).first()
return u
def parse_quantity(product, raw) -> Decimal:
"""把原始输入解析成正数量;非法一律抛 InvalidLineQuantity。"""
code = getattr(product, "code", str(product))
if raw is None or (isinstance(raw, str) and not raw.strip()):
raise InvalidLineQuantity(code, raw, "数量不能为空")
try:
qty = Decimal(str(raw))
except (InvalidOperation, ValueError, TypeError):
raise InvalidLineQuantity(code, raw, "不是合法数字")
if not qty.is_finite():
raise InvalidLineQuantity(code, raw, "数量必须是有限数")
if qty <= 0:
raise InvalidLineQuantity(code, raw, "数量必须大于 0")
if qty < MIN_LINE_QUANTITY:
raise InvalidLineQuantity(
code, raw, f"数量不能小于 {MIN_LINE_QUANTITY}(避免产生金额极小的无效单据)"
)
if qty > MAX_LINE_QUANTITY:
raise InvalidLineQuantity(code, raw, f"数量超过上限 {MAX_LINE_QUANTITY}")
return qty
def resolve_line_quantity(product, ln) -> dict:
"""解析行数量:返回 {quantity(基本单位), source_unit, source_quantity}。
数量必须为正(负数会反向出库)、在合理上限内——校验集中在 parse_quantity。
"""
if not isinstance(ln, dict):
raise InvalidLineQuantity(getattr(product, "code", "?"), ln, "行数据格式错误")
src_unit = resolve_unit(product, ln)
raw_qty = parse_quantity(product, ln.get("quantity"))
if src_unit is None:
return {"quantity": raw_qty, "source_unit": None, "source_quantity": None}
src_qty = raw_qty
qty = to_base(product, src_unit, raw_qty)
return {"quantity": qty, "source_unit": src_unit, "source_quantity": src_qty}
def parse_price(product, raw) -> Decimal:
"""把原始输入解析成合法单价(必须 > 0)。
为什么不接受 0:
- 销售 0 元 = 白送(赠品应走专门流程,不该产生 0 金额单据)
- 采购 0 元入库会**污染加权平均成本**(把真实成本拉低,导致毛利虚高)
负价同样拒绝(倒贴出货)。
"""
code = getattr(product, "code", str(product))
try:
price = Decimal(str(raw))
except (InvalidOperation, ValueError, TypeError):
raise InvalidLinePrice(code, raw, "不是合法数字")
if not price.is_finite():
raise InvalidLinePrice(code, raw, "单价必须是有限数")
if price <= 0:
raise InvalidLinePrice(code, raw, "单价必须大于 0(0 元单据无业务意义)")
if price > MAX_LINE_PRICE:
raise InvalidLinePrice(code, raw, f"单价超过上限 {MAX_LINE_PRICE}")
return price
def resolve_line_price(product, ln, *, base_price: Decimal | None = None,
rate: Decimal | None = None) -> Decimal:
"""解析行单价。
- 显式传入 unit_price → 校验后使用(按录入单位计价)
- 未传 → base_price(基本单位默认价)× rate(录入单位换算率);
无录入单位时直接用 base_price。
"""
raw = ln.get("unit_price")
if raw not in (None, ""):
return parse_price(product, raw)
if base_price is None:
raise ValueError(f"line for {product.code} missing unit_price and no default price")
if rate is not None:
return base_price * rate
return base_price
def check_min_price(product, *, unit_price: Decimal, rate: Decimal | None = None,
allow_below_min: bool = False) -> None:
"""最低售价校验(按基本单位折算比较)。allow_below_min=True 视为审批放行。
放行是有后果的(可能亏本销售),因此记录审计——调用方拿不到 request,
这里只记业务事实,用户归属由上层补。
"""
min_price = getattr(product, "min_sale_price", Decimal("0")) or Decimal("0")
if min_price <= 0:
return
# unit_price is always expressed in the selected source unit. Convert it
# back to the product's base-unit price before comparing with min_sale_price.
# (Auto-quoting multiplies the base price by rate; multiplying again here
# would both mask under-minimum prices and make the frontend appear correct
# only by accident.)
base_price = unit_price / rate if rate is not None else unit_price
if base_price < min_price:
if not allow_below_min:
raise BelowMinPrice(product.code, unit_price, min_price)
# 审批放行 → 留痕
try:
from apps.core import audit as audit_log
audit_log.log_force_release(
tenant=product.tenant,
kind="below_min_price",
target=product.code,
detail={
"product_code": product.code,
"unit_price": str(unit_price),
"min_price": str(min_price),
"base_price": str(base_price),
},
)
except Exception:
pass
ALLOWED_ROUND_TO = {Decimal("0.01"), Decimal("0.1"), Decimal("1")}
def resolve_round_to(raw) -> Decimal | None:
"""校验抹零精度:只允许 0.01 / 0.1 / 1(None 或 0 = 不抹零)。"""
if raw in (None, "", 0, "0"):
return None
try:
value = Decimal(str(raw))
except (InvalidOperation, ValueError, TypeError):
raise ValueError(f"抹零精度不合法:{raw!r}(仅支持 0.01 / 0.1 / 1)")
if value == 0:
return None
if value not in ALLOWED_ROUND_TO:
raise ValueError(f"抹零精度不合法:{raw!r}(仅支持 0.01 / 0.1 / 1)")
return value
def compute_round_off(total: Decimal, round_to: Decimal | None) -> Decimal:
"""抹零额:向下取整到 round_to 的倍数,差值即抹零金额。
例:total=100.56, round_to=1 → 0.56;round_to=0.1 → 0.06;round_to=0.01 → 0。
"""
if round_to in (None, Decimal("0")):
return Decimal("0")
round_to = Decimal(str(round_to))
if round_to <= 0:
return Decimal("0")
units = (total / round_to).to_integral_value(rounding=ROUND_DOWN)
rounded = units * round_to
return (total - rounded).quantize(Decimal("0.0001"))
# ============================================================
# 税率(批次 D4)
# ============================================================
class InvalidLineAmount(Exception):
"""行金额非法(≤0)。数量与单价校验通过后仍可能合计为 0(如单价 0 的赠品需显式标注)。"""
def __init__(self, product_code: str, amount):
self.product_code = product_code
self.amount = amount
super().__init__(f"line amount must be > 0 for {product_code}: {amount}")
def compute_tax(amount: Decimal, tax_rate: Decimal | None) -> dict:
"""价内税拆分:amount 视为**含税**金额,拆出不含税净额与税额。
采用价内口径(国内零售/批发单据惯例:报价即含税价):
net = amount / (1 + rate)
tax = amount - net
返回 {tax_rate, gross, net, tax}。rate 为 0/None 时 tax=0、net=gross。
"""
gross = Decimal(str(amount or 0))
rate = Decimal(str(tax_rate or 0))
if rate <= 0:
return {
"tax_rate": Decimal("0"),
"gross": gross.quantize(Decimal("0.0001")),
"net": gross.quantize(Decimal("0.0001")),
"tax": Decimal("0.0000"),
}
net = (gross / (Decimal("1") + rate)).quantize(Decimal("0.0001"))
tax = (gross - net).quantize(Decimal("0.0001"))
return {"tax_rate": rate, "gross": gross, "net": net, "tax": tax}
def resolve_tax_rate(product, line: dict | None = None) -> Decimal:
"""行税率:行显式指定 > 商品默认税率 > 0(不启用)。"""
line = line or {}
raw = line.get("tax_rate")
if raw not in (None, ""):
return Decimal(str(raw))
return Decimal(str(getattr(product, "tax_rate", 0) or 0))
# ============================================================
# 单号生成(迭代第 4 轮:并发安全 + 删除安全)
# ============================================================
import re as _re
_BILL_NO_RE = _re.compile(r"^([A-Z]+)(\d{8})(\d+)$")
# 生成单号时的最大重试次数(并发冲突时重试)
BILL_NO_MAX_ATTEMPTS = 20
def next_bill_no(tenant, prefix: str, model, *, date_str: str | None = None,
field: str = "bill_no") -> str:
"""按 `前缀+日期+4位序号` 生成单号。
相比旧实现(`count() + 1`)修了两个问题:
1. **并发撞号**:多个事务同时 `count()` 会拿到同一个数 → 生成相同单号
(PG 实测:9 个并发建单产生重复的 `XS202609110001`)。
2. **删除后重复**:`count()` 在删单后会变小,重新生成的号可能已被用过。
例如建了 001/002 后删掉 001,count=1,下一张又是 002 → 撞唯一约束。
改用 `MAX(序号) + 1`,配合调用方的唯一约束冲突重试,
保证"不重复、不跳号(除了冲突重试)"。
注意:**本函数只负责取号,不保证唯一**。真正保证唯一的是
数据库唯一约束 + 调用方的重试(见 `create_with_unique_bill_no`)。
"""
from django.db.models import Max
day = date_str or date.today().strftime("%Y%m%d")
head = f"{prefix}{day}"
# 取该前缀+日期下已有单号的最大序号
existing = (
model.objects.filter(tenant=tenant, **{f"{field}__startswith": head})
.values_list(field, flat=True)
)
max_seq = 0
for no in existing:
m = _BILL_NO_RE.match(no)
if m and m.group(2) == day:
try:
max_seq = max(max_seq, int(m.group(3)))
except ValueError:
continue
return f"{head}{max_seq + 1:04d}"
def create_with_unique_bill_no(model, *, tenant, prefix: str, defaults: dict,
field: str = "bill_no",
attempts: int = BILL_NO_MAX_ATTEMPTS):
"""创建一条带唯一单号的记录,冲突时自动重试。
用**乐观并发控制**:不依赖锁(`select_for_update` 锁不住不存在的行——
这是上一轮修库存竞态时学到的教训),而是"先试、撞了再换号重试"。
`defaults` 为除 bill_no 外的创建参数。
"""
from django.db import IntegrityError, transaction
last_exc = None
for _ in range(attempts):
bill_no = next_bill_no(tenant, prefix, model, field=field)
try:
# 保存点:冲突只回滚这一次尝试,不污染外层事务
with transaction.atomic():
return model.objects.create(
tenant=tenant, **{field: bill_no}, **defaults
)
except IntegrityError as exc:
last_exc = exc
continue
raise RuntimeError(
f"生成唯一单号失败(尝试 {attempts} 次):{prefix} · {last_exc}"
)