Files
group_xinghuo_jinrong/app/service/suitability.py
T

158 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""适当性校验服务(R-02 · 全系统唯一阻断点 · SUIT-001~008)。
规则权威:docs/PRD/附-风控规则表.md §1。返回语义(PRD FR-2):
- is_matched = 纯等级矩阵结果(含 SUIT-006 封顶后判定)
- blocked = 最终是否阻断(= NOT is_matched 或 SUIT-008 测评过期)
- reasons[] 列明各规则判定;落库 customer_risk_level 记**原测评等级**
- 数据归属校验(G-01)在 FastAPI 依赖层完成,本服务不做
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.utils.trace import current_trace
TIER_ORDER = {"C1": 1, "C2": 2, "C3": 3, "C4": 4, "C5": 5}
@dataclass(frozen=True)
class SuitabilityResult:
customer_level: str # 原测评等级(落库用)
effective_level: str # SUIT-006 封顶后判定等级
product_level: str
is_matched: bool
blocked: bool
reasons: list[str] = field(default_factory=list)
block_reason: str = "" # 阻断主因(人类可读,不含文案模板)
def grade_number(grade: str) -> int:
"""C1~C5 / R1~R5 → 1~5;非法值抛错(数据污染即 Fail-fast)。"""
n = TIER_ORDER.get(grade)
if n is None:
n = TIER_ORDER.get(grade.replace("R", "C"))
if n is None:
raise ValueError(f"invalid risk grade: {grade!r}")
return n
def cap_by_age(level: str, age: int | None) -> tuple[str, str | None]:
"""SUIT-006:年龄 ≥70 按最高 C3 封顶;age IS NULL 跳过并提示人工复核。"""
if age is None:
return level, "年龄缺失,建议人工复核"
if age >= 70 and grade_number(level) > grade_number("C3"):
return "C3", f"客户测评 {level} 因年龄≥70 按 C3 处理(SUIT-006)"
return level, None
def is_assessment_valid(evaluated_at: date | datetime | str | None, today: date, valid_days: int) -> bool:
"""SUIT-008:测评有效期默认 365 天(.env 可配)。兼容 str 输入(驱动差异防御)。"""
if evaluated_at is None:
return False
if isinstance(evaluated_at, str):
evaluated_at = date.fromisoformat(evaluated_at[:10])
if isinstance(evaluated_at, datetime):
evaluated_at = evaluated_at.date()
return (today - evaluated_at).days < valid_days
def match_by_matrix(customer_level: str, product_level: str) -> bool:
"""SUIT-001~005:客户等级序号 ≥ 产品等级序号方可购买。"""
return grade_number(customer_level) >= grade_number(product_level)
def check_core(
customer: dict[str, Any],
product: dict[str, Any],
valid_days: int = 365,
today: date | None = None,
) -> SuitabilityResult:
"""纯函数核心(无 IO):customer=core_customer+risk 合并行,product=core_product 行。
键约定:customer.risk_code(C1~C5)、customer.age(可 None)、
customer.risk_evaluated_at(date/datetime);product.min_risk_code(R1~R5)。
"""
today = today or date.today()
raw_level: str = customer["risk_code"]
product_level: str = product["min_risk_code"]
reasons: list[str] = []
effective_level, cap_reason = cap_by_age(raw_level, customer.get("age"))
if cap_reason:
reasons.append(cap_reason)
is_matched = match_by_matrix(effective_level, product_level)
if is_matched:
reasons.append(f"等级矩阵通过:{effective_level} 可购 {product_level}(SUIT-001~005)")
else:
reasons.append(f"等级矩阵不通过:{effective_level} 不可购 {product_level}(SUIT-001~005)")
expired = not is_assessment_valid(customer.get("risk_evaluated_at"), today, valid_days)
if expired:
reasons.append(f"风险测评已过期(有效期 {valid_days} 天),请重新测评(SUIT-008)")
blocked = (not is_matched) or expired
if not is_matched:
block_reason = f"您的风险等级为{raw_level}(判定按{effective_level}),该产品为{product_level},风险不匹配"
elif expired:
block_reason = "您的风险测评已过期,无法购买新产品,请重新测评"
else:
block_reason = ""
return SuitabilityResult(
customer_level=raw_level,
effective_level=effective_level,
product_level=product_level,
is_matched=is_matched,
blocked=blocked,
reasons=reasons,
block_reason=block_reason,
)
def suitability_check(
customer_id: str,
product_id: str,
core_ro: CoreReadOnlyRepository | None = None,
risk_repo: RiskRepository | None = None,
valid_days: int = 365,
today: date | None = None,
) -> SuitabilityResult:
"""服务入口:查 L0 事实 → 纯函数判定 → 落 risk_suitability_log(每次校验必落)。
阻断时的预警单由调用方(交易网关 FR-1)生成,本函数只落校验日志。
"""
core_ro = core_ro or CoreReadOnlyRepository()
risk_repo = risk_repo or RiskRepository()
customer = core_ro.get_customer_l0(customer_id)
if customer is None:
raise LookupError(f"customer not found: {customer_id}")
product = core_ro.get_product(product_id)
if product is None:
raise LookupError(f"product not found: {product_id}")
result = check_core(customer, product, valid_days=valid_days, today=today)
risk_repo.insert_suitability_log(
{
"trace_id": current_trace(),
"customer_id": customer_id,
"product_id": product_id,
"customer_risk_level": result.customer_level,
"product_risk_level": result.product_level,
"is_matched": int(result.is_matched),
"is_blocked": int(result.blocked),
"block_reason": result.block_reason or None,
"request_ref": None,
"profile_l1_version": None,
}
)
return result