157 lines
5.3 KiB
Python
157 lines
5.3 KiB
Python
"""多份保险计划书的确定性对比计算。"""
|
|
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
|