diff --git a/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md new file mode 100644 index 00000000..4931466b --- /dev/null +++ b/docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md @@ -0,0 +1,78 @@ +# LSFX Mock Server 异常账户后端实施记录 + +## 1. 实施范围 + +本次改动仅覆盖 `lsfx-mock-server` 后端 Mock 造数主链路,目标是在不新增接口的前提下,为异常账户规则补齐稳定命中能力。 + +涉及规则: + +- `SUDDEN_ACCOUNT_CLOSURE` +- `DORMANT_ACCOUNT_LARGE_ACTIVATION` + +## 2. 主要改动 + +### 2.1 FileRecord 新增异常账户计划与事实 + +在 `lsfx-mock-server/services/file_service.py` 中扩展了 `FileRecord`: + +- 新增 `abnormal_account_hit_rules` +- 新增 `abnormal_accounts` + +同时把异常账户规则池并入现有规则命中计划生成逻辑: + +- `subset` 模式下按 `logId` 稳定随机命中异常账户规则 +- `all` 模式下自动纳入全部异常账户规则 +- 在上传链路与 `fetch_inner_flow(...)` 中同步生成最小异常账户事实 + +最小账户事实字段包括: + +- `account_no` +- `owner_id_card` +- `account_name` +- `status` +- `effective_date` +- `invalid_date` + +### 2.2 新增两类异常账户样本生成器 + +在 `lsfx-mock-server/services/statement_rule_samples.py` 中新增: + +- `build_sudden_account_closure_samples(...)` +- `build_dormant_account_large_activation_samples(...)` + +口径落实如下: + +- `SUDDEN_ACCOUNT_CLOSURE` 的样本流水全部落在销户日前 30 天窗口内 +- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 的首笔流水晚于开户满 6 个月 +- 休眠激活样本同时满足累计金额阈值与单笔最大金额阈值 + +### 2.3 接入现有种子流水主链路 + +未新增平行入口,直接复用现有: + +- `FileService -> FileRecord` +- `StatementService._generate_statements(...)` +- `build_seed_statements_for_rule_plan(...)` + +接入方式: + +- 在统一种子流水构造入口增加 `abnormal_account_hit_rules` 分支 +- 根据 `abnormal_accounts` 为每条异常账户规则选择匹配账户事实 +- 生成的异常账户样本继续与既有规则样本一起补噪声、编号、打乱和分页 + +## 3. 测试补充 + +新增并通过的关键测试包括: + +- `test_fetch_inner_flow_should_attach_abnormal_account_rule_plan` +- `test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date` +- `test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months` +- `test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record` + +## 4. 联动修正 + +在 `all` 模式安全噪声测试中,原有用例只清空了旧规则维度,未同步清空新增的 `abnormal_account_hit_rules`。本次已将该测试夹具补齐,保证它继续只验证“月固定收入 + 安全噪声”的原始语义。 + +## 5. 结果 + +异常账户命中计划、最小账户事实、样本生成器和服务层主链路均已落地,现有 Mock 服务可以为同一个 `logId` 稳定提供异常账户命中流水样本。 diff --git a/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md b/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md new file mode 100644 index 00000000..a566b429 --- /dev/null +++ b/docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md @@ -0,0 +1,39 @@ +# LSFX Mock Server 异常账户后端验证记录 + +## 1. 验证命令 + +按实施过程实际执行了以下命令: + +```bash +cd lsfx-mock-server +python3 -m pytest tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan -v +python3 -m pytest tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation" -v +python3 -m pytest tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record -v +python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v +python3 -m pytest tests/ -v +``` + +README 补充后按计划追加执行: + +```bash +cd lsfx-mock-server +python3 -m pytest tests/test_statement_service.py -v +``` + +## 2. 验证结果摘要 + +- `tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan`:通过 +- `tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation"`:通过 +- `tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record`:通过 +- `python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v`:`43 passed` +- `python3 -m pytest tests/ -v`:`84 passed` +- `python3 -m pytest tests/test_statement_service.py -v`:`26 passed` + +## 3. 过程说明 + +- 回归期间发现 `all` 模式安全噪声测试未同步清空新增的异常账户规则维度,导致异常账户样本被计入噪声断言 +- 已通过补齐测试夹具方式修正,随后重新执行聚焦回归和全量回归,结果均通过 + +## 4. 进程清理 + +本轮只执行了 `pytest` 命令,未启动额外前端、后端或 Mock 服务进程,因此无需清理残留进程。 diff --git a/lsfx-mock-server/README.md b/lsfx-mock-server/README.md index 3233e3b2..8e70b13a 100644 --- a/lsfx-mock-server/README.md +++ b/lsfx-mock-server/README.md @@ -37,6 +37,12 @@ python dev.py --reload --rule-hit-mode all - `subset`:默认模式,按 `logId` 稳定随机命中部分规则 - `all`:全部兼容规则命中模式,会命中当前可共存的全部规则 +补充说明: + +- `fetch_inner_flow` 与上传链路会在内部生成 `abnormal_account_hit_rules` +- 当前异常账户规则样本包含 `SUDDEN_ACCOUNT_CLOSURE` 与 `DORMANT_ACCOUNT_LARGE_ACTIVATION` +- `/watson/api/project/getBSByLogId` 会沿用现有种子流水主链路,自动混入与异常账户事实匹配的命中流水样本 + ### 3. 访问 API 文档 - **Swagger UI**: http://localhost:8000/docs diff --git a/lsfx-mock-server/services/file_service.py b/lsfx-mock-server/services/file_service.py index 7f4a769a..50a02d4b 100644 --- a/lsfx-mock-server/services/file_service.py +++ b/lsfx-mock-server/services/file_service.py @@ -48,6 +48,11 @@ PHASE2_BASELINE_RULE_CODES = [ "SUPPLIER_CONCENTRATION", ] +ABNORMAL_ACCOUNT_RULE_CODES = [ + "SUDDEN_ACCOUNT_CLOSURE", + "DORMANT_ACCOUNT_LARGE_ACTIVATION", +] + MONTHLY_FIXED_INCOME_ISOLATED_LARGE_TRANSACTION_RULE_CODES = { "SINGLE_LARGE_INCOME", "CUMULATIVE_INCOME", @@ -127,6 +132,8 @@ class FileRecord: phase1_hit_rules: List[str] = field(default_factory=list) phase2_statement_hit_rules: List[str] = field(default_factory=list) phase2_baseline_hit_rules: List[str] = field(default_factory=list) + abnormal_account_hit_rules: List[str] = field(default_factory=list) + abnormal_accounts: List[dict] = field(default_factory=list) class FileService: @@ -213,6 +220,9 @@ class FileService: "phase2_baseline_hit_rules": self._pick_rule_subset( rng, PHASE2_BASELINE_RULE_CODES, 2, 4 ), + "abnormal_account_hit_rules": self._pick_rule_subset( + rng, ABNORMAL_ACCOUNT_RULE_CODES, 1, len(ABNORMAL_ACCOUNT_RULE_CODES) + ), } def _build_all_compatible_rule_hit_plan(self) -> dict: @@ -222,6 +232,7 @@ class FileService: "phase1_hit_rules": list(PHASE1_RULE_CODES), "phase2_statement_hit_rules": list(PHASE2_STATEMENT_RULE_CODES), "phase2_baseline_hit_rules": list(PHASE2_BASELINE_RULE_CODES), + "abnormal_account_hit_rules": list(ABNORMAL_ACCOUNT_RULE_CODES), } def _build_monthly_fixed_income_isolated_rule_hit_plan(self) -> dict: @@ -284,6 +295,52 @@ class FileService: file_record.phase2_baseline_hit_rules = list( rule_hit_plan.get("phase2_baseline_hit_rules", []) ) + file_record.abnormal_account_hit_rules = list( + rule_hit_plan.get("abnormal_account_hit_rules", []) + ) + file_record.abnormal_accounts = self._build_abnormal_accounts( + log_id=file_record.log_id, + staff_id_card=file_record.staff_id_card, + abnormal_account_hit_rules=file_record.abnormal_account_hit_rules, + ) + + def _build_abnormal_accounts( + self, + *, + log_id: int, + staff_id_card: str, + abnormal_account_hit_rules: List[str], + ) -> List[dict]: + """按命中规则生成最小异常账户事实。""" + if not abnormal_account_hit_rules: + return [] + + rng = random.Random(f"abnormal-account:{log_id}") + accounts = [] + for index, rule_code in enumerate(abnormal_account_hit_rules, start=1): + account_no = f"622200{rng.randint(10**9, 10**10 - 1)}" + account_fact = { + "account_no": account_no, + "owner_id_card": staff_id_card, + "account_name": "测试员工工资卡", + "status": 1, + "effective_date": "2025-01-01", + "invalid_date": None, + "rule_code": rule_code, + } + if rule_code == "SUDDEN_ACCOUNT_CLOSURE": + account_fact["status"] = 2 + account_fact["effective_date"] = "2024-01-01" + account_fact["invalid_date"] = "2026-03-20" + elif rule_code == "DORMANT_ACCOUNT_LARGE_ACTIVATION": + account_fact["status"] = 1 + account_fact["effective_date"] = "2025-01-01" + account_fact["invalid_date"] = None + + account_fact["account_no"] = f"{account_no[:-2]}{index:02d}" + accounts.append(account_fact) + + return accounts def _rebalance_all_mode_group_rule_plans(self, group_id: int) -> None: """同项目存在多文件时,隔离月固定收入样本,避免被其他正向流入规则污染。""" @@ -332,6 +389,8 @@ class FileService: phase1_hit_rules: List[str] = None, phase2_statement_hit_rules: List[str] = None, phase2_baseline_hit_rules: List[str] = None, + abnormal_account_hit_rules: List[str] = None, + abnormal_accounts: List[dict] = None, parsing: bool = True, status: int = -5, ) -> FileRecord: @@ -366,6 +425,8 @@ class FileService: phase1_hit_rules=list(phase1_hit_rules or []), phase2_statement_hit_rules=list(phase2_statement_hit_rules or []), phase2_baseline_hit_rules=list(phase2_baseline_hit_rules or []), + abnormal_account_hit_rules=list(abnormal_account_hit_rules or []), + abnormal_accounts=[dict(account) for account in (abnormal_accounts or [])], parsing=parsing, status=status, ) @@ -444,6 +505,12 @@ class FileService: phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []), phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_hit_rules", []), phase2_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []), + abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []), + abnormal_accounts=self._build_abnormal_accounts( + log_id=log_id, + staff_id_card=identity_scope["staff_id_card"], + abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []), + ), ) # 存储记录 @@ -775,6 +842,12 @@ class FileService: phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []), phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_hit_rules", []), phase2_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []), + abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []), + abnormal_accounts=self._build_abnormal_accounts( + log_id=log_id, + staff_id_card=identity_scope["staff_id_card"], + abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []), + ), parsing=False, ) diff --git a/lsfx-mock-server/services/statement_rule_samples.py b/lsfx-mock-server/services/statement_rule_samples.py index 67242902..0a60a6e8 100644 --- a/lsfx-mock-server/services/statement_rule_samples.py +++ b/lsfx-mock-server/services/statement_rule_samples.py @@ -811,6 +811,117 @@ def build_salary_unused_samples(group_id: int, log_id: int, **kwargs) -> List[Di ] +def build_sudden_account_closure_samples( + group_id: int, + log_id: int, + *, + account_fact: Dict, + le_name: str = "模型测试主体", +) -> List[Dict]: + invalid_date = datetime.strptime(account_fact["invalid_date"], "%Y-%m-%d") + owner_id_card = account_fact["owner_id_card"] + account_no = account_fact["account_no"] + account_name = account_fact["account_name"] + + return [ + _build_statement( + group_id, + log_id, + trx_datetime=invalid_date - timedelta(days=30, hours=-1), + cret_no=owner_id_card, + customer_name="杭州临时往来款账户", + user_memo=f"{account_name}销户前资金回笼", + cash_type="对私转账", + cr_amount=88000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666610001", + ), + _build_statement( + group_id, + log_id, + trx_datetime=invalid_date - timedelta(days=12, hours=2), + cret_no=owner_id_card, + customer_name="杭州消费支付商户", + user_memo=f"{account_name}销户前集中支出", + cash_type="快捷支付", + dr_amount=62000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666610002", + ), + _build_statement( + group_id, + log_id, + trx_datetime=invalid_date - timedelta(days=1, hours=3), + cret_no=owner_id_card, + customer_name="浙江异常账户清理专户", + user_memo=f"{account_name}异常账户销户前转出", + cash_type="对私转账", + dr_amount=126000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666610003", + ), + ] + + +def build_dormant_account_large_activation_samples( + group_id: int, + log_id: int, + *, + account_fact: Dict, + le_name: str = "模型测试主体", +) -> List[Dict]: + effective_date = datetime.strptime(account_fact["effective_date"], "%Y-%m-%d") + activation_start = datetime(effective_date.year, effective_date.month, effective_date.day) + timedelta(days=181) + owner_id_card = account_fact["owner_id_card"] + account_no = account_fact["account_no"] + account_name = account_fact["account_name"] + + return [ + _build_statement( + group_id, + log_id, + trx_datetime=activation_start, + cret_no=owner_id_card, + customer_name="浙江存量资产回收账户", + user_memo=f"{account_name}休眠后异常账户激活入账", + cash_type="对公转账", + cr_amount=180000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666620001", + ), + _build_statement( + group_id, + log_id, + trx_datetime=activation_start + timedelta(days=9, hours=2), + cret_no=owner_id_card, + customer_name="浙江大额往来备付金专户", + user_memo=f"{account_name}休眠激活后大额转入", + cash_type="对公转账", + cr_amount=260000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666620002", + ), + _build_statement( + group_id, + log_id, + trx_datetime=activation_start + timedelta(days=18, hours=1), + cret_no=owner_id_card, + customer_name="杭州临时资金调拨账户", + user_memo=f"{account_name}休眠账户异常账户激活转出", + cash_type="对私转账", + dr_amount=120000.0, + le_name=le_name, + account_mask_no=account_no, + customer_account_mask_no="6222024666620003", + ), + ] + + LARGE_TRANSACTION_BUILDERS = { "HOUSE_OR_CAR_EXPENSE": build_house_or_car_samples, "TAX_EXPENSE": build_tax_samples, @@ -842,6 +953,39 @@ PHASE2_STATEMENT_RULE_BUILDERS = { "SALARY_UNUSED": build_salary_unused_samples, } +ABNORMAL_ACCOUNT_RULE_BUILDERS = { + "SUDDEN_ACCOUNT_CLOSURE": build_sudden_account_closure_samples, + "DORMANT_ACCOUNT_LARGE_ACTIVATION": build_dormant_account_large_activation_samples, +} + + +def _resolve_abnormal_account_fact(rule_code: str, abnormal_accounts: List[Dict]) -> Optional[Dict]: + for account_fact in abnormal_accounts: + if account_fact.get("rule_code") == rule_code: + return account_fact + + if rule_code == "SUDDEN_ACCOUNT_CLOSURE": + return next( + ( + account_fact + for account_fact in abnormal_accounts + if account_fact.get("status") == 2 and account_fact.get("invalid_date") + ), + None, + ) + + if rule_code == "DORMANT_ACCOUNT_LARGE_ACTIVATION": + return next( + ( + account_fact + for account_fact in abnormal_accounts + if account_fact.get("status") == 1 and account_fact.get("effective_date") + ), + None, + ) + + return None + def build_seed_statements_for_rule_plan( group_id: int, @@ -850,21 +994,36 @@ def build_seed_statements_for_rule_plan( **kwargs, ) -> List[Dict]: statements: List[Dict] = [] + abnormal_accounts = list(kwargs.get("abnormal_accounts") or []) + common_kwargs = {key: value for key, value in kwargs.items() if key != "abnormal_accounts"} for rule_code in rule_plan.get("large_transaction_hit_rules", []): builder = LARGE_TRANSACTION_BUILDERS.get(rule_code) if builder is not None: - statements.extend(builder(group_id, log_id, **kwargs)) + statements.extend(builder(group_id, log_id, **common_kwargs)) for rule_code in rule_plan.get("phase1_hit_rules", []): builder = PHASE1_RULE_BUILDERS.get(rule_code) if builder is not None: - statements.extend(builder(group_id, log_id, **kwargs)) + statements.extend(builder(group_id, log_id, **common_kwargs)) for rule_code in rule_plan.get("phase2_statement_hit_rules", []): builder = PHASE2_STATEMENT_RULE_BUILDERS.get(rule_code) if builder is not None: - statements.extend(builder(group_id, log_id, **kwargs)) + statements.extend(builder(group_id, log_id, **common_kwargs)) + + for rule_code in rule_plan.get("abnormal_account_hit_rules", []): + builder = ABNORMAL_ACCOUNT_RULE_BUILDERS.get(rule_code) + account_fact = _resolve_abnormal_account_fact(rule_code, abnormal_accounts) + if builder is not None and account_fact is not None: + statements.extend( + builder( + group_id, + log_id, + account_fact=account_fact, + le_name=common_kwargs.get("primary_enterprise_name", "模型测试主体"), + ) + ) return statements diff --git a/lsfx-mock-server/services/statement_service.py b/lsfx-mock-server/services/statement_service.py index 8de23532..b3f089df 100644 --- a/lsfx-mock-server/services/statement_service.py +++ b/lsfx-mock-server/services/statement_service.py @@ -166,6 +166,9 @@ class StatementService: "phase2_statement_hit_rules": ( list(record.phase2_statement_hit_rules) if record is not None else [] ), + "abnormal_account_hit_rules": ( + list(record.abnormal_account_hit_rules) if record is not None else [] + ), } if record is not None and record.staff_id_card: allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards]) @@ -180,6 +183,7 @@ class StatementService: primary_account_no=primary_account_no, staff_id_card=record.staff_id_card if record is not None else None, family_id_cards=record.family_id_cards if record is not None else None, + abnormal_accounts=record.abnormal_accounts if record is not None else None, ) safe_all_mode_noise = settings.RULE_HIT_MODE == "all" and record is not None diff --git a/lsfx-mock-server/tests/test_file_service.py b/lsfx-mock-server/tests/test_file_service.py index 26510cde..aa90c767 100644 --- a/lsfx-mock-server/tests/test_file_service.py +++ b/lsfx-mock-server/tests/test_file_service.py @@ -163,6 +163,29 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch): assert record.total_records == 200 +def test_fetch_inner_flow_should_attach_abnormal_account_rule_plan(): + service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + + response = service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_abnormal_account", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + log_id = response["data"][0] + record = service.file_records[log_id] + + assert hasattr(record, "abnormal_account_hit_rules") + assert hasattr(record, "abnormal_accounts") + assert isinstance(record.abnormal_account_hit_rules, list) + assert isinstance(record.abnormal_accounts, list) + + def test_generate_log_id_should_retry_when_random_value_conflicts(monkeypatch): """随机 logId 命中已存在记录时必须重试并返回未占用值。""" service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) diff --git a/lsfx-mock-server/tests/test_statement_service.py b/lsfx-mock-server/tests/test_statement_service.py index 4e9973e1..20004a8c 100644 --- a/lsfx-mock-server/tests/test_statement_service.py +++ b/lsfx-mock-server/tests/test_statement_service.py @@ -4,6 +4,7 @@ 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 ( @@ -234,6 +235,82 @@ def test_generate_statements_should_follow_rule_hit_plan_from_file_record(): 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_generate_statements_should_stay_within_single_employee_scope_per_log_id(): """同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。""" file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) @@ -572,6 +649,8 @@ def test_generate_statements_should_keep_all_mode_noise_as_safe_debits(monkeypat "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 = [