"""跨 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}" )