Files
ccdi/lsfx-mock-server/tests/test_statement_service.py

774 lines
27 KiB
Python

"""
StatementService 主绑定注入测试
"""
from collections import Counter, defaultdict
import services.statement_rule_samples as statement_rule_samples
from services.file_service import FileService
from services.statement_service import StatementService
from services.statement_rule_samples import (
DEFAULT_LARGE_TRANSACTION_THRESHOLDS,
build_fixed_counterparty_transfer_samples,
build_large_transaction_seed_statements,
build_low_income_relative_large_transaction_samples,
build_monthly_fixed_income_samples,
build_seed_statements_for_rule_plan,
build_special_amount_transaction_samples,
build_suspicious_income_keyword_samples,
)
class FakeStaffIdentityRepository:
def select_random_staff_with_families(self):
return {
"staff_name": "数据库员工",
"staff_id_card": "320101199001010030",
"family_id_cards": ["320101199201010051", "320101199301010052"],
}
class FakeAbnormalAccountBaselineService:
def apply(self, staff_id_card, abnormal_accounts):
return None
def test_generate_statements_should_include_seeded_samples_before_noise_when_rule_plan_exists():
"""存在规则命中计划时,生成流水必须先混入被选中的命中样本。"""
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_seeded_samples",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.large_transaction_hit_rules = ["HOUSE_OR_CAR_EXPENSE"]
record.phase1_hit_rules = []
statements = service._generate_statements(group_id=1000, log_id=log_id, count=30)
assert len(statements) >= 30
assert any(item["userMemo"] == "购买房产首付款" for item in statements)
def test_build_seed_statements_for_rule_plan_should_only_include_requested_phase1_rules():
plan = {
"large_transaction_hit_rules": [],
"phase1_hit_rules": ["GAMBLING_SENSITIVE_KEYWORD", "FOREX_BUY_AMT"],
}
statements = build_seed_statements_for_rule_plan(
group_id=1000,
log_id=20001,
rule_plan=plan,
)
assert any("游戏" in item["userMemo"] for item in statements)
assert any("购汇" in item["userMemo"] for item in statements)
assert not any("证券" in item["userMemo"] for item in statements)
def test_build_seed_statements_for_rule_plan_should_generate_withdraw_cnt_samples():
plan = {
"large_transaction_hit_rules": [],
"phase1_hit_rules": ["WITHDRAW_CNT"],
}
statements = build_seed_statements_for_rule_plan(
group_id=1000,
log_id=20001,
rule_plan=plan,
)
assert len(
[
item
for item in statements
if "微信提现" in item["userMemo"] or "支付宝提现" in item["userMemo"]
]
) >= 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)
assert any(
item["userMemo"] == "购买房产首付款" and item["drAmount"] > 0
for item in statements
)
assert any(
"" in item["userMemo"] and item["drAmount"] > 0
for item in statements
)
assert any(
item["crAmount"] > DEFAULT_LARGE_TRANSACTION_THRESHOLDS["SINGLE_TRANSACTION_AMOUNT"]
for item in statements
)
assert sum(
1
for item in statements
if item["customerName"] == "浙江远望贸易有限公司" and item["crAmount"] > 0
) >= 3
assert sum(
1
for item in statements
if item["cashType"] == "现金存款"
and item["crAmount"] > DEFAULT_LARGE_TRANSACTION_THRESHOLDS["LARGE_CASH_DEPOSIT"]
) >= 1
assert any(
item["userMemo"] == "手机银行转账"
and item["drAmount"] > DEFAULT_LARGE_TRANSACTION_THRESHOLDS["FREQUENT_TRANSFER"]
for item in statements
)
same_day_cash_deposits = [
item for item in statements
if item["cretNo"] == "330101198801010011"
and item["trxDate"].startswith("2026-03-10")
and item["crAmount"] > DEFAULT_LARGE_TRANSACTION_THRESHOLDS["LARGE_CASH_DEPOSIT"]
]
assert len(same_day_cash_deposits) >= (
DEFAULT_LARGE_TRANSACTION_THRESHOLDS["FREQUENT_CASH_DEPOSIT"] + 1
)
def test_large_transaction_seed_income_should_avoid_salary_exclusion():
"""收入样本不得误带工资代发关键词,否则会被后端过滤。"""
statements = build_large_transaction_seed_statements(group_id=1000, log_id=20001)
income_samples = [item for item in statements if item["crAmount"] > 0]
assert income_samples
assert all(item["customerName"] != "浙江兰溪农村商业银行股份有限公司" for item in income_samples)
assert all(
keyword not in item["userMemo"]
for item in income_samples
for keyword in ("代发", "工资", "奖金", "薪酬", "薪金")
)
def test_generate_statements_should_fill_noise_up_to_requested_count():
"""样本不足请求总数时,服务层需要自动补齐噪声流水。"""
service = StatementService()
statements = service._generate_statements(group_id=1000, log_id=20001, count=80)
assert len(statements) == 80
def test_generate_statements_should_follow_rule_hit_plan_from_file_record():
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_rule_plan",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.large_transaction_hit_rules = ["HOUSE_OR_CAR_EXPENSE"]
record.phase1_hit_rules = ["GAMBLING_SENSITIVE_KEYWORD"]
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=200)
assert any("房产首付款" in item["userMemo"] for item in statements)
assert any("游戏" in item["userMemo"] for item in statements)
assert not any("购汇" in item["userMemo"] for item in statements)
def test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date():
statements = statement_rule_samples.build_sudden_account_closure_samples(
group_id=1000,
log_id=20001,
account_fact={
"account_no": "6222000000000001",
"owner_id_card": "320101199001010030",
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
},
le_name="测试主体",
)
assert statements
assert all("6222000000000001" == item["accountMaskNo"] for item in statements)
assert all("2026-02-18" <= item["trxDate"][:10] < "2026-03-20" for item in statements)
def test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months():
statements = statement_rule_samples.build_dormant_account_large_activation_samples(
group_id=1000,
log_id=20001,
account_fact={
"account_no": "6222000000000002",
"owner_id_card": "320101199001010030",
"account_name": "测试员工工资卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
},
le_name="测试主体",
)
assert statements
assert min(item["trxDate"][:10] for item in statements) >= "2025-07-01"
assert sum(item["drAmount"] + item["crAmount"] for item in statements) >= 500000
assert max(item["drAmount"] + item["crAmount"] for item in statements) >= 100000
def test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record():
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_rule_plan",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"]
record.abnormal_accounts = [
{
"account_no": "6222000000000001",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
}
]
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=80)
assert any(item["accountMaskNo"] == "6222000000000001" for item in statements)
assert any("销户" in item["userMemo"] or "异常账户" in item["userMemo"] for item in statements)
def test_get_bank_statement_should_preserve_abnormal_account_mask_no():
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_api",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = [
"SUDDEN_ACCOUNT_CLOSURE",
"DORMANT_ACCOUNT_LARGE_ACTIVATION",
]
record.abnormal_accounts = [
{
"account_no": "6222000000000001",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
"rule_code": "SUDDEN_ACCOUNT_CLOSURE",
},
{
"account_no": "6222000000000002",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
"rule_code": "DORMANT_ACCOUNT_LARGE_ACTIVATION",
},
]
response = statement_service.get_bank_statement(
{
"groupId": 1001,
"logId": log_id,
"pageNow": 1,
"pageSize": 500,
}
)
statements = response["data"]["bankStatementList"]
abnormal_statements = [
item for item in statements if "销户" in item["userMemo"] or "激活" in item["userMemo"]
]
assert abnormal_statements
assert any(item["accountMaskNo"] == "6222000000000001" for item in abnormal_statements)
assert any(item["accountMaskNo"] == "6222000000000002" for item in abnormal_statements)
def test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record():
file_service = FileService(
staff_identity_repository=FakeStaffIdentityRepository(),
abnormal_account_baseline_service=FakeAbnormalAccountBaselineService(),
)
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_statement_consistency",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"]
record.abnormal_accounts = [
{
"account_no": "6222000000000099",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
"rule_code": "SUDDEN_ACCOUNT_CLOSURE",
}
]
result = statement_service.get_bank_statement(
{"groupId": 1001, "logId": log_id, "pageNow": 1, "pageSize": 500}
)
abnormal_numbers = {
item["accountMaskNo"]
for item in result["data"]["bankStatementList"]
if "销户" in item["userMemo"] or "异常账户" in item["userMemo"]
}
assert abnormal_numbers == {"6222000000000099"}
def test_generate_statements_should_stay_within_single_employee_scope_per_log_id():
"""同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。"""
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_scope",
"dataChannelCode": "test",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
allowed_id_cards = {record.staff_id_card, *record.family_id_cards}
statements = service._generate_statements(group_id=1000, log_id=log_id, count=1600)
assert {item["cretNo"] for item in statements}.issubset(allowed_id_cards)
def test_get_bank_statement_should_include_counterparty_identity_fields():
service = StatementService()
response = service.get_bank_statement(
{
"groupId": 1000,
"logId": 20001,
"pageNow": 1,
"pageSize": 5,
}
)
statements = response["data"]["bankStatementList"]
assert statements
assert all("customerCertNo" in item for item in statements)
assert all("customerSocialCreditCode" in item for item in statements)
def test_all_mode_monthly_fixed_income_log_should_keep_monthly_income_stable(monkeypatch):
monkeypatch.setattr("services.file_service.settings.RULE_HIT_MODE", "all")
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
service = StatementService(file_service=file_service)
monkeypatch.setattr(
file_service,
"_generate_primary_binding",
lambda: ("全命中主体", "6222000012345678"),
)
request = {
"groupId": 1001,
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
first_log_id = file_service.fetch_inner_flow(
{**request, "customerNo": "customer_monthly_income_1"}
)["data"][0]
file_service.fetch_inner_flow(
{**request, "customerNo": "customer_monthly_income_2"}
)
record = file_service.file_records[first_log_id]
statements = service._generate_statements(group_id=1001, log_id=first_log_id, count=200)
monthly_amounts = defaultdict(float)
for item in statements:
if item["cretNo"] != record.staff_id_card:
continue
if item["crAmount"] <= 0:
continue
if not item["customerName"]:
continue
if item["leName"] == item["customerName"]:
continue
if item["customerName"] == "浙江兰溪农村商业银行股份有限公司" and (
any(keyword in item["userMemo"] for keyword in ("代发", "工资", "奖金", "薪酬", "薪金", "补贴", "", "年终奖", "年金", "加班费", "劳务费", "劳务外包", "提成", "劳务派遣", "绩效", "酬劳", "PAYROLL", "SALA", "CPF", "directors", "批量代付"))
or any(keyword in item["cashType"] for keyword in ("代发", "工资", "劳务费"))
):
continue
monthly_amounts[item["trxDate"][:7]] += item["crAmount"]
assert monthly_amounts
assert len(monthly_amounts) == 6
assert set(monthly_amounts.values()) == {7200.0}
def test_generate_statements_should_only_use_recognizable_identity_cards():
"""命中样本和随机噪声都只能使用现库可识别的身份证号。"""
service = StatementService()
statements = service._generate_statements(group_id=1000, log_id=20005, count=1600)
assert {item["cretNo"] for item in statements}.issubset(
{
"330101198801010011",
"330101199001010022",
"330101198802020033",
"330101199202020044",
}
)
def test_get_bank_statement_should_keep_same_cached_result_for_same_log_id():
"""同一 logId 首次生成后应复用缓存,避免分页结果漂移。"""
service = StatementService()
page1 = service.get_bank_statement(
{"groupId": 1000, "logId": 30001, "pageNow": 1, "pageSize": 20}
)
page2 = service.get_bank_statement(
{"groupId": 1000, "logId": 30001, "pageNow": 1, "pageSize": 20}
)
assert page1["data"]["bankStatementList"] == page2["data"]["bankStatementList"]
def test_get_bank_statement_should_use_fixed_total_count_200():
"""兰溪本地流水列表首次生成后,总条数应固定为 200。"""
service = StatementService()
response = service.get_bank_statement(
{"groupId": 1000, "logId": 30002, "pageNow": 1, "pageSize": 500}
)
assert response["data"]["totalCount"] == 200
assert len(response["data"]["bankStatementList"]) == 200
def test_get_bank_statement_uses_primary_binding_from_file_service(monkeypatch):
"""同一 logId 的流水记录必须复用 FileService 中的主体与账号绑定。"""
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
monkeypatch.setattr(
file_service,
"_generate_primary_binding",
lambda: ("绑定主体", "6222000011112222"),
)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_001",
"dataChannelCode": "test",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
statement_response = statement_service.get_bank_statement(
{
"groupId": 1001,
"logId": log_id,
"pageNow": 1,
"pageSize": 5,
}
)
statements = statement_response["data"]["bankStatementList"]
assert statements
assert all(item["leName"] == record.primary_enterprise_name for item in statements)
assert all(item["accountMaskNo"] == record.primary_account_no for item in statements)
def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
"""流水 Mock 首次生成时必须稳定包含可命中大额交易规则的样本簇。"""
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
monkeypatch.setattr(
file_service,
"_generate_primary_binding",
lambda: ("命中主体", "6222000099998888"),
)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_large_transaction",
"dataChannelCode": "test",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.large_transaction_hit_rules = [
"HOUSE_OR_CAR_EXPENSE",
"TAX_EXPENSE",
"CUMULATIVE_INCOME",
"FREQUENT_CASH_DEPOSIT",
"LARGE_TRANSFER",
]
record.phase1_hit_rules = []
staff_id_card = record.staff_id_card
family_id_card = record.family_id_cards[0]
statement_response = statement_service.get_bank_statement(
{
"groupId": 1001,
"logId": log_id,
"pageNow": 1,
"pageSize": 2000,
}
)
statements = statement_response["data"]["bankStatementList"]
assert statements
assert any(
item["cretNo"] in {staff_id_card, family_id_card}
for item in statements
)
assert any("房产首付款" in item["userMemo"] for item in statements)
assert any("" in item["userMemo"] or "税务" in item["customerName"] for item in statements)
income_amounts = defaultdict(float)
cash_deposit_daily_counter = Counter()
has_large_transfer = False
for item in statements:
if (
item["cretNo"] == staff_id_card
and item["customerName"] == "浙江远望贸易有限公司"
and item["crAmount"] > 0
):
income_amounts[(item["cretNo"], item["customerName"])] += item["crAmount"]
if item["crAmount"] > 2000001 and "现金" in item["cashType"]:
cash_deposit_daily_counter[(item["cretNo"], item["trxDate"][:10])] += 1
if item["drAmount"] > 100001 and item["userMemo"] == "手机银行转账":
has_large_transfer = True
assert any(amount > 50000001 for amount in income_amounts.values())
assert any(count >= 6 for count in cash_deposit_daily_counter.values())
assert has_large_transfer
def test_special_amount_transaction_samples_should_use_sql_supported_amounts():
statements = build_special_amount_transaction_samples(
group_id=1000,
log_id=20001,
)
assert statements
assert {item["transAmount"] for item in statements}.issubset({520.0, 1314.0})
def test_monthly_fixed_income_samples_should_cover_at_least_six_months():
statements = build_monthly_fixed_income_samples(
group_id=1000,
log_id=20001,
)
income_months = {item["trxDate"][:7] for item in statements}
assert len(income_months) >= 6
def test_suspicious_income_keyword_samples_should_hit_sql_keyword_set():
statements = build_suspicious_income_keyword_samples(
group_id=1000,
log_id=20001,
)
assert statements
assert any(
any(keyword in item["userMemo"] for keyword in ("工资", "分红", "红利", "奖金", "劳务费", "绩效"))
or any(keyword in item["cashType"] for keyword in ("工资", "劳务费", "代发"))
for item in statements
)
def test_fixed_counterparty_transfer_samples_should_use_staff_identity():
statements = build_fixed_counterparty_transfer_samples(
group_id=1000,
log_id=20001,
staff_id_card="320101199001010030",
family_id_cards=["320101199201010051", "320101199301010052"],
)
assert statements
assert {item["cretNo"] for item in statements} == {"320101199001010030"}
def test_low_income_relative_large_transaction_samples_should_exceed_total_threshold():
statements = build_low_income_relative_large_transaction_samples(
group_id=1000,
log_id=20001,
staff_id_card="320101199001010030",
family_id_cards=["320101199201010051", "320101199301010052"],
)
assert statements
assert len({item["cretNo"] for item in statements}) == 1
assert sum(item["crAmount"] + item["drAmount"] for item in statements) > 100000
def test_generate_statements_should_keep_all_mode_noise_as_safe_debits(monkeypatch):
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
monkeypatch.setattr("services.file_service.settings.RULE_HIT_MODE", "all")
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_safe_noise",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.large_transaction_hit_rules = []
record.phase1_hit_rules = []
record.phase2_statement_hit_rules = [
"MONTHLY_FIXED_INCOME",
"FIXED_COUNTERPARTY_TRANSFER",
]
record.abnormal_account_hit_rules = []
record.abnormal_accounts = []
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=30)
noise_statements = [
item
for item in statements
if item["userMemo"] not in {"月度稳定兼职收入", "季度稳定兼职收入"}
]
assert noise_statements
assert all(item["drAmount"] > 0 and item["crAmount"] == 0 for item in noise_statements)