完成LSFX Mock第二期稳定随机命中后端实施

This commit is contained in:
wkc
2026-03-22 11:48:22 +08:00
parent 4e4af5d9fb
commit cc209f04e2
12 changed files with 1131 additions and 6 deletions

View File

@@ -197,3 +197,42 @@ def test_inner_flow_bank_statement_should_keep_same_rule_subset(client):
).json()
assert page1["data"]["bankStatementList"] == page2["data"]["bankStatementList"]
def test_inner_flow_should_apply_phase2_baselines_before_get_bank_statement(client, monkeypatch):
from routers.api import file_service
applied = {}
def fake_apply(**kwargs):
applied["called"] = True
applied["baseline_rule_codes"] = kwargs["baseline_rule_codes"]
monkeypatch.setattr(file_service.phase2_baseline_service, "apply", fake_apply)
monkeypatch.setattr(
file_service,
"_build_rule_hit_plan",
lambda log_id: {
"large_transaction_hit_rules": [],
"phase1_hit_rules": [],
"phase2_statement_hit_rules": ["MONTHLY_FIXED_INCOME"],
"phase2_baseline_hit_rules": ["SUPPLIER_CONCENTRATION"],
},
)
response = client.post(
"/watson/api/project/getJZFileOrZjrcuFile",
data={
"groupId": 1001,
"customerNo": "phase2_customer",
"dataChannelCode": "channel_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
},
)
assert response.status_code == 200
assert applied["called"] is True
assert applied["baseline_rule_codes"] == ["SUPPLIER_CONCENTRATION"]

View File

@@ -185,6 +185,17 @@ def test_build_rule_hit_plan_should_be_deterministic_for_same_log_id():
assert 2 <= len(plan1["phase1_hit_rules"]) <= 4
def test_phase2_rule_hit_plan_should_be_deterministic_for_same_log_id():
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
plan1 = service._build_rule_hit_plan(10001)
plan2 = service._build_rule_hit_plan(10001)
assert plan1 == plan2
assert 2 <= len(plan1["phase2_statement_hit_rules"]) <= 4
assert 2 <= len(plan1["phase2_baseline_hit_rules"]) <= 4
def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch):
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
monkeypatch.setattr(
@@ -218,3 +229,46 @@ def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch):
"GAMBLING_SENSITIVE_KEYWORD",
"FOREX_BUY_AMT",
]
def test_fetch_inner_flow_should_persist_phase2_rule_hit_plan(monkeypatch):
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
monkeypatch.setattr(
service,
"_build_rule_hit_plan",
lambda log_id: {
"large_transaction_hit_rules": ["HOUSE_OR_CAR_EXPENSE", "TAX_EXPENSE"],
"phase1_hit_rules": ["GAMBLING_SENSITIVE_KEYWORD", "FOREX_BUY_AMT"],
"phase2_statement_hit_rules": [
"LOW_INCOME_RELATIVE_LARGE_TRANSACTION",
"SALARY_QUICK_TRANSFER",
],
"phase2_baseline_hit_rules": [
"HOUSE_REGISTRATION_MISMATCH",
"SUPPLIER_CONCENTRATION",
],
},
)
response = service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "test_customer_001",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = service.file_records[log_id]
assert record.phase2_statement_hit_rules == [
"LOW_INCOME_RELATIVE_LARGE_TRANSACTION",
"SALARY_QUICK_TRANSFER",
]
assert record.phase2_baseline_hit_rules == [
"HOUSE_REGISTRATION_MISMATCH",
"SUPPLIER_CONCENTRATION",
]

View File

