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` 稳定随机命中部分规则
|
- `subset`:默认模式,按 `logId` 稳定随机命中部分规则
|
||||||
- `all`:全部兼容规则命中模式,会命中当前可共存的全部规则
|
- `all`:全部兼容规则命中模式,会命中当前可共存的全部规则
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- `fetch_inner_flow` 与上传链路会在内部生成 `abnormal_account_hit_rules`
|
||||||
|
- 当前异常账户规则样本包含 `SUDDEN_ACCOUNT_CLOSURE` 与 `DORMANT_ACCOUNT_LARGE_ACTIVATION`
|
||||||
|
- `/watson/api/project/getBSByLogId` 会沿用现有种子流水主链路,自动混入与异常账户事实匹配的命中流水样本
|
||||||
|
|
||||||
### 3. 访问 API 文档
|
### 3. 访问 API 文档
|
||||||
|
|
||||||
- **Swagger UI**: http://localhost:8000/docs
|
- **Swagger UI**: http://localhost:8000/docs
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ PHASE2_BASELINE_RULE_CODES = [
|
|||||||
"SUPPLIER_CONCENTRATION",
|
"SUPPLIER_CONCENTRATION",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ABNORMAL_ACCOUNT_RULE_CODES = [
|
||||||
|
"SUDDEN_ACCOUNT_CLOSURE",
|
||||||
|
"DORMANT_ACCOUNT_LARGE_ACTIVATION",
|
||||||
|
]
|
||||||
|
|
||||||
MONTHLY_FIXED_INCOME_ISOLATED_LARGE_TRANSACTION_RULE_CODES = {
|
MONTHLY_FIXED_INCOME_ISOLATED_LARGE_TRANSACTION_RULE_CODES = {
|
||||||
"SINGLE_LARGE_INCOME",
|
"SINGLE_LARGE_INCOME",
|
||||||
"CUMULATIVE_INCOME",
|
"CUMULATIVE_INCOME",
|
||||||
@@ -127,6 +132,8 @@ class FileRecord:
|
|||||||
phase1_hit_rules: List[str] = field(default_factory=list)
|
phase1_hit_rules: List[str] = field(default_factory=list)
|
||||||
phase2_statement_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)
|
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:
|
class FileService:
|
||||||
@@ -213,6 +220,9 @@ class FileService:
|
|||||||
"phase2_baseline_hit_rules": self._pick_rule_subset(
|
"phase2_baseline_hit_rules": self._pick_rule_subset(
|
||||||
rng, PHASE2_BASELINE_RULE_CODES, 2, 4
|
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:
|
def _build_all_compatible_rule_hit_plan(self) -> dict:
|
||||||
@@ -222,6 +232,7 @@ class FileService:
|
|||||||
"phase1_hit_rules": list(PHASE1_RULE_CODES),
|
"phase1_hit_rules": list(PHASE1_RULE_CODES),
|
||||||
"phase2_statement_hit_rules": list(PHASE2_STATEMENT_RULE_CODES),
|
"phase2_statement_hit_rules": list(PHASE2_STATEMENT_RULE_CODES),
|
||||||
"phase2_baseline_hit_rules": list(PHASE2_BASELINE_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:
|
def _build_monthly_fixed_income_isolated_rule_hit_plan(self) -> dict:
|
||||||
@@ -284,6 +295,52 @@ class FileService:
|
|||||||
file_record.phase2_baseline_hit_rules = list(
|
file_record.phase2_baseline_hit_rules = list(
|
||||||
rule_hit_plan.get("phase2_baseline_hit_rules", [])
|
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:
|
def _rebalance_all_mode_group_rule_plans(self, group_id: int) -> None:
|
||||||
"""同项目存在多文件时,隔离月固定收入样本,避免被其他正向流入规则污染。"""
|
"""同项目存在多文件时,隔离月固定收入样本,避免被其他正向流入规则污染。"""
|
||||||
@@ -332,6 +389,8 @@ class FileService:
|
|||||||
phase1_hit_rules: List[str] = None,
|
phase1_hit_rules: List[str] = None,
|
||||||
phase2_statement_hit_rules: List[str] = None,
|
phase2_statement_hit_rules: List[str] = None,
|
||||||
phase2_baseline_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,
|
parsing: bool = True,
|
||||||
status: int = -5,
|
status: int = -5,
|
||||||
) -> FileRecord:
|
) -> FileRecord:
|
||||||
@@ -366,6 +425,8 @@ class FileService:
|
|||||||
phase1_hit_rules=list(phase1_hit_rules or []),
|
phase1_hit_rules=list(phase1_hit_rules or []),
|
||||||
phase2_statement_hit_rules=list(phase2_statement_hit_rules or []),
|
phase2_statement_hit_rules=list(phase2_statement_hit_rules or []),
|
||||||
phase2_baseline_hit_rules=list(phase2_baseline_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,
|
parsing=parsing,
|
||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
@@ -444,6 +505,12 @@ class FileService:
|
|||||||
phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []),
|
phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []),
|
||||||
phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_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", []),
|
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", []),
|
phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []),
|
||||||
phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_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", []),
|
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,
|
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 = {
|
LARGE_TRANSACTION_BUILDERS = {
|
||||||
"HOUSE_OR_CAR_EXPENSE": build_house_or_car_samples,
|
"HOUSE_OR_CAR_EXPENSE": build_house_or_car_samples,
|
||||||
"TAX_EXPENSE": build_tax_samples,
|
"TAX_EXPENSE": build_tax_samples,
|
||||||
@@ -842,6 +953,39 @@ PHASE2_STATEMENT_RULE_BUILDERS = {
|
|||||||
"SALARY_UNUSED": build_salary_unused_samples,
|
"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(
|
def build_seed_statements_for_rule_plan(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
@@ -850,21 +994,36 @@ def build_seed_statements_for_rule_plan(
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
statements: 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", []):
|
for rule_code in rule_plan.get("large_transaction_hit_rules", []):
|
||||||
builder = LARGE_TRANSACTION_BUILDERS.get(rule_code)
|
builder = LARGE_TRANSACTION_BUILDERS.get(rule_code)
|
||||||
if builder is not None:
|
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", []):
|
for rule_code in rule_plan.get("phase1_hit_rules", []):
|
||||||
builder = PHASE1_RULE_BUILDERS.get(rule_code)
|
builder = PHASE1_RULE_BUILDERS.get(rule_code)
|
||||||
if builder is not None:
|
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", []):
|
for rule_code in rule_plan.get("phase2_statement_hit_rules", []):
|
||||||
builder = PHASE2_STATEMENT_RULE_BUILDERS.get(rule_code)
|
builder = PHASE2_STATEMENT_RULE_BUILDERS.get(rule_code)
|
||||||
if builder is not None:
|
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
|
return statements
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ class StatementService:
|
|||||||
"phase2_statement_hit_rules": (
|
"phase2_statement_hit_rules": (
|
||||||
list(record.phase2_statement_hit_rules) if record is not None else []
|
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:
|
if record is not None and record.staff_id_card:
|
||||||
allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards])
|
allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards])
|
||||||
@@ -180,6 +183,7 @@ class StatementService:
|
|||||||
primary_account_no=primary_account_no,
|
primary_account_no=primary_account_no,
|
||||||
staff_id_card=record.staff_id_card if record is not None else None,
|
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,
|
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
|
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
|
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):
|
def test_generate_log_id_should_retry_when_random_value_conflicts(monkeypatch):
|
||||||
"""随机 logId 命中已存在记录时必须重试并返回未占用值。"""
|
"""随机 logId 命中已存在记录时必须重试并返回未占用值。"""
|
||||||
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ StatementService 主绑定注入测试
|
|||||||
|
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
import services.statement_rule_samples as statement_rule_samples
|
||||||
from services.file_service import FileService
|
from services.file_service import FileService
|
||||||
from services.statement_service import StatementService
|
from services.statement_service import StatementService
|
||||||
from services.statement_rule_samples import (
|
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)
|
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():
|
def test_generate_statements_should_stay_within_single_employee_scope_per_log_id():
|
||||||
"""同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。"""
|
"""同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。"""
|
||||||
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
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",
|
"MONTHLY_FIXED_INCOME",
|
||||||
"FIXED_COUNTERPARTY_TRANSFER",
|
"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)
|
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=30)
|
||||||
noise_statements = [
|
noise_statements = [
|
||||||
|
|||||||
Reference in New Issue
Block a user