"""suitability 单测(A4 · 验收 A-8):矩阵 25 组合 + SUIT-006/008 边界 + 服务集成。""" from datetime import date, datetime, timedelta import pytest from sqlalchemy import text from _ddl import create_sqlite_engine from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.service.suitability import ( cap_by_age, check_core, grade_number, is_assessment_valid, match_by_matrix, suitability_check, ) TODAY = date(2026, 9, 6) def _customer(level, age=40, evaluated=TODAY - timedelta(days=90)): return { "customer_id": "C1", "risk_code": level, "age": age, "risk_evaluated_at": evaluated, } def _product(level): return {"product_id": "P1", "min_risk_code": level} class TestMatrix25: """SUIT-001~005:C1~C5 × R1~R5 全组合(期望值为硬编码字面量,直接对照附表矩阵审查)。""" # 行=客户 C1~C5,列=产品 R1~R5(✓=可购),与附-风控规则表 §1 矩阵逐格对照 EXPECTED = [ # R1 R2 R3 R4 R5 [True, False, False, False, False], # C1 [True, True, False, False, False], # C2 [True, True, True, False, False], # C3 [True, True, True, True, False], # C4 [True, True, True, True, True], # C5 ] CUSTOMERS = ["C1", "C2", "C3", "C4", "C5"] PRODUCTS = ["R1", "R2", "R3", "R4", "R5"] @pytest.mark.parametrize("c", CUSTOMERS) @pytest.mark.parametrize("p", PRODUCTS) def test_matrix(self, c, p): expect = self.EXPECTED[self.CUSTOMERS.index(c)][self.PRODUCTS.index(p)] result = check_core(_customer(c), _product(p), today=TODAY) assert result.is_matched is expect assert result.blocked is (not expect) # 测评有效期内 blocked ≡ not matched assert result.customer_level == c and result.effective_level == c def test_a1_c1_buys_r4(self): """验收 A-1 断言口径:C1+R4 → rule_id=SUIT-001(客户维度,PRD 字面)。""" result = check_core(_customer("C1"), _product("R4"), today=TODAY) assert result.is_matched is False and result.blocked is True assert result.rule_id == "SUIT-001" assert "SUIT-001" in result.block_reason class TestSUIT006AgeCap: def test_70yo_c5_capped_to_c3(self): level, reason = cap_by_age("C5", 70) assert level == "C3" and "SUIT-006" in reason def test_69yo_c5_not_capped(self): assert cap_by_age("C5", 69) == ("C5", None) def test_70yo_c3_unchanged(self): assert cap_by_age("C3", 72) == ("C3", None) def test_null_age_skipped_with_review_hint(self): level, reason = cap_by_age("C5", None) assert level == "C5" and reason == "年龄缺失,建议人工复核" def test_a2_70yo_c5_buys_r4_blocked(self): """验收 A-2 断言口径:70 岁 C5 买 R4 → 封顶说明 + SUIT-003 不匹配,无过期干扰。""" result = check_core(_customer("C5", age=70), _product("R4"), today=TODAY) assert result.blocked is True and result.is_matched is False assert result.effective_level == "C3" assert result.rule_id == "SUIT-003" # 客户判定等级 C3 → SUIT-003(PRD A-2 字面) assert any("SUIT-006" in r for r in result.reasons) assert "SUIT-003" in result.block_reason assert not any("SUIT-008" in r for r in result.reasons) class TestSUIT008Validity: def test_364_days_valid(self): assert is_assessment_valid(TODAY - timedelta(days=364), TODAY, 365) is True def test_365_days_expired(self): assert is_assessment_valid(TODAY - timedelta(days=365), TODAY, 365) is False def test_none_evaluated_expired(self): assert is_assessment_valid(None, TODAY, 365) is False def test_expired_but_matched_still_blocked(self): """C3 买 R3 等级匹配,但测评过期 → 仍阻断(双字段语义)+ rule_id=SUIT-008。""" customer = _customer("C3", evaluated=TODAY - timedelta(days=400)) result = check_core(customer, _product("R3"), today=TODAY) assert result.is_matched is True and result.blocked is True assert result.rule_id == "SUIT-008" assert "SUIT-008" in result.reasons[-1] assert "重新测评" in result.block_reason def test_custom_valid_days(self): customer = _customer("C3", evaluated=TODAY - timedelta(days=100)) assert is_assessment_valid(customer["risk_evaluated_at"], TODAY, 90) is False def test_str_and_datetime_input_compat(self): """str/datetime 输入兼容(驱动差异防御,评审 P2-4②)。""" assert is_assessment_valid("2026-06-01", TODAY, 365) is True assert is_assessment_valid("2025-06-01", TODAY, 365) is False assert is_assessment_valid(datetime(2026, 6, 1, 12, 0), TODAY, 365) is True class TestCheckService: """suitability_check 服务集成(sqlite 内存库):落日志 + 原等级落库。""" @pytest.fixture() def repos(self): engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12) with engine.begin() as conn: conn.execute( text( "INSERT INTO core_customer (customer_id, display_name, age, open_date)" " VALUES ('CUST-1', '客户·测**', 70, '2020-01-01')" ) ) conn.execute( text( "INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at)" " VALUES ('CUST-1', 'C5', :evaluated)" ), {"evaluated": TODAY - timedelta(days=90)}, ) conn.execute( text( "INSERT INTO core_product (product_id, product_name, product_type, min_risk_code)" " VALUES ('PROD-R4', '测试R4产品', 'stock', 'R4')" ) ) core_ro = CoreReadOnlyRepository(engine=engine) risk_repo = RiskRepository(engine=engine) yield core_ro, risk_repo, engine engine.dispose() def test_service_blocked_and_log_written(self, repos): core_ro, risk_repo, engine = repos result = suitability_check( "CUST-1", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY ) assert result.blocked is True and result.effective_level == "C3" # 落库 customer_risk_level = 原测评等级 C5(封顶只进 reasons,PRD FR-2) row = engine.connect().execute( text( "SELECT customer_risk_level, product_risk_level, is_matched, is_blocked" " FROM risk_suitability_log" ) ).first() assert row[0] == "C5" and row[1] == "R4" and row[2] == 0 and row[3] == 1 def test_service_unknown_customer_raises(self, repos): core_ro, risk_repo, _ = repos with pytest.raises(LookupError): suitability_check("NOPE", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY) def test_service_default_valid_days_from_settings(self, repos, monkeypatch): """P1-1 回归:不传 valid_days 时取 settings(.env 可配性)。 CUST-1 为 70 岁 C5(封顶 C3 买 R4 本就不匹配,主因 SUIT-003 优先); 配置阈值降到 90 后 90 天前的测评命中过期 → reasons 出现 SUIT-008 行即证明配置生效。 """ from app.config.settings import settings as app_settings core_ro, risk_repo, _ = repos monkeypatch.setattr(app_settings, "risk_assessment_valid_days", 90) result = suitability_check("CUST-1", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY) assert result.blocked is True and result.rule_id == "SUIT-003" assert any("SUIT-008" in r for r in result.reasons) def test_service_request_ref_passthrough(self, repos): """P2-2:request_ref 透传落库(B5 网关传 trade_id 用)。""" core_ro, risk_repo, engine = repos suitability_check( "CUST-1", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY, request_ref="TRD-TEST-1", ) row = engine.connect().execute( text("SELECT request_ref, trace_id FROM risk_suitability_log") ).first() assert row[0] == "TRD-TEST-1" assert row[1] and row[1].startswith("trc-") # 无上游 trace 时兜底生成(P2-1)