"""多份保险计划书的确定性对比计算。""" from __future__ import annotations from typing import Iterable from insurance.ppt.irr import compute_irr_ma, ia_irr_cap DEFAULT_COMPARISON_YEARS = [5, 10, 20, 30] MAX_COMPARISON_PRODUCTS = 4 def build_comparison_contract( products: list[dict], mode: str = "single", comparison_years: Iterable[int] | None = None, ) -> dict: """将归一化产品转换为渲染器可直接使用的对比契约。""" normalized_mode = (mode or "single").lower() if normalized_mode not in {"single", "compare", "portfolio"}: raise ValueError("generationMode 必须是 single、compare 或 portfolio") if normalized_mode == "single": return {"mode": "single", "products": [], "years": [], "warnings": []} if len(products) < 2: raise ValueError("产品对比或组合方案至少需要 2 份有效计划书") if len(products) > MAX_COMPARISON_PRODUCTS: raise ValueError(f"一次最多对比 {MAX_COMPARISON_PRODUCTS} 份计划书") years = _normalize_years(comparison_years) kinds = {str(p.get("kind") or "").lower() for p in products} currencies = { str((p.get("policy") or {}).get("currency") or "").upper() for p in products if (p.get("policy") or {}).get("currency") } warnings: list[str] = [] if normalized_mode == "compare": if len(kinds) != 1: raise ValueError("不同险种不能直接横向排名,请选择“组合方案”模式") if len(currencies) > 1: raise ValueError("不同币种不能直接比较,请上传相同币种的计划书") elif len(currencies) > 1: warnings.append("组合中包含不同币种,金额仅分别展示,不计算合计") insured_signatures = { ( (p.get("insured") or {}).get("age"), str((p.get("insured") or {}).get("gender") or "").lower(), ) for p in products } if len(insured_signatures) > 1: warnings.append("计划书的受保人年龄或性别不一致,比较结论需谨慎使用") return { "mode": normalized_mode, "years": years, "currency": next(iter(currencies), ""), "productKinds": sorted(kinds), "products": [_build_product_metrics(product, years) for product in products], "warnings": warnings, } def _normalize_years(years: Iterable[int] | None) -> list[int]: values = [] for value in years or DEFAULT_COMPARISON_YEARS: try: year = int(value) except (TypeError, ValueError): continue if 1 <= year <= 100 and year not in values: values.append(year) return sorted(values) or list(DEFAULT_COMPARISON_YEARS) def _build_product_metrics(product: dict, years: list[int]) -> dict: policy = product.get("policy") or {} rows = product.get("benefitRows") or [] annual_premium = _number(policy.get("annualPremium")) pay_years = int(_number(policy.get("payYears"))) total_premium = _number( policy.get("contractualTotalPremium") or policy.get("totalPremium") or annual_premium * pay_years ) currency = str(policy.get("currency") or "").upper() year_values = {} for year in years: row = _find_year(rows, year) if not row: year_values[str(year)] = None continue total_value = _number(row.get("totalSurrenderValue")) guaranteed_value = _number(row.get("guaranteedCashValue")) death_benefit = _number( row.get("nonGuaranteedDeathBenefit") or row.get("deathBenefit") or row.get("guaranteedDeathBenefit") ) irr = compute_irr_ma( annual_premium, pay_years, total_value, year, ia_irr_cap(currency or "HKD"), ) year_values[str(year)] = { "guaranteedValue": guaranteed_value, "totalValue": total_value, "deathBenefit": death_benefit, "irr": round(irr * 100, 2) if irr is not None else None, "sourcePage": row.get("sourcePage"), } return { "fileId": product.get("fileId", ""), "pdfName": product.get("pdfName", ""), "companyId": product.get("companyId", ""), "productName": product.get("productName", ""), "kind": product.get("kind", "savings"), "currency": currency, "annualPremium": annual_premium, "payYears": pay_years, "totalPremium": total_premium, "sumInsured": _number(policy.get("sumInsured")), "breakevenYear": _find_breakeven_year(rows), "yearValues": year_values, } def _find_year(rows: list[dict], year: int) -> dict | None: for row in rows: if int(_number(row.get("policyYear"))) == year: return row return None def _find_breakeven_year(rows: list[dict]) -> int | None: for row in rows: premium = _number(row.get("totalPremiumPaid")) value = _number(row.get("totalSurrenderValue")) if premium > 0 and value >= premium: return int(_number(row.get("policyYear"))) return None def _number(value) -> float: try: return float(value or 0) except (TypeError, ValueError): return 0.0