海报确认、文案生成、海报生成增加服务端失败关闭门禁,绑定文件哈希、解析快照哈希和确认数据哈希,并返回 422 业务错误。[validators.py (line 65)](D:/work/code/python/coding/baodanagent/api/insurance/plan_data/validators.py:65) 缺失金额不再转换为 0;删除错误字段兜底和“年缴×年期=合同总保费”事实推导;里程碑冲突会阻断确认。[normalizer.py (line 14)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/normalizer.py:14) PPT 渲染器支持可空金额和实际币种,缺失值显示“待确认”,避免 float(None)、空值除法等异常。 模板必须覆盖全部输入保司和产品;自动选择排序确定化,同优先级歧义时阻断。[template_selection.py (line 4)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/template_selection.py:4) 场景判定写入 scenarioOverrideTrace,记录请求、模板、服务端及 Worker 最终判定。[routes.py (line 555)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/routes.py:555) 前端增加哈希提交、人工调整原因、模板歧义提示及真实能力说明。 冻结三份核心 Schema,并建立 Goldens manifest、说明和评估脚本。 验证结果: 后端目标回归:111 passed, 1 skipped PPT 运行时回归:81 passed 前端生产构建和 vue-tsc:通过 Python compileall:通过 三份 Schema JSON:解析通过 git diff --check:通过,仅有换行符提示
390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""PlanData 兼容门禁。
|
||
|
||
Phase 0 暂时继续使用 ``PosterCaseUpload``,但确认和生成必须经过同一组
|
||
失败关闭规则。后续不可变快照落地后,这里的纯函数可直接复用。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
|
||
ALLOWED_CURRENCIES = {"USD", "HKD", "CNY", "SGD", "EUR", "GBP"}
|
||
ALLOWED_GENDERS = {"male", "female", "男", "女"}
|
||
CONFIRMABLE_PARSE_STATUSES = {"parsed", "partial"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class GateIssue:
|
||
code: str
|
||
message: str
|
||
path: str = ""
|
||
|
||
def to_dict(self) -> dict:
|
||
return {"code": self.code, "message": self.message, "path": self.path}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class GateResult:
|
||
valid: bool
|
||
plan_data: dict
|
||
parse_snapshot_hash: str
|
||
issues: tuple[GateIssue, ...]
|
||
override_reason: str = ""
|
||
|
||
def error_data(self) -> dict:
|
||
return {
|
||
"errorCode": self.issues[0].code if self.issues else "PLAN_DATA_INVALID",
|
||
"issues": [issue.to_dict() for issue in self.issues],
|
||
}
|
||
|
||
|
||
def canonical_json_hash(value: Any) -> str:
|
||
"""以固定 JSON 表达计算 SHA-256,保留 ``None`` 与数值 ``0`` 的差异。"""
|
||
payload = json.dumps(
|
||
value,
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
allow_nan=False,
|
||
).encode("utf-8")
|
||
return hashlib.sha256(payload).hexdigest()
|
||
|
||
|
||
def parse_snapshot_hash(record) -> str:
|
||
parsed = _json_object(getattr(record, "parsed_data", None))
|
||
return canonical_json_hash(parsed) if parsed else ""
|
||
|
||
|
||
def validate_case_confirmation(record, request_data: dict, product_snapshot: dict | None = None) -> GateResult:
|
||
"""校验人工确认请求,并返回可持久化的确认数据。"""
|
||
issues: list[GateIssue] = []
|
||
plan_data = request_data.get("confirmedData")
|
||
plan_data = plan_data if isinstance(plan_data, dict) else {}
|
||
override_reason = str(request_data.get("overrideReason") or "").strip()
|
||
current_parse_hash = parse_snapshot_hash(record)
|
||
|
||
if getattr(record, "parse_status", None) not in CONFIRMABLE_PARSE_STATUSES:
|
||
issues.append(GateIssue("PARSE_STATUS_INVALID", "当前解析状态不可确认", "parseStatus"))
|
||
if not plan_data:
|
||
issues.append(GateIssue("PLAN_DATA_EMPTY", "确认数据不能为空", "confirmedData"))
|
||
|
||
expected_file_hash = str(getattr(record, "file_hash", None) or "")
|
||
submitted_file_hash = str(request_data.get("fileHash") or "")
|
||
if not expected_file_hash or submitted_file_hash != expected_file_hash:
|
||
issues.append(GateIssue("SOURCE_HASH_MISMATCH", "源文件版本已变化,请重新加载解析结果", "fileHash"))
|
||
elif not _source_file_matches(record, expected_file_hash):
|
||
issues.append(GateIssue("SOURCE_FILE_CHANGED", "源文件校验失败,请重新上传计划书", "fileHash"))
|
||
|
||
submitted_parse_hash = str(request_data.get("parseSnapshotHash") or "")
|
||
if not current_parse_hash or submitted_parse_hash != current_parse_hash:
|
||
issues.append(GateIssue("PARSE_SNAPSHOT_CHANGED", "解析结果已变化,请重新核对", "parseSnapshotHash"))
|
||
|
||
if plan_data:
|
||
issues.extend(_validate_business_fields(plan_data, product_snapshot or {}, override_reason))
|
||
|
||
if issues:
|
||
return GateResult(False, plan_data, current_parse_hash, tuple(issues), override_reason)
|
||
|
||
clean_data = dict(plan_data)
|
||
clean_data.pop("_confirmation", None)
|
||
clean_data["_confirmation"] = {
|
||
"schemaVersion": "poster-confirmation-v1",
|
||
"fileHash": expected_file_hash,
|
||
"parseSnapshotHash": current_parse_hash,
|
||
"payloadHash": canonical_json_hash(clean_data),
|
||
"confirmedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||
"overrideReason": override_reason or None,
|
||
}
|
||
return GateResult(True, clean_data, current_parse_hash, (), override_reason)
|
||
|
||
|
||
def validate_confirmed_case(record, product_snapshot: dict | None = None) -> GateResult:
|
||
"""生成前重新校验已确认数据,防止旧确认或篡改数据进入成品。"""
|
||
confirmed = _json_object(getattr(record, "confirmed_data", None))
|
||
confirmation = confirmed.get("_confirmation") if confirmed else None
|
||
plan_data = dict(confirmed) if confirmed else {}
|
||
plan_data.pop("_confirmation", None)
|
||
issues: list[GateIssue] = []
|
||
current_parse_hash = parse_snapshot_hash(record)
|
||
|
||
if not plan_data or not isinstance(confirmation, dict):
|
||
issues.append(GateIssue("DATA_NOT_CONFIRMED", "请先完成计划书数据确认", "confirmedData"))
|
||
else:
|
||
if confirmation.get("fileHash") != getattr(record, "file_hash", None):
|
||
issues.append(GateIssue("SOURCE_HASH_MISMATCH", "源文件与确认版本不一致,请重新确认", "fileHash"))
|
||
if confirmation.get("parseSnapshotHash") != current_parse_hash:
|
||
issues.append(GateIssue("PARSE_SNAPSHOT_CHANGED", "解析结果已更新,请重新确认", "parseSnapshotHash"))
|
||
if confirmation.get("payloadHash") != canonical_json_hash(plan_data):
|
||
issues.append(GateIssue("CONFIRMED_DATA_CHANGED", "已确认数据发生变化,请重新确认", "confirmedData"))
|
||
if not _source_file_matches(record, str(getattr(record, "file_hash", None) or "")):
|
||
issues.append(GateIssue("SOURCE_FILE_CHANGED", "源文件校验失败,请重新上传计划书", "fileHash"))
|
||
issues.extend(_validate_business_fields(
|
||
plan_data,
|
||
product_snapshot or {},
|
||
str(confirmation.get("overrideReason") or ""),
|
||
))
|
||
|
||
if issues:
|
||
return GateResult(False, plan_data, current_parse_hash, tuple(issues))
|
||
return GateResult(True, plan_data, current_parse_hash, ())
|
||
|
||
|
||
def _validate_business_fields(plan_data: dict, product_snapshot: dict, override_reason: str) -> list[GateIssue]:
|
||
issues: list[GateIssue] = []
|
||
plan_type = str(
|
||
plan_data.get("plan_type")
|
||
or (plan_data.get("meta") or {}).get("planType")
|
||
or product_snapshot.get("planType")
|
||
or "other"
|
||
).lower()
|
||
|
||
required = {
|
||
"savings": ("age", "gender", "currency", "annual_premium", "premium_term"),
|
||
"ci": ("age", "gender", "currency", "sum_assured"),
|
||
"iul": ("age", "gender", "currency", "sum_assured", "annual_premium"),
|
||
}.get(plan_type, ("age", "gender", "currency"))
|
||
for field in required:
|
||
if _is_missing(plan_data.get(field)):
|
||
issues.append(GateIssue("REQUIRED_FIELD_MISSING", "必填字段缺失", field))
|
||
|
||
age = _nullable_number(plan_data.get("age"))
|
||
if not _is_missing(plan_data.get("age")) and (age is None or not 0 < age <= 120):
|
||
issues.append(GateIssue("AGE_INVALID", "年龄必须是 1 至 120 的有效数字", "age"))
|
||
if not _is_missing(plan_data.get("gender")) and str(plan_data.get("gender")).lower() not in ALLOWED_GENDERS:
|
||
issues.append(GateIssue("GENDER_INVALID", "性别必须为男或女", "gender"))
|
||
currency = str(plan_data.get("currency") or "").upper()
|
||
if currency and currency not in ALLOWED_CURRENCIES:
|
||
issues.append(GateIssue("CURRENCY_INVALID", "币种不受支持或无法识别", "currency"))
|
||
|
||
positive_fields = ("annual_premium", "premium_term", "sum_assured")
|
||
for field in positive_fields:
|
||
value = plan_data.get(field)
|
||
if _is_missing(value):
|
||
continue
|
||
number = _nullable_number(value)
|
||
if number is None or number <= 0:
|
||
issues.append(GateIssue("NUMBER_INVALID", "必须填写大于 0 的有效数字", field))
|
||
for field in ("total_premium", "initial_death_benefit", "surrender_value_10", "surrender_value_20", "surrender_value_30"):
|
||
value = plan_data.get(field)
|
||
if _is_missing(value):
|
||
continue
|
||
number = _nullable_number(value)
|
||
if number is None or number < 0:
|
||
issues.append(GateIssue("AMOUNT_INVALID", "金额必须是大于或等于 0 的有效数字", field))
|
||
|
||
issues.extend(_unresolved_conflicts(plan_data))
|
||
issues.extend(_validate_benefit_table(plan_data))
|
||
issues.extend(_validate_product_match(plan_data, product_snapshot, override_reason))
|
||
issues.extend(_validate_manual_overrides(plan_data, override_reason))
|
||
issues.extend(_validate_critical_evidence(plan_data, plan_type, override_reason))
|
||
return issues
|
||
|
||
|
||
def _unresolved_conflicts(plan_data: dict) -> list[GateIssue]:
|
||
conflicts = (plan_data.get("meta") or {}).get("conflicts") or []
|
||
issues: list[GateIssue] = []
|
||
if isinstance(conflicts, dict):
|
||
conflicts = [{"field": key, **(value if isinstance(value, dict) else {})} for key, value in conflicts.items()]
|
||
if isinstance(conflicts, list):
|
||
for conflict in conflicts:
|
||
if not isinstance(conflict, dict) or str(conflict.get("status") or "unresolved") != "resolved":
|
||
path = conflict.get("field") if isinstance(conflict, dict) else "meta.conflicts"
|
||
issues.append(GateIssue("UNRESOLVED_CONFLICT", "存在未解决的数据冲突", str(path or "meta.conflicts")))
|
||
|
||
for index, row in enumerate(plan_data.get("benefit_table") or []):
|
||
if not isinstance(row, dict):
|
||
continue
|
||
conflict = row.get("conflict")
|
||
if isinstance(conflict, dict) and str(conflict.get("status") or "unresolved") != "resolved":
|
||
issues.append(GateIssue(
|
||
"UNRESOLVED_CONFLICT",
|
||
"利益表存在未解决的数据冲突",
|
||
f"benefit_table.{index}.conflict",
|
||
))
|
||
|
||
year = _nullable_number(_first_present(row, "policy_year", "policyYear", "year"))
|
||
if year is None:
|
||
continue
|
||
explicit = _nullable_number(plan_data.get(f"surrender_value_{int(year)}"))
|
||
table_value = _nullable_number(_first_present(
|
||
row,
|
||
"total_surrender_value",
|
||
"totalSurrenderValue",
|
||
"total_surrender",
|
||
"totalSurrender",
|
||
))
|
||
if explicit is not None and table_value is not None and explicit != table_value:
|
||
issues.append(GateIssue(
|
||
"UNRESOLVED_CONFLICT",
|
||
"里程碑金额与完整利益表不一致",
|
||
f"surrender_value_{int(year)}",
|
||
))
|
||
return issues
|
||
|
||
|
||
def _validate_benefit_table(plan_data: dict) -> list[GateIssue]:
|
||
rows = plan_data.get("benefit_table")
|
||
if rows is None:
|
||
return []
|
||
if not isinstance(rows, list):
|
||
return [GateIssue("BENEFIT_TABLE_INVALID", "利益表格式无效", "benefit_table")]
|
||
|
||
issues: list[GateIssue] = []
|
||
amount_fields = (
|
||
("total_premium_paid", "totalPremiumPaid"),
|
||
("guaranteed_cash_value", "guaranteedCashValue"),
|
||
("non_guaranteed_cash_value", "nonGuaranteedCashValue"),
|
||
("total_surrender_value", "totalSurrenderValue", "total_surrender", "totalSurrender"),
|
||
("death_benefit", "deathBenefit"),
|
||
)
|
||
for index, row in enumerate(rows):
|
||
if not isinstance(row, dict):
|
||
issues.append(GateIssue("BENEFIT_ROW_INVALID", "利益表行格式无效", f"benefit_table.{index}"))
|
||
continue
|
||
year = _nullable_number(_first_present(row, "policy_year", "policyYear", "year"))
|
||
if year is None or year <= 0 or not year.is_integer():
|
||
issues.append(GateIssue("BENEFIT_YEAR_INVALID", "保单年度必须为正整数", f"benefit_table.{index}.policy_year"))
|
||
for aliases in amount_fields:
|
||
field = aliases[0]
|
||
value = _first_present(row, *aliases)
|
||
if _is_missing(value):
|
||
continue
|
||
number = _nullable_number(value)
|
||
if number is None or number < 0:
|
||
issues.append(GateIssue(
|
||
"BENEFIT_AMOUNT_INVALID",
|
||
"利益表金额必须是大于或等于 0 的有效数字",
|
||
f"benefit_table.{index}.{field}",
|
||
))
|
||
return issues
|
||
|
||
|
||
def _validate_product_match(plan_data: dict, product_snapshot: dict, override_reason: str) -> list[GateIssue]:
|
||
extracted = _normalize_name(plan_data.get("product_name"))
|
||
selected = _normalize_name(
|
||
product_snapshot.get("productName")
|
||
or (product_snapshot.get("productData") or {}).get("displayName")
|
||
)
|
||
if extracted and selected and extracted != selected and not override_reason:
|
||
return [GateIssue(
|
||
"PRODUCT_MISMATCH",
|
||
"PDF 提取产品与所选产品不一致,必须填写人工覆盖原因",
|
||
"product_name",
|
||
)]
|
||
return []
|
||
|
||
|
||
def _validate_manual_overrides(plan_data: dict, override_reason: str) -> list[GateIssue]:
|
||
provenance = (plan_data.get("meta") or {}).get("provenance") or {}
|
||
if not isinstance(provenance, dict):
|
||
return []
|
||
has_override = any(
|
||
isinstance(value, dict) and value.get("source") == "manual_override"
|
||
for value in provenance.values()
|
||
)
|
||
if has_override and not override_reason:
|
||
return [GateIssue("OVERRIDE_REASON_REQUIRED", "人工修改数据时必须填写修改原因", "overrideReason")]
|
||
return []
|
||
|
||
|
||
def _validate_critical_evidence(plan_data: dict, plan_type: str, override_reason: str) -> list[GateIssue]:
|
||
fields = ["annual_premium"] if plan_type == "savings" else ["sum_assured"]
|
||
if plan_type == "iul":
|
||
fields.append("annual_premium")
|
||
fields.extend(
|
||
field for field in ("surrender_value_10", "surrender_value_20", "surrender_value_30")
|
||
if not _is_missing(plan_data.get(field))
|
||
)
|
||
issues = []
|
||
for field in fields:
|
||
if _is_missing(plan_data.get(field)):
|
||
continue
|
||
if _has_evidence(plan_data, field) or override_reason:
|
||
continue
|
||
issues.append(GateIssue(
|
||
"CRITICAL_EVIDENCE_MISSING",
|
||
"关键金额缺少来源证据或人工覆盖原因",
|
||
field,
|
||
))
|
||
return issues
|
||
|
||
|
||
def _has_evidence(plan_data: dict, field: str) -> bool:
|
||
provenance = (plan_data.get("meta") or {}).get("provenance") or {}
|
||
aliases = {field, f"policy.{field}", field.replace("_", ".")}
|
||
for key in aliases:
|
||
value = provenance.get(key) if isinstance(provenance, dict) else None
|
||
if isinstance(value, dict) and value.get("source") not in (None, "", "system_derived"):
|
||
return True
|
||
|
||
match = re.fullmatch(r"surrender_value_(\d+)", field)
|
||
if not match:
|
||
return False
|
||
target_year = int(match.group(1))
|
||
for row in plan_data.get("benefit_table") or []:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
year = _nullable_number(row.get("policy_year", row.get("policyYear", row.get("year"))))
|
||
if year == target_year and row.get("source_page", row.get("sourcePage")):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _source_file_matches(record, expected_hash: str) -> bool:
|
||
path = getattr(record, "source_file_url", None)
|
||
if not path or not expected_hash or not os.path.isfile(path):
|
||
return False
|
||
digest = hashlib.sha256()
|
||
try:
|
||
with open(path, "rb") as source:
|
||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
except OSError:
|
||
return False
|
||
return digest.hexdigest() == expected_hash
|
||
|
||
|
||
def _json_object(value: Any) -> dict:
|
||
if isinstance(value, dict):
|
||
return value
|
||
if not value:
|
||
return {}
|
||
try:
|
||
parsed = json.loads(value)
|
||
except (TypeError, ValueError):
|
||
return {}
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
|
||
|
||
def _nullable_number(value: Any) -> float | None:
|
||
if value is None or isinstance(value, bool) or (isinstance(value, str) and not value.strip()):
|
||
return None
|
||
try:
|
||
number = float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return number if math.isfinite(number) else None
|
||
|
||
|
||
def _is_missing(value: Any) -> bool:
|
||
return value is None or (isinstance(value, str) and not value.strip())
|
||
|
||
|
||
def _first_present(mapping: dict, *keys):
|
||
for key in keys:
|
||
value = mapping.get(key)
|
||
if not _is_missing(value):
|
||
return value
|
||
return None
|
||
|
||
|
||
def _normalize_name(value: Any) -> str:
|
||
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", str(value or "").lower())
|