baodan/api/insurance/plan_data/snapshot_validation.py
2026-08-04 10:44:34 +08:00

119 lines
4.8 KiB
Python

"""PlanData v1 的结构、冲突和证据门禁。"""
from __future__ import annotations
from insurance.plan_data.field_values import validate_field_value
AMOUNT_TOKENS = (
"premium", "sumassured", "cashvalue", "surrendervalue",
"deathbenefit", "accountvalue", "amount", "withdrawal",
)
# 只有核心保单金额需要在确认时提示证据缺失。利益演示、退保价值和
# 提领方案属于可选展示数据,不参与确认门禁。
CRITICAL_AMOUNT_PATHS = {
"policy.annualPremium",
"policy.sumAssured",
}
def validate_plan_data(plan_data: dict, *, for_confirmation: bool = False) -> dict:
issues: list[dict] = []
if not isinstance(plan_data, dict):
issues.append(_issue("PLAN_DATA_INVALID", "PlanData 必须是对象", "planData"))
return _result(issues)
for section in ("identity", "policy", "benefitScenarios", "assumptions"):
if section not in plan_data:
issues.append(_issue("PLAN_SECTION_MISSING", "PlanData 分区缺失", f"planData.{section}"))
if not isinstance(plan_data.get("identity"), dict):
issues.append(_issue("PLAN_SECTION_INVALID", "身份信息格式无效", "planData.identity"))
if not isinstance(plan_data.get("policy"), dict):
issues.append(_issue("PLAN_SECTION_INVALID", "保单信息格式无效", "planData.policy"))
if not isinstance(plan_data.get("benefitScenarios"), list):
issues.append(_issue("PLAN_SECTION_INVALID", "利益场景格式无效", "planData.benefitScenarios"))
field_values = list(iter_field_values(plan_data))
for path, value in field_values:
issues.extend(validate_field_value(value, f"planData.{path}"))
if for_confirmation:
issues.extend(_required_confirmation_fields(plan_data))
for path, value in field_values:
if value.get("status") == "conflict":
issues.append(_issue("UNRESOLVED_CONFLICT", "字段冲突尚未解决", f"planData.{path}"))
if _is_exported_amount(path, value) and not _has_traceable_source(value):
issues.append(_issue(
"CRITICAL_EVIDENCE_MISSING",
"导出金额缺少 PDF 证据或人工覆盖原因",
f"planData.{path}",
severity="warning",
))
return _result(issues)
def iter_field_values(value, prefix: str = ""):
if isinstance(value, dict):
if "status" in value and "value" in value and "evidence" in value:
yield prefix, value
return
for key, item in value.items():
child = f"{prefix}.{key}" if prefix else str(key)
yield from iter_field_values(item, child)
elif isinstance(value, list):
for index, item in enumerate(value):
child = f"{prefix}.{index}" if prefix else str(index)
yield from iter_field_values(item, child)
def _required_confirmation_fields(plan_data: dict) -> list[dict]:
required = (
("identity.insuredAge", (plan_data.get("identity") or {}).get("insuredAge")),
("identity.insuredGender", (plan_data.get("identity") or {}).get("insuredGender")),
("policy.currency", (plan_data.get("policy") or {}).get("currency")),
)
issues = []
for path, field in required:
if not isinstance(field, dict) or field.get("status") == "missing" or field.get("value") in (None, ""):
issues.append(_issue("REQUIRED_FIELD_MISSING", "确认所需字段缺失", f"planData.{path}"))
return issues
def _is_exported_amount(path: str, field: dict) -> bool:
if field.get("status") in {"missing", "derived"} or field.get("value") is None:
return False
if path not in CRITICAL_AMOUNT_PATHS:
return False
token = path.replace("_", "").lower()
if any(non_amount in token for non_amount in ("period", "year", "age", "label", "source")):
return False
return any(item in token for item in AMOUNT_TOKENS)
def _has_traceable_source(field: dict) -> bool:
if field.get("status") == "confirmed" and str(field.get("overrideReason") or "").strip():
return True
return any(
isinstance(item, dict)
and item.get("documentId")
and item.get("pageNumber")
and item.get("bbox")
for item in field.get("evidence") or []
)
def _result(issues: list[dict]) -> dict:
blocking = sum(1 for item in issues if item.get("severity") == "error")
warnings = sum(1 for item in issues if item.get("severity") == "warning")
conflicts = sum(1 for item in issues if item.get("code") == "UNRESOLVED_CONFLICT")
return {
"blockingCount": blocking,
"warningCount": warnings,
"conflictCount": conflicts,
"issues": issues,
}
def _issue(code: str, message: str, path: str, severity: str = "error") -> dict:
return {"code": code, "message": message, "path": path, "severity": severity}