"""核算预演纯函数:只分配已明确的平账组,不写正式台账。""" from datetime import date from decimal import ROUND_HALF_UP, Decimal from fractions import Fraction from app.services import formula from app.services.personnel_roles import role_on from app.services.rate_rules import decide CENT = Decimal("0.01") ZERO = Decimal("0") START = date(2026, 3, 1) AUGUST = date(2026, 8, 1) def money(value) -> Decimal: """拒绝非有限金额,不用浮点数参与计算。""" if isinstance(value, float): raise ValueError("金额必须为十进制字符串") result = Decimal(str(value)) if not result.is_finite(): raise ValueError("金额必须为有限数字") return result def amount_text(value: Decimal) -> str: """在展示边界按分四舍五入。""" return str(value.quantize(CENT, rounding=ROUND_HALF_UP)) def _day(value) -> date: return date.fromisoformat(str(value)[:10]) def _key(row): return (_day(row["date"]), int(row["source_id"]), int(row.get("line_id", 0))) def _beneficiary(shipment, receipt, facts, as_of): owner = str(shipment.get("personnel_id") or "") applicable = sorted( (f for f in facts if f.get("business_key") == shipment.get("business_key")), key=lambda f: (_day(f["effective_date"]), f.get("version", 0)), ) for fact in applicable: # 在职交接仍按每次发货的归属,不把整笔绩效按固定比例拆分。 if ( fact["mode"] == "RESIGNATION" and _day(fact["effective_date"]) <= _day(as_of) and owner == str(fact["from_personnel_id"]) ): owner = str(fact["to_personnel_id"]) return owner def _calculate(shipment, receipt, matched, previously_matched, facts, params): reasons = list(shipment.get("pending", [])) + list(receipt.get("pending", [])) if any( handover.get("business_key") == shipment.get("business_key") and handover.get("mode") == "ACTIVE" and not handover.get("shipping_ownership_confirmed") for handover in facts.get("handovers", []) ): reasons.append("HANDOVER_SHIPMENTS_PENDING") effective = max(_day(shipment["date"]), _day(receipt["date"])) is_prepayment = _day(receipt["date"]) < _day(shipment["date"]) if is_prepayment and not params.get("prepayment_parameter_basis"): reasons.append("PREPAYMENT_PARAMETERS_PENDING") if effective >= AUGUST and not params.get("august_confirmed", False): reasons.append("AUGUST_RULE_PENDING") owner = _beneficiary(shipment, receipt, facts.get("handovers", []), facts["as_of"]) person = facts.get("personnel", {}).get(owner) original_person = facts.get("personnel", {}).get(str(shipment.get("personnel_id"))) role = role_on(person, effective) original_role = role_on(original_person, _day(shipment["date"])) if not person: reasons.append("PERSONNEL_PENDING") elif person.get("status") == 0: known_later_departure = any( record.get("mode") == "RESIGNATION" and record.get("business_key") == shipment.get("business_key") and str(record.get("from_personnel_id")) == owner and _day(record["effective_date"]) > _day(facts["as_of"]) for record in facts.get("handovers", []) ) if not known_later_departure: reasons.append("HANDOVER_PENDING") if person and (role is None or original_role is None): reasons.append("PERSONNEL_ROLE_PENDING") cost = shipment.get("cost_total") if shipment.get("cost_month_basis") == "RECEIPT": cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7]) if cost is None: reasons.append("GUIDANCE_PENDING") category = shipment.get("category") if category not in ("municipal_water", "rail_transit"): reasons.append("PRODUCT_CATEGORY_PENDING") customer_type = shipment.get("customer_type") if customer_type not in ("personal", "manager_personal", "enterprise"): reasons.append("CUSTOMER_CATEGORY_PENDING") parameter_date = ( _day(receipt["date"]) if is_prepayment and params.get("prepayment_parameter_basis") == "RECEIPT" else effective ) rate_params = params.get(parameter_date.isoformat()) if rate_params is None: reasons.append("PARAMETERS_PENDING") if reasons: return {"status": "PENDING", "reasons": sorted(set(reasons)), "performance": None} total = money(shipment["amount"]) cost_total = money(cost) # 累计舍入差分使同一发货拆成多笔回款时,成本分摊合计仍等于总成本。 before = (cost_total * previously_matched / total).quantize(CENT, rounding=ROUND_HALF_UP) after = (cost_total * (previously_matched + matched) / total).quantize( CENT, rounding=ROUND_HALF_UP ) allocated_cost = after - before threshold = Fraction(str(rate_params["thresholds"][category])) converted = formula.converted_profit( matched, formula.profit_rate(matched - allocated_cost, matched), threshold ) days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"])) manager_own = original_role == "sales_manager" rate, basis, rule = decide( rate_params, category=category, customer_type=customer_type, role_code="sales_manager" if manager_own else role, days=days, prepayment=is_prepayment, ) calculation_base = matched if basis == "sales" else converted return { "status": "READY", "reasons": [], "personnel_id": owner, "role_code": role, "original_role_code": original_role, "manager_id": shipment.get("manager_id"), "manager_pending": not bool(shipment.get("manager_id")), "effective_date": effective.isoformat(), "days": days, "allocated_cost": amount_text(allocated_cost), "converted_profit": amount_text(converted), "profit_threshold": str(rate_params["thresholds"][category]), "rate": str(rate), "rate_percent": format( (Decimal(rate.numerator) * 100 / Decimal(rate.denominator)).quantize(Decimal("0.0001")), "f", ) .rstrip("0") .rstrip(".") + "%", "parameters": rate_params, "parameter_date": parameter_date.isoformat(), "performance": amount_text(formula.performance_amount(calculation_base, rate)), "calculation_base": amount_text(calculation_base), "basis": basis, "rule": rule, "excluded_manager_own": manager_own or role == "sales_manager", } def preview_group(group, shipments, receipts, facts, params, until: date) -> dict: """按明确分组分配资金,缺条件时保留金额和原因,不猜测绩效。""" if not group.get("scope_confirmed"): return {"status": "PENDING", "reasons": ["GROUP_SCOPE_PENDING"], "lines": []} if not group.get("opening_confirmed") or group.get("opening_balance") is None: return {"status": "PENDING", "reasons": ["OPENING_BALANCE_PENDING"], "lines": []} if not group.get("historical_settlement_confirmed"): return {"status": "PENDING", "reasons": ["HISTORICAL_SETTLEMENT_PENDING"], "lines": []} shipments = [row for row in shipments if START <= _day(row["date"]) <= until] receipts = [row for row in receipts if START <= _day(row["date"]) <= until] excluded_shipments = [row for row in shipments if row.get("performance_excluded")] shipments = [row for row in shipments if not row.get("performance_excluded")] excluded_receipts = sorted((row for row in receipts if row.get("exclusion")), key=_key) receipts = [row for row in receipts if not row.get("exclusion")] amount_pending = { reason for row in shipments for reason in row.get("pending", []) if reason in ("FREIGHT_PENDING", "TAX_AMOUNT_PENDING") } if amount_pending: # 净销售额未知会改变后续所有分配,不能先猜零运费再计算同组其他业务。 return {"status": "PENDING", "reasons": sorted(amount_pending), "lines": []} opening = money(group["opening_balance"]) if opening < 0: raise ValueError("期初应收不能为负,预收余额应单独登记") invalid = [r for r in (*shipments, *receipts) if money(r["amount"]) <= 0] if invalid: return {"status": "PENDING", "reasons": ["NEGATIVE_OR_ZERO_DOCUMENT"], "lines": []} if any("NEGATIVE_EVENT_REVIEW_REQUIRED" in r.get("pending", []) for r in shipments): return {"status": "PENDING", "reasons": ["NEGATIVE_EVENT_REVIEW_REQUIRED"], "lines": []} if any( money(r["amount"]) != money(r["amount"]).quantize(CENT) for r in (*shipments, *receipts) ): return {"status": "PENDING", "reasons": ["AMOUNT_PRECISION_PENDING"], "lines": []} groups = {str(r.get("group_key", "")) for r in (*shipments, *receipts)} if groups - {str(group["key"])}: raise ValueError("禁止跨平账组分配") ship_rows = sorted(shipments, key=_key) cash_rows = sorted(receipts, key=_key) ship_keys = [(str(r["source_id"]), str(r.get("line_id", 0))) for r in ship_rows] receipt_keys = [str(r["source_id"]) for r in cash_rows] if len(set(ship_keys)) != len(ship_keys) or len(set(receipt_keys)) != len(receipt_keys): raise ValueError("预演输入包含重复单据或明细") # 凭证状态未证实的收款不能消耗后续发货,否则会改变其他款的 FIFO 顺序。 if any(not r.get("voucher_confirmed") for r in cash_rows): reasons = {"VOUCHER_STATE_PENDING"} for receipt in cash_rows: reasons.update(receipt.get("pending", [])) return {"status": "PENDING", "reasons": sorted(reasons), "lines": []} consumed = [ZERO for _ in ship_rows] lines = [ { "kind": "EXCLUDED", "shipment_id": str(row["source_id"]), "shipment_no": row.get("source_no"), "shipment_date": row["date"], "line_id": str(row.get("line_id", 0)), "amount": amount_text(money(row["amount"])), "status": "EXCLUDED", "performance": None, "reasons": [row.get("exclusion_reason") or "CORRECTION_EXCLUDED"], "correction_pair_key": row.get("correction_pair_key"), } for row in excluded_shipments ] + [ { "kind": "EXCLUDED", "receipt_id": str(row["source_id"]), "receipt_no": row.get("source_no"), "receipt_date": row["date"], "amount": amount_text(money(row["amount"])), "status": "EXCLUDED", "performance": None, "reasons": [row["exclusion"]], "voucher_evidence": row.get("voucher_evidence"), "accounting_voucher_code": row.get("accounting_voucher_code"), } for row in excluded_receipts ] remaining_opening = opening cash_total = ZERO for receipt in cash_rows: available = money(receipt["amount"]) cash_total += available if remaining_opening: matched = min(available, remaining_opening) if matched: lines.append( { "kind": "OPENING", "receipt_id": str(receipt["source_id"]), "receipt_no": receipt.get("source_no"), "receipt_date": receipt["date"], "voucher_evidence": receipt.get("voucher_evidence"), "accounting_voucher_code": receipt.get("accounting_voucher_code"), "amount": amount_text(matched), "status": "LEGACY", "performance": None, "reasons": ["LEGACY_SYSTEM_CALCULATES"], } ) remaining_opening -= matched available -= matched for i, shipment in enumerate(ship_rows): if available == 0: break remaining = money(shipment["amount"]) - consumed[i] matched = min(available, remaining) if matched <= 0: continue result = _calculate( shipment, receipt, matched, consumed[i], {**facts, "as_of": until.isoformat()}, params, ) lines.append( { "kind": "SHIPMENT", "receipt_id": str(receipt["source_id"]), "receipt_no": receipt.get("source_no"), "receipt_date": receipt["date"], "voucher_evidence": receipt.get("voucher_evidence"), "accounting_voucher_code": receipt.get("accounting_voucher_code"), "shipment_id": str(shipment["source_id"]), "shipment_no": shipment.get("source_no"), "shipment_date": shipment["date"], "line_id": str(shipment.get("line_id", 0)), "product_id": shipment.get("product_id"), "quantity": shipment.get("quantity"), "cost_total": shipment.get("cost_total"), "gross_tax_amount": shipment.get("gross_tax_amount"), "freight_amount": shipment.get("freight_amount"), "cost_month_basis": shipment.get("cost_month_basis"), "cost_by_month": shipment.get("cost_by_month"), "shipment_remaining_after": amount_text(remaining - matched), "amount": amount_text(matched), **result, } ) consumed[i] += matched available -= matched if available: lines.append( { "kind": "PREPAYMENT", "receipt_id": str(receipt["source_id"]), "receipt_no": receipt.get("source_no"), "receipt_date": receipt["date"], "voucher_evidence": receipt.get("voucher_evidence"), "accounting_voucher_code": receipt.get("accounting_voucher_code"), "amount": amount_text(available), "status": "WAITING_SHIPMENT", "performance": None, "reasons": ["NO_ACTUAL_SHIPMENT"], } ) allocated = sum((money(r["amount"]) for r in lines if r["kind"] != "EXCLUDED"), ZERO) if allocated != cash_total: raise ValueError("回款分配金额不守恒") return { "status": "PARTIAL" if any(r["status"] == "PENDING" for r in lines) else "SUCCEEDED", "reasons": [], "lines": lines, "receipt_total": amount_text(cash_total), "excluded_receipt_total": amount_text( sum((money(r["amount"]) for r in excluded_receipts), ZERO) ), "excluded_shipment_total": amount_text( sum((money(r["amount"]) for r in excluded_shipments), ZERO) ), "allocated_total": amount_text(allocated), "opening_remaining": amount_text(remaining_opening), "shipment_remaining": amount_text( sum( ( money(row["amount"]) - used for row, used in zip(ship_rows, consumed, strict=True) ), ZERO, ) ), "prepayment_remaining": amount_text( sum((money(r["amount"]) for r in lines if r["kind"] == "PREPAYMENT"), ZERO) ), "performance_total": amount_text( sum((money(r["performance"]) for r in lines if r.get("performance") is not None), ZERO) ), "month_performance_total": amount_text( sum( ( money(r["performance"]) for r in lines if r.get("performance") is not None and r.get("effective_date", "")[:7] == until.isoformat()[:7] ), ZERO, ) ), }