"""适当性校验服务(R-02 · 全系统唯一阻断点 · main 矩阵契约)。 AL-05 对齐 main 基准(2026-09-07): - 判定内核由「SUIT-001~008 纯函数矩阵」整体替换为 main 的 ``CoreReadOnlyRepository.check_suitability``(C×R 数据驱动,L0 权威 = jinrong_core.core_suitability_rule);check_core / cap_by_age / match_by_matrix / is_assessment_valid 删除(SUIT-006 封顶被 FM-01 网点确认 替换;SUIT-008 被 FM-03 expires_at 数据驱动替换,settings.risk_assessment_valid_days 退役)。 - 返回 SuitabilityResult 扩展 main 新契约字段(match_result 五值 / mismatch_type 七值 / requires_disclosure / needs_branch_confirm / block_response_code / rule_refs);同时保留旧字段供既有调用方零改动读取: - ``rule_id`` = block_response_code(机器码别名,SUIT-00n 体系退役后的过渡口); 正式规则依据请读 ``rule_refs``(JR-AST-012 / FM-01 / FM-03 / JR-AST-PRO)。 - ``effective_level`` = customer_level(SUIT-006 封顶退役后二者恒等)。 - ``reasons`` = [reason](main 契约单条对人可读原因)。 - NotFound 语义变更:客户/产品缺失不再抛 NotFoundError,返回 forbidden/not_found 结构(main 契约)。 - 落库走 ``app/model/suitability.build_suitability_log_row`` 统一映射; check_source 参数化(网关 r02_trade / 对话 Tool r02_chat / C-11 询问 c11_inquiry / 人工 manual),actor_id 由调用方透传。 - 数据归属校验(G-01)在 FastAPI 依赖层完成,本服务不做。 """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any from app.model.suitability import CheckSource, build_suitability_log_row, compute_rule_refs from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.utils.trace import current_trace, new_trace @dataclass(frozen=True) class SuitabilityResult: """R-02 判定结果(main 契约字段 + 旧字段兼容层)。""" # ---- main 新契约 ---- match_result: str # allowed / allowed_with_disclosure / forbidden / risk_expired / professional_exempt mismatch_type: str # none / risk_level / risk_expired / age_branch_confirm / min_subscribe / not_found / professional_exempt is_matched: bool blocked: bool requires_disclosure: bool = False needs_branch_confirm: bool = False # FM-01 年龄≥70 买 R3+ block_reason: str = "" # 对人可读原因(= reason) block_response_code: str = "" # API 机器码,如 SUIT_RISK_MISMATCH rule_refs: list[str] = field(default_factory=list) # ["JR-AST-012","FM-03",...] investor_category: str = "ordinary" # ---- 兼容旧调用方(语义映射,见 module docstring) ---- customer_level: str = "" # 原测评等级(= check.customer_risk_code) product_level: str = "" # 产品最低适配等级(= check.product_risk_code) effective_level: str = "" # SUIT-006 退役后恒 = customer_level reasons: list[str] = field(default_factory=list) # = [reason] rule_id: str = "" # = block_response_code(机器码别名;SUIT-00n 退役) @classmethod def from_check(cls, check: dict[str, Any]) -> "SuitabilityResult": """core_ro.check_suitability 结果 → SuitabilityResult(含兼容映射)。""" reason = check.get("reason") or "" code = check.get("block_response_code") or "" customer_level = check.get("customer_risk_code") or "" return cls( match_result=check.get("match_result") or "forbidden", mismatch_type=check.get("mismatch_type") or "not_found", is_matched=bool(check.get("matched")), blocked=bool(check.get("blocked")), requires_disclosure=bool(check.get("requires_disclosure")), needs_branch_confirm=bool(check.get("needs_branch_confirm")), block_reason=reason, block_response_code=code, rule_refs=compute_rule_refs(check), investor_category=check.get("investor_category") or "ordinary", customer_level=customer_level, product_level=check.get("product_risk_code") or "", effective_level=customer_level, reasons=[reason] if reason else [], rule_id=code, ) def suitability_check( customer_id: str, product_id: str, core_ro: CoreReadOnlyRepository | None = None, risk_repo: RiskRepository | None = None, check_source: CheckSource = "r02_trade", actor_id: str | None = None, request_ref: str | None = None, ) -> SuitabilityResult: """服务入口:core_ro.check_suitability 矩阵判定 → 落 risk_suitability_log(每次校验必落)。 - 签名兼容:前五参位置调用(trade_gateway / risk.py / chat_tools)不受影响; valid_days / today 参数随 SUIT-008 退役删除(有效期改由 core_customer_risk.expires_at 数据驱动)。 - NotFound:客户/产品缺失返回 forbidden/not_found 结构,不再抛异常。 - check_source:网关 r02_trade / 对话 Tool r02_chat / C-11 c11_inquiry / manual;actor_id 由调用方透传(网关 "svc-trade-suitability",直调 API 传 auth.actor_id),缺省 "SYSTEM"。 - 阻断时的预警单由调用方(交易网关 FR-1)生成,本函数只落校验日志。 """ core = core_ro or CoreReadOnlyRepository() repo = risk_repo or RiskRepository() check = core.check_suitability(customer_id, product_id) result = SuitabilityResult.from_check(check) row = build_suitability_log_row( check, trace_id=current_trace() or new_trace(), actor_id=actor_id or "SYSTEM", check_source=check_source, request_ref=request_ref, profile_l1_version=None, ) repo.insert_suitability_log(row) return result