"""缴费模式切换校验(与前端 protocol-pay-mode-switch.ts 规则一致)""" from __future__ import annotations from decimal import Decimal from typing import Any, Dict, List, Optional from app.services.protocol_payment_installment import is_legacy_installment_ratios LOCKED_STATUSES = frozenset({"jfqrz", "yjf", "tfqrz", "ytf"}) # 仅阻断进行中的缴费/退费确认;已退费(ytf)为终态,允许随协议单保存(明细只读) BLOCKING_STATUSES = frozenset({"jfqrz", "tfqrz"}) def _dec(v: Any) -> Decimal: if v is None: return Decimal("0") return Decimal(str(v)) def dj_due_amount_mismatch_hint( contract_amount: Optional[Any], details: Optional[list], ) -> Optional[str]: """趸交:应缴合计与合同金额不一致时返回提醒文案(不阻断保存)。""" rows = [dict(d) for d in (details or [])] contract = _dec(contract_amount) if not contract: return None total_due = sum( _dec(r.get("due_amount") if r.get("due_amount") is not None else r.get("dueAmount")) for r in rows ) if abs(total_due - contract) > Decimal("0.015"): return f"趸交各明细应缴金额之和应为 {contract:.2f},当前为 {total_due:.2f},请核对" return None def validate_payment_details_for_save( pay_mode: str, contract_amount: Optional[Any], installment_count: Optional[int], details: Optional[list], installment_ratios: Optional[List[float]] = None, installment_advance_days: Optional[int] = None, locked_rows_are_read_only: bool = False, ) -> None: """保存缴费明细前的 P0 校验。 趸交应缴合计与合同金额不一致仅作前端提醒,不在此阻断;按用户填写原样保存。 """ if pay_mode not in {'dj', 'fq', 'djfq', 'fgd'}: raise ValueError('不支持的缴费模式') rows = [dict(d) for d in (details or [])] contract = _dec(contract_amount) if not locked_rows_are_read_only: for r in rows: status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip() if status in BLOCKING_STATUSES: raise ValueError("存在付款确认中或退费处理中的明细,无法保存") yjf_rows = [r for r in rows if (r.get("pay_status") or r.get("payStatus")) == "yjf"] if pay_mode in ("fq", "djfq", "fgd"): count = int(installment_count or 0) if count < 1: raise ValueError("分期/分改趸模式须填写期数") if installment_advance_days is not None and int(installment_advance_days) < 0: raise ValueError("应缴费提前天数不能为负数") if installment_ratios is not None: values = [Decimal(str(x)) for x in installment_ratios] if len(values) != count: raise ValueError("分期金额数量须与分期期数一致") if is_legacy_installment_ratios(values, Decimal(str(contract)) if contract else None): if abs(sum(values) - Decimal("100")) > Decimal("0.01"): raise ValueError("缴费比例之和须等于 100%") if len(rows) != count: raise ValueError(f"分期明细条数应等于分期期数({count})") if not locked_rows_are_read_only: fq_forbidden = frozenset({"jfqrz", "tfqrz"}) for r in rows: status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip() if status in fq_forbidden: raise ValueError("分期模式下存在不可保存的缴费状态") if yjf_rows: max_paid_no = max( int(r.get("installment_no") or r.get("installmentNo") or 0) for r in yjf_rows ) if max_paid_no > count: raise ValueError("分期期数不能少于已缴费的最大期次")