"""验证模块 — 数据完整性和导出就绪检查。""" from dataclasses import dataclass, field from typing import Optional @dataclass class FormalDeckIssue: code: str level: str # "error" | "warn" message: str path: str = "" # 字段路径,如 "insured.age"、"benefitRows[5].totalSurrenderValue" section: str = "" # 分区:fields、benefitRows、withdrawalRows、coverageItems、indexAccounts suggested_action: str = "" # fill_or_confirm | modify | review | none @dataclass class ValidationIssue: field: str message: str level: str # "error" | "warn" def _is_continuous(rows: list[dict]) -> bool: """检查保单年度是否连续。""" years = sorted(r.get("policyYear", 0) for r in rows if isinstance(r, dict)) for i in range(1, len(years)): if years[i] != years[i - 1] + 1: return False return True def _has_sufficient_milestone_coverage(rows: list[dict]) -> bool: """判断非连续利益表是否覆盖正式方案所需的关键年度。""" years = { int(r.get("policyYear", 0)) for r in rows if isinstance(r, dict) and _safe_number(r.get("policyYear")) > 0 } return len(years) >= 5 and {10, 20, 30}.issubset(years) and max(years) >= 30 def validate_formal_savings_plan(plan: dict) -> list[FormalDeckIssue]: """验证归一化储蓄险数据的导出就绪性。""" issues = [] def err(code, msg, path="", section="fields", suggested_action="review"): issues.append(FormalDeckIssue(code, "error", msg, path=path, section=section, suggested_action=suggested_action)) def warn(code, msg, path="", section="fields", suggested_action="review"): issues.append(FormalDeckIssue(code, "warn", msg, path=path, section=section, suggested_action=suggested_action)) if not plan.get("productName"): err("PRODUCT_NAME_MISSING", "产品名称缺失", path="productName", suggested_action="fill_or_confirm") insured = plan.get("insured", {}) if not insured.get("age"): err("INSURED_AGE_MISSING", "被保险人年龄缺失", path="insured.age", suggested_action="fill_or_confirm") if insured.get("smoker") not in ("yes", "no"): warn("SMOKER_STATUS_UNKNOWN", "吸烟状态未知,请核对计划书", path="insured.smoker") policy = plan.get("policy", {}) if not policy.get("currency"): err("CURRENCY_MISSING", "币种缺失", path="policy.currency", suggested_action="fill_or_confirm") if _safe_number(policy.get("annualPremium")) <= 0: err("ANNUAL_PREMIUM_INVALID", "年缴保费必须大于 0", path="policy.annualPremium", suggested_action="fill_or_confirm") if _safe_number(policy.get("payYears")) <= 0: err("PAY_YEARS_INVALID", "缴费年期必须大于 0", path="policy.payYears", suggested_action="fill_or_confirm") benefit_rows = plan.get("benefitRows", []) if len(benefit_rows) < 20: if _has_sufficient_milestone_coverage(benefit_rows): warn( "BENEFIT_ROWS_MILESTONE_ONLY", f"利益演示仅提供里程碑年度(当前 {len(benefit_rows)} 行),已覆盖 10、20、30 年,可继续生成", path="benefitRows", section="benefitRows", ) else: warn( "BENEFIT_ROWS_INCOMPLETE", f"利益演示数据不足(当前 {len(benefit_rows)} 行,且未覆盖 10、20、30 年)", path="benefitRows", section="benefitRows", ) if benefit_rows and not _is_continuous(benefit_rows): warn( "BENEFIT_ROWS_DISCONTINUOUS", "利益演示采用里程碑年度,保单年度不连续,请在生成前核对关键年份", path="benefitRows", section="benefitRows", ) source = plan.get("source", {}) if not source.get("pdfHash"): err("SOURCE_HASH_MISSING", "缺少 PDF 哈希(来源追溯)", path="source.pdfHash") if not any(r.get("sourcePage") for r in benefit_rows): warn("BENEFIT_SOURCE_PAGE_MISSING", "利益演示缺少来源页码", path="benefitRows", section="benefitRows") withdrawal_rows = plan.get("withdrawalRows", []) if withdrawal_rows and not _is_continuous(withdrawal_rows): warn("WITHDRAWAL_ROWS_DISCONTINUOUS", "提领方案保单年度不连续", path="withdrawalRows", section="withdrawalRows") if withdrawal_rows and not any(r.get("sourcePage") for r in withdrawal_rows): warn("WITHDRAWAL_SOURCE_PAGE_MISSING", "提领方案缺少来源页码", path="withdrawalRows", section="withdrawalRows") # 检查 total_surrender_value >= guaranteed_cash_value 一致性 for i, row in enumerate(benefit_rows): gcv = _safe_number(row.get("guaranteedCashValue")) rev = _safe_number(row.get("reversionaryBonus")) term = _safe_number(row.get("terminalDividend")) total = _safe_number(row.get("totalSurrenderValue")) if total > 0 and gcv > 0 and total < gcv: year = row.get("policyYear", "?") expected = gcv + rev + term warn( "TOTAL_SURRENDER_VALUE_INCONSISTENT", f"第 {year} 年总退保价值({total})低于保证现金价值({gcv})," f"应为 {expected},请核对数据是否列错位", path=f"benefitRows[{i}].totalSurrenderValue", section="benefitRows", ) return issues def validate_formal_ci_plan(plan: dict) -> list[FormalDeckIssue]: """验证归一化重疾险数据。""" issues = [] def err(code, msg): issues.append(FormalDeckIssue(code, "error", msg)) def warn(code, msg): issues.append(FormalDeckIssue(code, "warn", msg)) if not plan.get("productName"): err("CI_PRODUCT_NAME_MISSING", "产品名称缺失") if not plan.get("insured", {}).get("age"): err("CI_INSURED_AGE_MISSING", "被保险人年龄缺失") if plan.get("insured", {}).get("smoker") not in ("yes", "no"): warn("CI_SMOKER_STATUS_UNKNOWN", "吸烟状态未知,请核对计划书") if not plan.get("policy", {}).get("currency"): err("CI_CURRENCY_MISSING", "币种缺失") if _safe_number(plan.get("policy", {}).get("sumInsured")) <= 0: err("CI_SUM_INSURED_INVALID", "保额必须大于 0") if _safe_number(plan.get("policy", {}).get("annualPremium")) <= 0: err("CI_ANNUAL_PREMIUM_INVALID", "年缴保费必须大于 0") if _safe_number(plan.get("policy", {}).get("payYears")) <= 0: err("CI_PAY_YEARS_INVALID", "缴费年期必须大于 0") if not plan.get("coverageItems"): warn("CI_COVERAGE_ITEMS_MISSING", "保障项目列表为空,相关页面将隐藏") return issues def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]: """验证归一化 IUL 数据。""" issues = [] def err(code, msg): issues.append(FormalDeckIssue(code, "error", msg)) def warn(code, msg): issues.append(FormalDeckIssue(code, "warn", msg)) if not plan.get("productName"): err("IUL_PRODUCT_NAME_MISSING", "产品名称缺失") if not plan.get("insured", {}).get("age"): err("IUL_INSURED_AGE_MISSING", "被保险人年龄缺失") if plan.get("insured", {}).get("smoker") not in ("yes", "no"): warn("IUL_SMOKER_STATUS_UNKNOWN", "吸烟状态未知,请核对计划书") policy = plan.get("policy", {}) if not policy.get("currency"): err("IUL_CURRENCY_MISSING", "币种缺失") if _safe_number(plan.get("policy", {}).get("sumInsured")) <= 0: err("IUL_SUM_INSURED_INVALID", "保额必须大于 0") if max( _safe_number(policy.get("targetPremium")), _safe_number(policy.get("annualPremium")), _safe_number(policy.get("initialPremium")), ) <= 0: err("IUL_PREMIUM_INVALID", "目标保费、计划保费或首期保费至少填写一项") if not plan.get("indexAccounts"): warn("IUL_INDEX_ACCOUNT_MISSING", "指数账户配置为空,相关页面将隐藏") benefit_rows = plan.get("benefitRows", []) if len(benefit_rows) < 20: warn("IUL_BENEFIT_ROWS_INCOMPLETE", f"利益演示行数不足(当前 {len(benefit_rows)} 行),可继续生成") is_continuous = _is_continuous(benefit_rows) if benefit_rows else True if benefit_rows and not is_continuous: warn("IUL_BENEFIT_ROWS_DISCONTINUOUS", "利益演示保单年度不连续,请确认 PDF 是否只提供里程碑年度") source = plan.get("source", {}) if not source.get("pdfHash"): err("IUL_SOURCE_HASH_MISSING", "缺少 PDF 哈希") if benefit_rows and not any(r.get("sourcePage") for r in benefit_rows): warn("IUL_SOURCE_PAGE_MISSING", "利益演示缺少来源页码") annual_premium = _safe_number(policy.get("annualPremium")) positive_surrender = [ _safe_number(row.get("totalSurrenderValue")) for row in benefit_rows if _safe_number(row.get("totalSurrenderValue")) > 0 ] if annual_premium > 0 and len(positive_surrender) >= 3: tiny_values = [value for value in positive_surrender if value < annual_premium * 0.01] if len(tiny_values) / len(positive_surrender) >= 0.6: err( "IUL_BENEFIT_VALUE_IMPLAUSIBLE", "多数退保价值不足年缴保费的 1%,疑似把年龄、页码或百分比识别为金额,请核对利益表", ) # 缴费年期一致性检查 payment_period = plan.get("policy", {}).get("paymentPeriod", "") if benefit_rows and payment_period and is_continuous: # 通过数据检测实际缴费年数 detected_years = 0 for i in range(1, len(benefit_rows)): prev = _safe_number(benefit_rows[i - 1].get("totalPremiumPaid")) curr = _safe_number(benefit_rows[i].get("totalPremiumPaid")) if curr > prev: detected_years += 1 # 解析声明的缴费年期 stated_years = _extract_years(payment_period) if stated_years > 0 and detected_years > 0 and abs(detected_years - stated_years) > 1: err("IUL_PAY_TERM_MISMATCH", f"缴费年期不一致:声明 {stated_years} 年,数据检测 {detected_years} 年") # 年龄合理性检查 insured_age = _safe_number(plan.get("insured", {}).get("age")) for row in benefit_rows: if isinstance(row, dict): age = _safe_number(row.get("age")) if age > 0 and (age < 0 or age > 150): err("IUL_AGE_OUT_OF_RANGE", f"年龄超出合理范围: {age}") break policy_year = _safe_number(row.get("policyYear")) expected_age = insured_age + policy_year - 1 if age > 0 and insured_age > 0 and policy_year > 0 and abs(age - expected_age) > 1: err( "IUL_AGE_YEAR_MISMATCH", f"第 {int(policy_year)} 保单年度年龄应约为 {int(expected_age)},实际为 {int(age)}", ) break return issues def validate_savings_metrics(metrics: dict) -> list[ValidationIssue]: """验证储蓄险关键指标。""" issues = [] if not metrics.get("productName"): issues.append(ValidationIssue("productName", "产品名称缺失", "error")) if _safe_number(metrics.get("insuredAge")) <= 0: issues.append(ValidationIssue("insuredAge", "被保险人年龄无效", "error")) if not metrics.get("currency"): issues.append(ValidationIssue("currency", "货币缺失", "error")) if _safe_number(metrics.get("annualPremium")) <= 0: issues.append(ValidationIssue("annualPremium", "年缴保费无效", "error")) if _safe_number(metrics.get("payYears")) <= 0: issues.append(ValidationIssue("payYears", "缴费年期无效", "error")) if metrics.get("multiple20") is None: issues.append(ValidationIssue("multiple20", "20年倍数缺失", "warn")) if metrics.get("multiple30") is None: issues.append(ValidationIssue("multiple30", "30年倍数缺失", "warn")) if metrics.get("breakevenYear") is None: issues.append(ValidationIssue("breakevenYear", "回本年度缺失", "warn")) return issues def _safe_number(value) -> float: if value is None: return 0 try: return float(value) except (ValueError, TypeError): return 0 def _extract_years(value) -> int: import re if value is None: return 0 match = re.search(r"\d+(?:\.\d+)?", str(value)) return int(float(match.group())) if match else 0