@@ -0,0 +1,101 @@
"""
第二期数据库基线服务测试
"""
from contextlib import nullcontext
from services.phase2_baseline_service import Phase2BaselineService
def test_build_sql_plan_should_return_idempotent_sql_plan_for_selected_phase2_baselines():
"""抽中第二期基线规则时,应生成幂等 SQL 计划。"""
service = Phase2BaselineService()
sql_plan = service.build_sql_plan(
staff_id_card="330101198801010011",
family_id_cards=["330101199001010022"],
baseline_rule_codes=[
"SUPPLIER_CONCENTRATION",
"HOUSE_REGISTRATION_MISMATCH",
],
)
assert any("LSFXMOCKP2PUR001" in sql for sql in sql_plan)
assert any("LSFX Mock P2 HOUSE" in sql for sql in sql_plan)
assert any("'房产'" in sql for sql in sql_plan)
assert any("'正常'" in sql for sql in sql_plan)
assert any(sql.strip().startswith("DELETE") for sql in sql_plan)
assert any(sql.strip().startswith("INSERT") for sql in sql_plan)
def test_build_sql_plan_should_skip_unselected_phase2_rules():
"""未选中的规则不应写入无关 SQL。"""
service = Phase2BaselineService()
sql_plan = service.build_sql_plan(
staff_id_card="330101198801010011",
family_id_cards=[],
baseline_rule_codes=["SUPPLIER_CONCENTRATION"],
)
assert any("LSFXMOCKP2PUR001" in sql for sql in sql_plan)
assert not any("LSFX Mock P2 HOUSE" in sql for sql in sql_plan)
assert not any("ccdi_asset_info" in sql for sql in sql_plan)
def test_build_sql_plan_should_use_staff_scope_for_family_asset_baselines():
"""亲属资产基线应保留员工归属与亲属实际持有人的双字段语义。"""
service = Phase2BaselineService()
sql_plan = service.build_sql_plan(
staff_id_card="330101198801010011",
family_id_cards=["330101199001010022"],
baseline_rule_codes=["PROPERTY_FEE_REGISTRATION_MISMATCH"],
)
assert any("'330101198801010011'" in sql for sql in sql_plan)
assert any("'330101199001010022'" in sql for sql in sql_plan)
assert not any("'REAL_ESTATE'" in sql for sql in sql_plan)
def test_apply_should_execute_generated_sql_plan(monkeypatch):
"""apply() 应执行生成出的 SQL 计划,而不是只返回字符串。"""
service = Phase2BaselineService()
executed_sql = []
committed = {"value": False}
class FakeCursor:
def execute(self, sql):
executed_sql.append(sql.strip())
class FakeConnection:
def __init__(self):
self.cursor_instance = FakeCursor()
def cursor(self):
return nullcontext(self.cursor_instance)
def commit(self):
committed["value"] = True
def rollback(self):
raise AssertionError("测试路径不应触发回滚")
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(service, "_connect", lambda: FakeConnection())
result = service.apply(
staff_id_card="330101198801010011",
family_id_cards=["330101199001010022"],
baseline_rule_codes=["SUPPLIER_CONCENTRATION"],
)
assert result is None
assert committed["value"] is True
assert any("DELETE FROM ccdi_purchase_transaction" in sql for sql in executed_sql)
assert any("INSERT INTO ccdi_purchase_transaction" in sql for sql in executed_sql)

View File

@@ -87,6 +87,54 @@ def test_build_seed_statements_for_rule_plan_should_generate_withdraw_cnt_sample
) >= 4
def test_build_seed_statements_for_rule_plan_should_only_include_requested_phase2_rules():
plan = {
"large_transaction_hit_rules": [],
"phase1_hit_rules": [],
"phase2_statement_hit_rules": [
"MULTI_PARTY_GAMBLING_TRANSFER",
"SALARY_QUICK_TRANSFER",
],
"phase2_baseline_hit_rules": [],
}
statements = build_seed_statements_for_rule_plan(
group_id=1000,
log_id=30001,
rule_plan=plan,
)
assert any(item["userMemo"] == "工资入账" for item in statements)
assert any(item["customerName"] == "欢乐游戏科技有限公司" for item in statements)
assert not any(item["userMemo"] == "季度稳定兼职收入" for item in statements)
def test_salary_quick_transfer_and_salary_unused_should_use_different_identity_groups():
plan = {
"large_transaction_hit_rules": [],
"phase1_hit_rules": [],
"phase2_statement_hit_rules": [
"SALARY_QUICK_TRANSFER",
"SALARY_UNUSED",
],
"phase2_baseline_hit_rules": [],
}
statements = build_seed_statements_for_rule_plan(
group_id=1000,
log_id=30001,
rule_plan=plan,
)
salary_id_cards = {
item["cretNo"]
for item in statements
if item["userMemo"] == "工资入账"
}
assert len(salary_id_cards) >= 2
def test_large_transaction_seed_should_cover_all_eight_rules():
"""大额交易样本生成器必须覆盖 8 条已实现规则的关键口径。"""
statements = build_large_transaction_seed_statements(group_id=1000, log_id=20001)