Merge branch 'codex/lsfx-mock-server-abnormal-account-backend' into dev
This commit is contained in:
@@ -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` 稳定提供异常账户命中流水样本。
|
||||
@@ -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 服务进程,因此无需清理残留进程。
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user