Merge branch 'codex/lsfx-mock-phase2-random-hit' into dev
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
# LSFX Mock 第二期稳定随机命中后端实施记录
|
||||||
|
|
||||||
|
## 修改范围
|
||||||
|
|
||||||
|
- `lsfx-mock-server/services/file_service.py`
|
||||||
|
- `lsfx-mock-server/services/statement_rule_samples.py`
|
||||||
|
- `lsfx-mock-server/services/statement_service.py`
|
||||||
|
- `lsfx-mock-server/services/phase2_baseline_service.py`
|
||||||
|
- `lsfx-mock-server/tests/test_file_service.py`
|
||||||
|
- `lsfx-mock-server/tests/test_statement_service.py`
|
||||||
|
- `lsfx-mock-server/tests/test_phase2_baseline_service.py`
|
||||||
|
- `lsfx-mock-server/tests/integration/test_full_workflow.py`
|
||||||
|
- `sql/migration/2026-03-20-lsfx-mock-phase2-hit-baseline.sql`
|
||||||
|
|
||||||
|
## 第二期规则分层
|
||||||
|
|
||||||
|
- 流水样本驱动规则:
|
||||||
|
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
|
||||||
|
- `MULTI_PARTY_GAMBLING_TRANSFER`
|
||||||
|
- `MONTHLY_FIXED_INCOME`
|
||||||
|
- `FIXED_COUNTERPARTY_TRANSFER`
|
||||||
|
- `SALARY_QUICK_TRANSFER`
|
||||||
|
- `SALARY_UNUSED`
|
||||||
|
- 数据库基线驱动规则:
|
||||||
|
- `HOUSE_REGISTRATION_MISMATCH`
|
||||||
|
- `PROPERTY_FEE_REGISTRATION_MISMATCH`
|
||||||
|
- `TAX_ASSET_REGISTRATION_MISMATCH`
|
||||||
|
- `SUPPLIER_CONCENTRATION`
|
||||||
|
|
||||||
|
## 职责边界
|
||||||
|
|
||||||
|
- `FileService`
|
||||||
|
- 为同一 `logId` 稳定生成并持久化第二期规则命中计划。
|
||||||
|
- 在 `upload_file()` 与 `fetch_inner_flow()` 链路中写入 `phase2_statement_hit_rules` 与 `phase2_baseline_hit_rules`。
|
||||||
|
- 在记录创建后立即调用第二期基线服务,避免出现“流水已返回但基线未写”的假成功状态。
|
||||||
|
- `StatementService`
|
||||||
|
- 从 `FileRecord` 读取第二期流水规则子集。
|
||||||
|
- 继续保持 `FIXED_TOTAL_COUNT = 200`、稳定 ID 分配与缓存分页语义。
|
||||||
|
- `Phase2BaselineService`
|
||||||
|
- 复用项目数据库配置生成并执行第二期幂等 SQL 计划。
|
||||||
|
- 采购基线通过 `ccdi_base_staff.id_card -> staff_id` 映射到真实员工工号。
|
||||||
|
- 资产基线使用固定 `asset_name` 先删后插,并保持“故意不匹配”的资产枚举口径。
|
||||||
|
|
||||||
|
## 第二期流水样本策略
|
||||||
|
|
||||||
|
- `MULTI_PARTY_GAMBLING_TRANSFER`
|
||||||
|
- 为同一证件号生成同日多对手方、多笔区间金额转出。
|
||||||
|
- `MONTHLY_FIXED_INCOME`
|
||||||
|
- 生成连续 4 个月的稳定非工资转入。
|
||||||
|
- `FIXED_COUNTERPARTY_TRANSFER`
|
||||||
|
- 为固定对手方生成跨季度稳定转入样本。
|
||||||
|
- `SALARY_QUICK_TRANSFER`
|
||||||
|
- 生成工资入账后 6 小时内的大额转出。
|
||||||
|
- `SALARY_UNUSED`
|
||||||
|
- 生成独立证件号的工资入账与代扣样本,不与 `SALARY_QUICK_TRANSFER` 共用同一对象。
|
||||||
|
|
||||||
|
## 幂等 SQL 基线方案
|
||||||
|
|
||||||
|
- `SUPPLIER_CONCENTRATION`
|
||||||
|
- 固定采购主键 `LSFXMOCKP2PUR001`,先删后插。
|
||||||
|
- 插入 SQL 通过 `ccdi_base_staff` 按身份证反查真实 `staff_id` 与姓名,确保可被真实规则 SQL 关联。
|
||||||
|
- 三条资产不匹配规则
|
||||||
|
- 固定资产名称前缀 `LSFX Mock P2 ...`,先删后插。
|
||||||
|
- 统一使用当前项目真实口径中的 `asset_main_type = '房产'`、`asset_status = '正常'`。
|
||||||
|
- `asset_sub_type` 故意使用非 `住宅` 的值,维持“存在资产事实但不满足房产登记匹配”的状态。
|
||||||
|
- 亲属资产记录遵循 `family_id = 员工身份证号`、`person_id = 亲属身份证号`。
|
||||||
|
|
||||||
|
## 实施结果
|
||||||
|
|
||||||
|
- 第二期规则命中计划已接入 `FileService -> StatementService -> 缓存分页` 主链路。
|
||||||
|
- 第二期数据库基线已在拉取链路内接通,并支持通过独立 SQL 脚本重复重放。
|
||||||
|
- 单元测试、集成测试和全量 `lsfx-mock-server` 回归均已通过。
|
||||||
@@ -19,4 +19,5 @@
|
|||||||
|
|
||||||
## 后续动作
|
## 后续动作
|
||||||
|
|
||||||
- 待用户审核设计文档后,再进入实施计划编写。
|
- 已基于设计文档完成后端实施计划与后端代码落地。
|
||||||
|
- 第二期稳定随机命中计划、流水样本与数据库基线已在 `lsfx-mock-server` 中接通。
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# LSFX Mock 第二期稳定随机命中后端验证记录
|
||||||
|
|
||||||
|
## 验证时间
|
||||||
|
|
||||||
|
- 2026-03-20
|
||||||
|
|
||||||
|
## pytest 验证
|
||||||
|
|
||||||
|
- 聚焦验证命令:
|
||||||
|
- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py -k "phase2_rule_hit_plan" -v`
|
||||||
|
- `python3 -m pytest lsfx-mock-server/tests/test_phase2_baseline_service.py -v`
|
||||||
|
- `python3 -m pytest lsfx-mock-server/tests/test_statement_service.py -k "phase2 or salary_quick_transfer_and_salary_unused" -v`
|
||||||
|
- `python3 -m pytest lsfx-mock-server/tests/integration/test_full_workflow.py -k "phase2" -v`
|
||||||
|
- 聚焦验证结果:
|
||||||
|
- 上述 4 组命令全部通过。
|
||||||
|
- 全量回归命令:
|
||||||
|
- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_statement_service.py lsfx-mock-server/tests/test_phase2_baseline_service.py lsfx-mock-server/tests/test_api.py lsfx-mock-server/tests/integration/test_full_workflow.py -v`
|
||||||
|
- 全量回归结果:
|
||||||
|
- `48 passed`
|
||||||
|
|
||||||
|
## SQL 执行与核验
|
||||||
|
|
||||||
|
- 执行命令:
|
||||||
|
- `bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-phase2-hit-baseline.sql`
|
||||||
|
- 只读核验结果:
|
||||||
|
- `ccdi_purchase_transaction` 中存在 `LSFXMOCKP2PUR001 / 兰溪市联调供应链有限公司 / 186000.00`
|
||||||
|
- `ccdi_asset_info` 中存在 3 条 `LSFX Mock P2` 资产基线记录
|
||||||
|
- 资产记录写回查询时,`asset_status` 读数为 `NORMAL`;结合现网样例数据的 `正常` 口径,推测当前库内存在状态值归一化或历史兼容现象,但本次“不匹配”目标仍由非 `住宅` 子类型稳定满足
|
||||||
|
- 幂等性复核:
|
||||||
|
- 脚本重复执行 1 次后再次查询
|
||||||
|
- `ccdi_purchase_transaction` 计数为 `1`
|
||||||
|
- `ccdi_asset_info` 计数为 `3`
|
||||||
|
|
||||||
|
## 端到端链路结果
|
||||||
|
|
||||||
|
- `test_inner_flow_should_apply_phase2_baselines_before_get_bank_statement` 通过。
|
||||||
|
- 结果表明:
|
||||||
|
- `getJZFileOrZjrcuFile` 在返回 `logId` 前已调用第二期基线写入。
|
||||||
|
- `getBSByLogId` 仍可在同一 `logId` 下稳定读取流水列表。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
- 第二期稳定随机命中计划、流水样本装配、数据库基线写入和集成链路均已接通。
|
||||||
|
- 当前 `lsfx-mock-server` 回归通过,数据库基线可重复执行且未产生重复脏数据。
|
||||||
|
|
||||||
|
## 环境清理
|
||||||
|
|
||||||
|
- 本次验证未启动长期驻留的前后端开发进程,无残留进程需要额外关闭。
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import BackgroundTasks, UploadFile
|
from fastapi import BackgroundTasks, UploadFile
|
||||||
from utils.response_builder import ResponseBuilder
|
from utils.response_builder import ResponseBuilder
|
||||||
from config.settings import settings
|
from config.settings import settings
|
||||||
|
from services.phase2_baseline_service import Phase2BaselineService
|
||||||
from services.staff_identity_repository import StaffIdentityRepository
|
from services.staff_identity_repository import StaffIdentityRepository
|
||||||
from typing import Dict, List, Union
|
from typing import Dict, List, Union
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -31,6 +32,22 @@ PHASE1_RULE_CODES = [
|
|||||||
"WITHDRAW_CNT",
|
"WITHDRAW_CNT",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
PHASE2_STATEMENT_RULE_CODES = [
|
||||||
|
"LOW_INCOME_RELATIVE_LARGE_TRANSACTION",
|
||||||
|
"MULTI_PARTY_GAMBLING_TRANSFER",
|
||||||
|
"MONTHLY_FIXED_INCOME",
|
||||||
|
"FIXED_COUNTERPARTY_TRANSFER",
|
||||||
|
"SALARY_QUICK_TRANSFER",
|
||||||
|
"SALARY_UNUSED",
|
||||||
|
]
|
||||||
|
|
||||||
|
PHASE2_BASELINE_RULE_CODES = [
|
||||||
|
"HOUSE_REGISTRATION_MISMATCH",
|
||||||
|
"PROPERTY_FEE_REGISTRATION_MISMATCH",
|
||||||
|
"TAX_ASSET_REGISTRATION_MISMATCH",
|
||||||
|
"SUPPLIER_CONCENTRATION",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FileRecord:
|
class FileRecord:
|
||||||
@@ -88,6 +105,8 @@ class FileRecord:
|
|||||||
family_id_cards: List[str] = field(default_factory=list)
|
family_id_cards: List[str] = field(default_factory=list)
|
||||||
large_transaction_hit_rules: List[str] = field(default_factory=list)
|
large_transaction_hit_rules: List[str] = field(default_factory=list)
|
||||||
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_baseline_hit_rules: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FileService:
|
class FileService:
|
||||||
@@ -97,10 +116,11 @@ class FileService:
|
|||||||
LOG_ID_MIN = settings.INITIAL_LOG_ID
|
LOG_ID_MIN = settings.INITIAL_LOG_ID
|
||||||
LOG_ID_MAX = 99999
|
LOG_ID_MAX = 99999
|
||||||
|
|
||||||
def __init__(self, staff_identity_repository=None):
|
def __init__(self, staff_identity_repository=None, phase2_baseline_service=None):
|
||||||
self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord
|
self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord
|
||||||
self.log_counter = settings.INITIAL_LOG_ID
|
self.log_counter = settings.INITIAL_LOG_ID
|
||||||
self.staff_identity_repository = staff_identity_repository or StaffIdentityRepository()
|
self.staff_identity_repository = staff_identity_repository or StaffIdentityRepository()
|
||||||
|
self.phase2_baseline_service = phase2_baseline_service or Phase2BaselineService()
|
||||||
|
|
||||||
def get_file_record(self, log_id: int) -> FileRecord:
|
def get_file_record(self, log_id: int) -> FileRecord:
|
||||||
"""按 logId 获取已存在的文件记录。"""
|
"""按 logId 获取已存在的文件记录。"""
|
||||||
@@ -167,6 +187,12 @@ class FileService:
|
|||||||
rng, LARGE_TRANSACTION_RULE_CODES, 2, 4
|
rng, LARGE_TRANSACTION_RULE_CODES, 2, 4
|
||||||
),
|
),
|
||||||
"phase1_hit_rules": self._pick_rule_subset(rng, PHASE1_RULE_CODES, 2, 4),
|
"phase1_hit_rules": self._pick_rule_subset(rng, PHASE1_RULE_CODES, 2, 4),
|
||||||
|
"phase2_statement_hit_rules": self._pick_rule_subset(
|
||||||
|
rng, PHASE2_STATEMENT_RULE_CODES, 2, 4
|
||||||
|
),
|
||||||
|
"phase2_baseline_hit_rules": self._pick_rule_subset(
|
||||||
|
rng, PHASE2_BASELINE_RULE_CODES, 2, 4
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _create_file_record(
|
def _create_file_record(
|
||||||
@@ -191,6 +217,8 @@ class FileService:
|
|||||||
family_id_cards: List[str] = None,
|
family_id_cards: List[str] = None,
|
||||||
large_transaction_hit_rules: List[str] = None,
|
large_transaction_hit_rules: List[str] = None,
|
||||||
phase1_hit_rules: List[str] = None,
|
phase1_hit_rules: List[str] = None,
|
||||||
|
phase2_statement_hit_rules: List[str] = None,
|
||||||
|
phase2_baseline_hit_rules: List[str] = None,
|
||||||
parsing: bool = True,
|
parsing: bool = True,
|
||||||
status: int = -5,
|
status: int = -5,
|
||||||
) -> FileRecord:
|
) -> FileRecord:
|
||||||
@@ -223,6 +251,8 @@ class FileService:
|
|||||||
family_id_cards=list(family_id_cards or []),
|
family_id_cards=list(family_id_cards or []),
|
||||||
large_transaction_hit_rules=list(large_transaction_hit_rules or []),
|
large_transaction_hit_rules=list(large_transaction_hit_rules or []),
|
||||||
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_baseline_hit_rules=list(phase2_baseline_hit_rules or []),
|
||||||
parsing=parsing,
|
parsing=parsing,
|
||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
@@ -231,6 +261,14 @@ class FileService:
|
|||||||
"""读取一个员工及其亲属身份范围。"""
|
"""读取一个员工及其亲属身份范围。"""
|
||||||
return self.staff_identity_repository.select_random_staff_with_families()
|
return self.staff_identity_repository.select_random_staff_with_families()
|
||||||
|
|
||||||
|
def _apply_phase2_baselines(self, file_record: FileRecord) -> None:
|
||||||
|
"""按当前记录命中的第二期基线规则幂等补齐外部事实。"""
|
||||||
|
self.phase2_baseline_service.apply(
|
||||||
|
staff_id_card=file_record.staff_id_card,
|
||||||
|
family_id_cards=file_record.family_id_cards,
|
||||||
|
baseline_rule_codes=file_record.phase2_baseline_hit_rules,
|
||||||
|
)
|
||||||
|
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks
|
self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
@@ -280,12 +318,15 @@ class FileService:
|
|||||||
staff_name=identity_scope["staff_name"],
|
staff_name=identity_scope["staff_name"],
|
||||||
staff_id_card=identity_scope["staff_id_card"],
|
staff_id_card=identity_scope["staff_id_card"],
|
||||||
family_id_cards=identity_scope["family_id_cards"],
|
family_id_cards=identity_scope["family_id_cards"],
|
||||||
large_transaction_hit_rules=rule_hit_plan["large_transaction_hit_rules"],
|
large_transaction_hit_rules=rule_hit_plan.get("large_transaction_hit_rules", []),
|
||||||
phase1_hit_rules=rule_hit_plan["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_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 存储记录
|
# 存储记录
|
||||||
self.file_records[log_id] = file_record
|
self.file_records[log_id] = file_record
|
||||||
|
self._apply_phase2_baselines(file_record)
|
||||||
|
|
||||||
# 添加后台任务(延迟解析)
|
# 添加后台任务(延迟解析)
|
||||||
background_tasks.add_task(self._delayed_parse, log_id)
|
background_tasks.add_task(self._delayed_parse, log_id)
|
||||||
@@ -607,12 +648,15 @@ class FileService:
|
|||||||
staff_name=identity_scope["staff_name"],
|
staff_name=identity_scope["staff_name"],
|
||||||
staff_id_card=identity_scope["staff_id_card"],
|
staff_id_card=identity_scope["staff_id_card"],
|
||||||
family_id_cards=identity_scope["family_id_cards"],
|
family_id_cards=identity_scope["family_id_cards"],
|
||||||
large_transaction_hit_rules=rule_hit_plan["large_transaction_hit_rules"],
|
large_transaction_hit_rules=rule_hit_plan.get("large_transaction_hit_rules", []),
|
||||||
phase1_hit_rules=rule_hit_plan["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_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []),
|
||||||
parsing=False,
|
parsing=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.file_records[log_id] = file_record
|
self.file_records[log_id] = file_record
|
||||||
|
self._apply_phase2_baselines(file_record)
|
||||||
|
|
||||||
# 返回成功的响应,包含logId数组
|
# 返回成功的响应,包含logId数组
|
||||||
return {
|
return {
|
||||||
|
|||||||
264
lsfx-mock-server/services/phase2_baseline_service.py
Normal file
264
lsfx-mock-server/services/phase2_baseline_service.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
from textwrap import dedent
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Phase2BaselineService:
|
||||||
|
"""第二期数据库基线服务。
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUPPLIER_PURCHASE_ID = "LSFXMOCKP2PUR001"
|
||||||
|
SUPPLIER_NAME = "兰溪市联调供应链有限公司"
|
||||||
|
ASSET_IDENTIFIERS = {
|
||||||
|
"HOUSE_REGISTRATION_MISMATCH": "LSFX Mock P2 HOUSE_REGISTRATION_MISMATCH",
|
||||||
|
"PROPERTY_FEE_REGISTRATION_MISMATCH": "LSFX Mock P2 PROPERTY_FEE_REGISTRATION_MISMATCH",
|
||||||
|
"TAX_ASSET_REGISTRATION_MISMATCH": "LSFX Mock P2 TAX_ASSET_REGISTRATION_MISMATCH",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.db_config = {
|
||||||
|
"host": settings.CCDI_DB_HOST,
|
||||||
|
"port": settings.CCDI_DB_PORT,
|
||||||
|
"database": settings.CCDI_DB_NAME,
|
||||||
|
"username": settings.CCDI_DB_USERNAME,
|
||||||
|
"password": settings.CCDI_DB_PASSWORD,
|
||||||
|
"connect_timeout_seconds": settings.CCDI_DB_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sql_quote(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("缺少 PyMySQL 依赖,无法写入第二期数据库基线") from exc
|
||||||
|
|
||||||
|
return pymysql.connect(
|
||||||
|
host=settings.CCDI_DB_HOST,
|
||||||
|
port=settings.CCDI_DB_PORT,
|
||||||
|
user=settings.CCDI_DB_USERNAME,
|
||||||
|
password=settings.CCDI_DB_PASSWORD,
|
||||||
|
database=settings.CCDI_DB_NAME,
|
||||||
|
charset="utf8mb4",
|
||||||
|
connect_timeout=settings.CCDI_DB_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
autocommit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_asset_owner_scopes(
|
||||||
|
self,
|
||||||
|
staff_id_card: str,
|
||||||
|
family_id_cards: List[str],
|
||||||
|
) -> Dict[str, Tuple[str, str]]:
|
||||||
|
family_pool = [card for card in family_id_cards if card]
|
||||||
|
first_family = family_pool[0] if family_pool else staff_id_card
|
||||||
|
second_family = family_pool[1] if len(family_pool) > 1 else first_family
|
||||||
|
return {
|
||||||
|
"HOUSE_REGISTRATION_MISMATCH": (staff_id_card, staff_id_card),
|
||||||
|
"PROPERTY_FEE_REGISTRATION_MISMATCH": (staff_id_card, first_family),
|
||||||
|
"TAX_ASSET_REGISTRATION_MISMATCH": (staff_id_card, second_family),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_supplier_concentration_sql(self, staff_id_card: str) -> List[str]:
|
||||||
|
purchase_id = self.SUPPLIER_PURCHASE_ID
|
||||||
|
supplier_name = self.SUPPLIER_NAME
|
||||||
|
sql = [
|
||||||
|
dedent(
|
||||||
|
f"""
|
||||||
|
DELETE FROM ccdi_purchase_transaction
|
||||||
|
WHERE purchase_id = {self._sql_quote(purchase_id)};
|
||||||
|
"""
|
||||||
|
).strip(),
|
||||||
|
dedent(
|
||||||
|
f"""
|
||||||
|
INSERT INTO ccdi_purchase_transaction (
|
||||||
|
purchase_id,
|
||||||
|
purchase_category,
|
||||||
|
project_name,
|
||||||
|
subject_name,
|
||||||
|
subject_desc,
|
||||||
|
purchase_qty,
|
||||||
|
budget_amount,
|
||||||
|
bid_amount,
|
||||||
|
actual_amount,
|
||||||
|
contract_amount,
|
||||||
|
settlement_amount,
|
||||||
|
purchase_method,
|
||||||
|
supplier_name,
|
||||||
|
contact_person,
|
||||||
|
contact_phone,
|
||||||
|
supplier_uscc,
|
||||||
|
supplier_bank_account,
|
||||||
|
apply_date,
|
||||||
|
plan_approve_date,
|
||||||
|
announce_date,
|
||||||
|
bid_open_date,
|
||||||
|
contract_sign_date,
|
||||||
|
expected_delivery_date,
|
||||||
|
actual_delivery_date,
|
||||||
|
acceptance_date,
|
||||||
|
settlement_date,
|
||||||
|
applicant_id,
|
||||||
|
applicant_name,
|
||||||
|
apply_department,
|
||||||
|
purchase_leader_id,
|
||||||
|
purchase_leader_name,
|
||||||
|
purchase_department,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
{self._sql_quote(purchase_id)},
|
||||||
|
'设备采购',
|
||||||
|
'LSFX Mock P2',
|
||||||
|
'可疑采购基线',
|
||||||
|
'用于命中 SUPPLIER_CONCENTRATION 真实规则',
|
||||||
|
1,
|
||||||
|
188000.00,
|
||||||
|
186000.00,
|
||||||
|
186000.00,
|
||||||
|
186000.00,
|
||||||
|
186000.00,
|
||||||
|
'竞争性谈判',
|
||||||
|
{self._sql_quote(supplier_name)},
|
||||||
|
'联调联系人',
|
||||||
|
'13800000000',
|
||||||
|
'91330781P2PUR00011',
|
||||||
|
'6222000000001234',
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CAST(s.staff_id AS CHAR),
|
||||||
|
s.name,
|
||||||
|
'纪检初核部',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
'admin',
|
||||||
|
'admin'
|
||||||
|
FROM ccdi_base_staff s
|
||||||
|
WHERE s.id_card = {self._sql_quote(staff_id_card)}
|
||||||
|
LIMIT 1;
|
||||||
|
"""
|
||||||
|
).strip(),
|
||||||
|
]
|
||||||
|
return sql
|
||||||
|
|
||||||
|
def _build_asset_mismatch_sql(
|
||||||
|
self,
|
||||||
|
rule_code: str,
|
||||||
|
family_id: str,
|
||||||
|
person_id: str,
|
||||||
|
) -> List[str]:
|
||||||
|
asset_name = self.ASSET_IDENTIFIERS[rule_code]
|
||||||
|
asset_main_type = "房产"
|
||||||
|
asset_sub_type = "商铺"
|
||||||
|
|
||||||
|
return [
|
||||||
|
dedent(
|
||||||
|
f"""
|
||||||
|
DELETE FROM ccdi_asset_info
|
||||||
|
WHERE asset_name = {self._sql_quote(asset_name)};
|
||||||
|
"""
|
||||||
|
).strip(),
|
||||||
|
dedent(
|
||||||
|
f"""
|
||||||
|
INSERT INTO ccdi_asset_info (
|
||||||
|
family_id,
|
||||||
|
person_id,
|
||||||
|
asset_main_type,
|
||||||
|
asset_sub_type,
|
||||||
|
asset_name,
|
||||||
|
ownership_ratio,
|
||||||
|
purchase_eval_date,
|
||||||
|
original_value,
|
||||||
|
current_value,
|
||||||
|
valuation_date,
|
||||||
|
asset_status,
|
||||||
|
remarks,
|
||||||
|
create_by,
|
||||||
|
update_by
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
{self._sql_quote(family_id)},
|
||||||
|
{self._sql_quote(person_id)},
|
||||||
|
{self._sql_quote(asset_main_type)},
|
||||||
|
{self._sql_quote(asset_sub_type)},
|
||||||
|
{self._sql_quote(asset_name)},
|
||||||
|
100.00,
|
||||||
|
CURRENT_DATE,
|
||||||
|
1880000.00,
|
||||||
|
1880000.00,
|
||||||
|
CURRENT_DATE,
|
||||||
|
'正常',
|
||||||
|
{self._sql_quote(f'用于命中 {rule_code} 真实规则的第二期基线')},
|
||||||
|
'admin',
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
).strip(),
|
||||||
|
]
|
||||||
|
|
||||||
|
def build_sql_plan(
|
||||||
|
self,
|
||||||
|
staff_id_card: str,
|
||||||
|
family_id_cards: List[str],
|
||||||
|
baseline_rule_codes: List[str],
|
||||||
|
) -> List[str]:
|
||||||
|
"""生成第二期基线 SQL 计划。"""
|
||||||
|
selected_rule_codes = []
|
||||||
|
for rule_code in baseline_rule_codes or []:
|
||||||
|
if rule_code not in selected_rule_codes:
|
||||||
|
selected_rule_codes.append(rule_code)
|
||||||
|
|
||||||
|
sql_plan: List[str] = []
|
||||||
|
asset_owner_ids = self._resolve_asset_owner_scopes(
|
||||||
|
staff_id_card,
|
||||||
|
family_id_cards or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
for rule_code in selected_rule_codes:
|
||||||
|
if rule_code == "SUPPLIER_CONCENTRATION":
|
||||||
|
sql_plan.extend(self._build_supplier_concentration_sql(staff_id_card))
|
||||||
|
elif rule_code in self.ASSET_IDENTIFIERS:
|
||||||
|
family_id, person_id = asset_owner_ids[rule_code]
|
||||||
|
sql_plan.extend(
|
||||||
|
self._build_asset_mismatch_sql(
|
||||||
|
rule_code=rule_code,
|
||||||
|
family_id=family_id,
|
||||||
|
person_id=person_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return sql_plan
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
staff_id_card: str,
|
||||||
|
family_id_cards: List[str],
|
||||||
|
baseline_rule_codes: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""按当前命中的规则将第二期基线幂等写入数据库。"""
|
||||||
|
sql_plan = self.build_sql_plan(staff_id_card, family_id_cards, baseline_rule_codes)
|
||||||
|
if not sql_plan:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
for sql in sql_plan:
|
||||||
|
cursor.execute(sql)
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -164,6 +164,35 @@ def _build_sample_context(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_phase2_subjects(
|
||||||
|
log_id: int,
|
||||||
|
staff_id_card: Optional[str] = None,
|
||||||
|
family_id_cards: Optional[List[str]] = None,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
identity_scope = resolve_identity_scope(log_id)
|
||||||
|
fallback_staff = identity_scope["staff"]["id_card"]
|
||||||
|
fallback_family = identity_scope["family"]["id_card"]
|
||||||
|
|
||||||
|
primary_subject = staff_id_card or fallback_staff
|
||||||
|
family_pool = [
|
||||||
|
card
|
||||||
|
for card in (family_id_cards or [])
|
||||||
|
if card and card != primary_subject
|
||||||
|
]
|
||||||
|
secondary_subject = (
|
||||||
|
family_pool[0]
|
||||||
|
if family_pool
|
||||||
|
else (fallback_family if fallback_family != primary_subject else primary_subject)
|
||||||
|
)
|
||||||
|
tertiary_subject = family_pool[1] if len(family_pool) > 1 else secondary_subject
|
||||||
|
|
||||||
|
return {
|
||||||
|
"primary": primary_subject,
|
||||||
|
"secondary": secondary_subject,
|
||||||
|
"tertiary": tertiary_subject,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_house_or_car_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]:
|
def build_house_or_car_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]:
|
||||||
context = _build_sample_context(log_id, **kwargs)
|
context = _build_sample_context(log_id, **kwargs)
|
||||||
return [
|
return [
|
||||||
@@ -559,6 +588,223 @@ def build_withdraw_cnt_samples(group_id: int, log_id: int, **kwargs) -> List[Dic
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_low_income_relative_large_transaction_samples(
|
||||||
|
group_id: int,
|
||||||
|
log_id: int,
|
||||||
|
**kwargs,
|
||||||
|
) -> List[Dict]:
|
||||||
|
context = _build_sample_context(log_id, **kwargs)
|
||||||
|
subjects = _build_phase2_subjects(
|
||||||
|
log_id,
|
||||||
|
staff_id_card=kwargs.get("staff_id_card"),
|
||||||
|
family_id_cards=kwargs.get("family_id_cards"),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=REFERENCE_NOW - timedelta(days=18),
|
||||||
|
cret_no=subjects["secondary"],
|
||||||
|
customer_name="兰溪惠民互助协会",
|
||||||
|
user_memo="亲属大额转入",
|
||||||
|
cash_type="对私转账",
|
||||||
|
cr_amount=68000.0,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no="6222024888800001",
|
||||||
|
),
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=REFERENCE_NOW - timedelta(days=9),
|
||||||
|
cret_no=subjects["secondary"],
|
||||||
|
customer_name="兰溪惠民互助协会",
|
||||||
|
user_memo="亲属经营补贴",
|
||||||
|
cash_type="对私转账",
|
||||||
|
cr_amount=52000.0,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no="6222024888800001",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_multi_party_gambling_transfer_samples(
|
||||||
|
group_id: int,
|
||||||
|
log_id: int,
|
||||||
|
**kwargs,
|
||||||
|
) -> List[Dict]:
|
||||||
|
context = _build_sample_context(log_id, **kwargs)
|
||||||
|
subjects = _build_phase2_subjects(
|
||||||
|
log_id,
|
||||||
|
staff_id_card=kwargs.get("staff_id_card"),
|
||||||
|
family_id_cards=kwargs.get("family_id_cards"),
|
||||||
|
)
|
||||||
|
transfer_specs = [
|
||||||
|
("欢乐游戏科技有限公司", 3888.0),
|
||||||
|
("星彩娱乐网络科技有限公司", 4288.0),
|
||||||
|
("极速竞技服务有限公司", 4688.0),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=datetime(2026, 3, 11, 10 + index, 0, 0),
|
||||||
|
cret_no=subjects["primary"],
|
||||||
|
customer_name=customer_name,
|
||||||
|
user_memo="手机银行转账",
|
||||||
|
cash_type="对私转账",
|
||||||
|
dr_amount=dr_amount,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no=f"62220247777000{index + 1}",
|
||||||
|
)
|
||||||
|
for index, (customer_name, dr_amount) in enumerate(transfer_specs)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_monthly_fixed_income_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]:
|
||||||
|
context = _build_sample_context(log_id, **kwargs)
|
||||||
|
subjects = _build_phase2_subjects(
|
||||||
|
log_id,
|
||||||
|
staff_id_card=kwargs.get("staff_id_card"),
|
||||||
|
family_id_cards=kwargs.get("family_id_cards"),
|
||||||
|
)
|
||||||
|
income_months = [
|
||||||
|
datetime(2025, 12, 5, 9, 0, 0),
|
||||||
|
datetime(2026, 1, 5, 9, 0, 0),
|
||||||
|
datetime(2026, 2, 5, 9, 0, 0),
|
||||||
|
datetime(2026, 3, 5, 9, 0, 0),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=trx_datetime,
|
||||||
|
cret_no=subjects["primary"],
|
||||||
|
customer_name="兰溪远航信息服务有限公司",
|
||||||
|
user_memo="月度稳定兼职收入",
|
||||||
|
cash_type="对私转账",
|
||||||
|
cr_amount=7200.0,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no="6222024666600101",
|
||||||
|
)
|
||||||
|
for trx_datetime in income_months
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_fixed_counterparty_transfer_samples(
|
||||||
|
group_id: int,
|
||||||
|
log_id: int,
|
||||||
|
**kwargs,
|
||||||
|
) -> List[Dict]:
|
||||||
|
context = _build_sample_context(log_id, **kwargs)
|
||||||
|
subjects = _build_phase2_subjects(
|
||||||
|
log_id,
|
||||||
|
staff_id_card=kwargs.get("staff_id_card"),
|
||||||
|
family_id_cards=kwargs.get("family_id_cards"),
|
||||||
|
)
|
||||||
|
quarter_dates = [
|
||||||
|
datetime(2025, 4, 8, 9, 0, 0),
|
||||||
|
datetime(2025, 7, 8, 9, 0, 0),
|
||||||
|
datetime(2025, 10, 8, 9, 0, 0),
|
||||||
|
datetime(2026, 1, 8, 9, 0, 0),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=trx_datetime,
|
||||||
|
cret_no=subjects["secondary"],
|
||||||
|
customer_name="兰溪零工服务有限公司",
|
||||||
|
user_memo="季度稳定兼职收入",
|
||||||
|
cash_type="对私转账",
|
||||||
|
cr_amount=4200.0,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no="6222024666600201",
|
||||||
|
)
|
||||||
|
for trx_datetime in quarter_dates
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_salary_quick_transfer_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]:
|
||||||
|
context = _build_sample_context(log_id, **kwargs)
|
||||||
|
subjects = _build_phase2_subjects(
|
||||||
|
log_id,
|
||||||
|
staff_id_card=kwargs.get("staff_id_card"),
|
||||||
|
family_id_cards=kwargs.get("family_id_cards"),
|
||||||
|
)
|
||||||
|
salary_time = datetime(2026, 3, 14, 9, 0, 0)
|
||||||
|
return [
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=salary_time,
|
||||||
|
cret_no=subjects["primary"],
|
||||||
|
customer_name="浙江兰溪农村商业银行股份有限公司",
|
||||||
|
user_memo="工资入账",
|
||||||
|
cash_type="工资代发",
|
||||||
|
cr_amount=12000.0,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no="6222024666600301",
|
||||||
|
),
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=salary_time + timedelta(hours=6),
|
||||||
|
cret_no=subjects["primary"],
|
||||||
|
customer_name="张某某",
|
||||||
|
user_memo="工资到账后快速转出",
|
||||||
|
cash_type="对私转账",
|
||||||
|
dr_amount=10800.0,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no="6222024666600302",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_salary_unused_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]:
|
||||||
|
context = _build_sample_context(log_id, **kwargs)
|
||||||
|
subjects = _build_phase2_subjects(
|
||||||
|
log_id,
|
||||||
|
staff_id_card=kwargs.get("staff_id_card"),
|
||||||
|
family_id_cards=kwargs.get("family_id_cards"),
|
||||||
|
)
|
||||||
|
salary_time = datetime(2026, 2, 10, 9, 0, 0)
|
||||||
|
return [
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=salary_time,
|
||||||
|
cret_no=subjects["secondary"],
|
||||||
|
customer_name="浙江兰溪农村商业银行股份有限公司",
|
||||||
|
user_memo="工资入账",
|
||||||
|
cash_type="工资代发",
|
||||||
|
cr_amount=9800.0,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no="6222024666600401",
|
||||||
|
),
|
||||||
|
_build_statement(
|
||||||
|
group_id,
|
||||||
|
log_id,
|
||||||
|
trx_datetime=salary_time + timedelta(days=5),
|
||||||
|
cret_no=subjects["secondary"],
|
||||||
|
customer_name="兰溪住房公积金中心",
|
||||||
|
user_memo="代扣公积金",
|
||||||
|
cash_type="代扣支出",
|
||||||
|
dr_amount=500.0,
|
||||||
|
le_name=context["le_name"],
|
||||||
|
account_mask_no=context["account_no"],
|
||||||
|
customer_account_mask_no="6222024666600402",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -581,6 +827,15 @@ PHASE1_RULE_BUILDERS = {
|
|||||||
"WITHDRAW_CNT": build_withdraw_cnt_samples,
|
"WITHDRAW_CNT": build_withdraw_cnt_samples,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PHASE2_STATEMENT_RULE_BUILDERS = {
|
||||||
|
"LOW_INCOME_RELATIVE_LARGE_TRANSACTION": build_low_income_relative_large_transaction_samples,
|
||||||
|
"MULTI_PARTY_GAMBLING_TRANSFER": build_multi_party_gambling_transfer_samples,
|
||||||
|
"MONTHLY_FIXED_INCOME": build_monthly_fixed_income_samples,
|
||||||
|
"FIXED_COUNTERPARTY_TRANSFER": build_fixed_counterparty_transfer_samples,
|
||||||
|
"SALARY_QUICK_TRANSFER": build_salary_quick_transfer_samples,
|
||||||
|
"SALARY_UNUSED": build_salary_unused_samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_seed_statements_for_rule_plan(
|
def build_seed_statements_for_rule_plan(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
@@ -600,6 +855,11 @@ def build_seed_statements_for_rule_plan(
|
|||||||
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, **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))
|
||||||
|
|
||||||
return statements
|
return statements
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ class StatementService:
|
|||||||
list(record.large_transaction_hit_rules) if record is not None else []
|
list(record.large_transaction_hit_rules) if record is not None else []
|
||||||
),
|
),
|
||||||
"phase1_hit_rules": list(record.phase1_hit_rules) if record is not None else [],
|
"phase1_hit_rules": list(record.phase1_hit_rules) if record is not None else [],
|
||||||
|
"phase2_statement_hit_rules": (
|
||||||
|
list(record.phase2_statement_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])
|
||||||
|
|||||||
@@ -197,3 +197,42 @@ def test_inner_flow_bank_statement_should_keep_same_rule_subset(client):
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
assert page1["data"]["bankStatementList"] == page2["data"]["bankStatementList"]
|
assert page1["data"]["bankStatementList"] == page2["data"]["bankStatementList"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_inner_flow_should_apply_phase2_baselines_before_get_bank_statement(client, monkeypatch):
|
||||||
|
from routers.api import file_service
|
||||||
|
|
||||||
|
applied = {}
|
||||||
|
|
||||||
|
def fake_apply(**kwargs):
|
||||||
|
applied["called"] = True
|
||||||
|
applied["baseline_rule_codes"] = kwargs["baseline_rule_codes"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(file_service.phase2_baseline_service, "apply", fake_apply)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
file_service,
|
||||||
|
"_build_rule_hit_plan",
|
||||||
|
lambda log_id: {
|
||||||
|
"large_transaction_hit_rules": [],
|
||||||
|
"phase1_hit_rules": [],
|
||||||
|
"phase2_statement_hit_rules": ["MONTHLY_FIXED_INCOME"],
|
||||||
|
"phase2_baseline_hit_rules": ["SUPPLIER_CONCENTRATION"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/watson/api/project/getJZFileOrZjrcuFile",
|
||||||
|
data={
|
||||||
|
"groupId": 1001,
|
||||||
|
"customerNo": "phase2_customer",
|
||||||
|
"dataChannelCode": "channel_code",
|
||||||
|
"requestDateId": 20240101,
|
||||||
|
"dataStartDateId": 20240101,
|
||||||
|
"dataEndDateId": 20240131,
|
||||||
|
"uploadUserId": 902001,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert applied["called"] is True
|
||||||
|
assert applied["baseline_rule_codes"] == ["SUPPLIER_CONCENTRATION"]
|
||||||
|
|||||||
@@ -185,6 +185,17 @@ def test_build_rule_hit_plan_should_be_deterministic_for_same_log_id():
|
|||||||
assert 2 <= len(plan1["phase1_hit_rules"]) <= 4
|
assert 2 <= len(plan1["phase1_hit_rules"]) <= 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_phase2_rule_hit_plan_should_be_deterministic_for_same_log_id():
|
||||||
|
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||||
|
|
||||||
|
plan1 = service._build_rule_hit_plan(10001)
|
||||||
|
plan2 = service._build_rule_hit_plan(10001)
|
||||||
|
|
||||||
|
assert plan1 == plan2
|
||||||
|
assert 2 <= len(plan1["phase2_statement_hit_rules"]) <= 4
|
||||||
|
assert 2 <= len(plan1["phase2_baseline_hit_rules"]) <= 4
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch):
|
def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch):
|
||||||
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -218,3 +229,46 @@ def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch):
|
|||||||
"GAMBLING_SENSITIVE_KEYWORD",
|
"GAMBLING_SENSITIVE_KEYWORD",
|
||||||
"FOREX_BUY_AMT",
|
"FOREX_BUY_AMT",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_inner_flow_should_persist_phase2_rule_hit_plan(monkeypatch):
|
||||||
|
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_build_rule_hit_plan",
|
||||||
|
lambda log_id: {
|
||||||
|
"large_transaction_hit_rules": ["HOUSE_OR_CAR_EXPENSE", "TAX_EXPENSE"],
|
||||||
|
"phase1_hit_rules": ["GAMBLING_SENSITIVE_KEYWORD", "FOREX_BUY_AMT"],
|
||||||
|
"phase2_statement_hit_rules": [
|
||||||
|
"LOW_INCOME_RELATIVE_LARGE_TRANSACTION",
|
||||||
|
"SALARY_QUICK_TRANSFER",
|
||||||
|
],
|
||||||
|
"phase2_baseline_hit_rules": [
|
||||||
|
"HOUSE_REGISTRATION_MISMATCH",
|
||||||
|
"SUPPLIER_CONCENTRATION",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = service.fetch_inner_flow(
|
||||||
|
{
|
||||||
|
"groupId": 1001,
|
||||||
|
"customerNo": "test_customer_001",
|
||||||
|
"dataChannelCode": "test_code",
|
||||||
|
"requestDateId": 20240101,
|
||||||
|
"dataStartDateId": 20240101,
|
||||||
|
"dataEndDateId": 20240131,
|
||||||
|
"uploadUserId": 902001,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
log_id = response["data"][0]
|
||||||
|
record = service.file_records[log_id]
|
||||||
|
|
||||||
|
assert record.phase2_statement_hit_rules == [
|
||||||
|
"LOW_INCOME_RELATIVE_LARGE_TRANSACTION",
|
||||||
|
"SALARY_QUICK_TRANSFER",
|
||||||
|
]
|
||||||
|
assert record.phase2_baseline_hit_rules == [
|
||||||
|
"HOUSE_REGISTRATION_MISMATCH",
|
||||||
|
"SUPPLIER_CONCENTRATION",
|
||||||
|
]
|
||||||
|
|||||||
101
lsfx-mock-server/tests/test_phase2_baseline_service.py
Normal file
101
lsfx-mock-server/tests/test_phase2_baseline_service.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
第二期数据库基线服务测试
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
|
from services.phase2_baseline_service import Phase2BaselineService
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sql_plan_should_return_idempotent_sql_plan_for_selected_phase2_baselines():
|
||||||
|
"""抽中第二期基线规则时,应生成幂等 SQL 计划。"""
|
||||||
|
service = Phase2BaselineService()
|
||||||
|
|
||||||
|
sql_plan = service.build_sql_plan(
|
||||||
|
staff_id_card="330101198801010011",
|
||||||
|
family_id_cards=["330101199001010022"],
|
||||||
|
baseline_rule_codes=[
|
||||||
|
"SUPPLIER_CONCENTRATION",
|
||||||
|
"HOUSE_REGISTRATION_MISMATCH",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any("LSFXMOCKP2PUR001" in sql for sql in sql_plan)
|
||||||
|
assert any("LSFX Mock P2 HOUSE" in sql for sql in sql_plan)
|
||||||
|
assert any("'房产'" in sql for sql in sql_plan)
|
||||||
|
assert any("'正常'" in sql for sql in sql_plan)
|
||||||
|
assert any(sql.strip().startswith("DELETE") for sql in sql_plan)
|
||||||
|
assert any(sql.strip().startswith("INSERT") for sql in sql_plan)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sql_plan_should_skip_unselected_phase2_rules():
|
||||||
|
"""未选中的规则不应写入无关 SQL。"""
|
||||||
|
service = Phase2BaselineService()
|
||||||
|
|
||||||
|
sql_plan = service.build_sql_plan(
|
||||||
|
staff_id_card="330101198801010011",
|
||||||
|
family_id_cards=[],
|
||||||
|
baseline_rule_codes=["SUPPLIER_CONCENTRATION"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any("LSFXMOCKP2PUR001" in sql for sql in sql_plan)
|
||||||
|
assert not any("LSFX Mock P2 HOUSE" in sql for sql in sql_plan)
|
||||||
|
assert not any("ccdi_asset_info" in sql for sql in sql_plan)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sql_plan_should_use_staff_scope_for_family_asset_baselines():
|
||||||
|
"""亲属资产基线应保留员工归属与亲属实际持有人的双字段语义。"""
|
||||||
|
service = Phase2BaselineService()
|
||||||
|
|
||||||
|
sql_plan = service.build_sql_plan(
|
||||||
|
staff_id_card="330101198801010011",
|
||||||
|
family_id_cards=["330101199001010022"],
|
||||||
|
baseline_rule_codes=["PROPERTY_FEE_REGISTRATION_MISMATCH"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any("'330101198801010011'" in sql for sql in sql_plan)
|
||||||
|
assert any("'330101199001010022'" in sql for sql in sql_plan)
|
||||||
|
assert not any("'REAL_ESTATE'" in sql for sql in sql_plan)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_should_execute_generated_sql_plan(monkeypatch):
|
||||||
|
"""apply() 应执行生成出的 SQL 计划,而不是只返回字符串。"""
|
||||||
|
service = Phase2BaselineService()
|
||||||
|
executed_sql = []
|
||||||
|
committed = {"value": False}
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def execute(self, sql):
|
||||||
|
executed_sql.append(sql.strip())
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def __init__(self):
|
||||||
|
self.cursor_instance = FakeCursor()
|
||||||
|
|
||||||
|
def cursor(self):
|
||||||
|
return nullcontext(self.cursor_instance)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
committed["value"] = True
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
raise AssertionError("测试路径不应触发回滚")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_connect", lambda: FakeConnection())
|
||||||
|
|
||||||
|
result = service.apply(
|
||||||
|
staff_id_card="330101198801010011",
|
||||||
|
family_id_cards=["330101199001010022"],
|
||||||
|
baseline_rule_codes=["SUPPLIER_CONCENTRATION"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert committed["value"] is True
|
||||||
|
assert any("DELETE FROM ccdi_purchase_transaction" in sql for sql in executed_sql)
|
||||||
|
assert any("INSERT INTO ccdi_purchase_transaction" in sql for sql in executed_sql)
|
||||||
@@ -87,6 +87,54 @@ def test_build_seed_statements_for_rule_plan_should_generate_withdraw_cnt_sample
|
|||||||
) >= 4
|
) >= 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():
|
def test_large_transaction_seed_should_cover_all_eight_rules():
|
||||||
"""大额交易样本生成器必须覆盖 8 条已实现规则的关键口径。"""
|
"""大额交易样本生成器必须覆盖 8 条已实现规则的关键口径。"""
|
||||||
statements = build_large_transaction_seed_statements(group_id=1000, log_id=20001)
|
statements = build_large_transaction_seed_statements(group_id=1000, log_id=20001)
|
||||||
|
|||||||
191
sql/migration/2026-03-20-lsfx-mock-phase2-hit-baseline.sql
Normal file
191
sql/migration/2026-03-20-lsfx-mock-phase2-hit-baseline.sql
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
DELETE FROM ccdi_purchase_transaction
|
||||||
|
WHERE purchase_id = 'LSFXMOCKP2PUR001';
|
||||||
|
|
||||||
|
INSERT INTO ccdi_purchase_transaction (
|
||||||
|
purchase_id,
|
||||||
|
purchase_category,
|
||||||
|
project_name,
|
||||||
|
subject_name,
|
||||||
|
subject_desc,
|
||||||
|
purchase_qty,
|
||||||
|
budget_amount,
|
||||||
|
bid_amount,
|
||||||
|
actual_amount,
|
||||||
|
contract_amount,
|
||||||
|
settlement_amount,
|
||||||
|
purchase_method,
|
||||||
|
supplier_name,
|
||||||
|
contact_person,
|
||||||
|
contact_phone,
|
||||||
|
supplier_uscc,
|
||||||
|
supplier_bank_account,
|
||||||
|
apply_date,
|
||||||
|
plan_approve_date,
|
||||||
|
announce_date,
|
||||||
|
bid_open_date,
|
||||||
|
contract_sign_date,
|
||||||
|
expected_delivery_date,
|
||||||
|
actual_delivery_date,
|
||||||
|
acceptance_date,
|
||||||
|
settlement_date,
|
||||||
|
applicant_id,
|
||||||
|
applicant_name,
|
||||||
|
apply_department,
|
||||||
|
purchase_leader_id,
|
||||||
|
purchase_leader_name,
|
||||||
|
purchase_department,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'LSFXMOCKP2PUR001',
|
||||||
|
'设备采购',
|
||||||
|
'LSFX Mock P2',
|
||||||
|
'可疑采购基线',
|
||||||
|
'用于命中 SUPPLIER_CONCENTRATION 真实规则',
|
||||||
|
1,
|
||||||
|
188000.00,
|
||||||
|
186000.00,
|
||||||
|
186000.00,
|
||||||
|
186000.00,
|
||||||
|
186000.00,
|
||||||
|
'竞争性谈判',
|
||||||
|
'兰溪市联调供应链有限公司',
|
||||||
|
'联调联系人',
|
||||||
|
'13800000000',
|
||||||
|
'91330781P2PUR00011',
|
||||||
|
'6222000000001234',
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CURRENT_DATE,
|
||||||
|
CAST(s.staff_id AS CHAR),
|
||||||
|
s.name,
|
||||||
|
'纪检初核部',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
'admin',
|
||||||
|
'admin'
|
||||||
|
FROM ccdi_base_staff s
|
||||||
|
WHERE COALESCE(TRIM(s.id_card), '') <> ''
|
||||||
|
AND COALESCE(TRIM(CAST(s.staff_id AS CHAR)), '') <> ''
|
||||||
|
AND COALESCE(TRIM(s.name), '') <> ''
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
DELETE FROM ccdi_asset_info
|
||||||
|
WHERE asset_name = 'LSFX Mock P2 HOUSE_REGISTRATION_MISMATCH';
|
||||||
|
|
||||||
|
INSERT INTO ccdi_asset_info (
|
||||||
|
family_id,
|
||||||
|
person_id,
|
||||||
|
asset_main_type,
|
||||||
|
asset_sub_type,
|
||||||
|
asset_name,
|
||||||
|
ownership_ratio,
|
||||||
|
purchase_eval_date,
|
||||||
|
original_value,
|
||||||
|
current_value,
|
||||||
|
valuation_date,
|
||||||
|
asset_status,
|
||||||
|
remarks,
|
||||||
|
create_by,
|
||||||
|
update_by
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'330101198801010011',
|
||||||
|
'330101198801010011',
|
||||||
|
'房产',
|
||||||
|
'商铺',
|
||||||
|
'LSFX Mock P2 HOUSE_REGISTRATION_MISMATCH',
|
||||||
|
100.00,
|
||||||
|
CURRENT_DATE,
|
||||||
|
1880000.00,
|
||||||
|
1880000.00,
|
||||||
|
CURRENT_DATE,
|
||||||
|
'正常',
|
||||||
|
'用于命中 HOUSE_REGISTRATION_MISMATCH 真实规则的第二期基线',
|
||||||
|
'admin',
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM ccdi_asset_info
|
||||||
|
WHERE asset_name = 'LSFX Mock P2 PROPERTY_FEE_REGISTRATION_MISMATCH';
|
||||||
|
|
||||||
|
INSERT INTO ccdi_asset_info (
|
||||||
|
family_id,
|
||||||
|
person_id,
|
||||||
|
asset_main_type,
|
||||||
|
asset_sub_type,
|
||||||
|
asset_name,
|
||||||
|
ownership_ratio,
|
||||||
|
purchase_eval_date,
|
||||||
|
original_value,
|
||||||
|
current_value,
|
||||||
|
valuation_date,
|
||||||
|
asset_status,
|
||||||
|
remarks,
|
||||||
|
create_by,
|
||||||
|
update_by
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'330101198801010011',
|
||||||
|
'330101199001010022',
|
||||||
|
'房产',
|
||||||
|
'商铺',
|
||||||
|
'LSFX Mock P2 PROPERTY_FEE_REGISTRATION_MISMATCH',
|
||||||
|
100.00,
|
||||||
|
CURRENT_DATE,
|
||||||
|
1880000.00,
|
||||||
|
1880000.00,
|
||||||
|
CURRENT_DATE,
|
||||||
|
'正常',
|
||||||
|
'用于命中 PROPERTY_FEE_REGISTRATION_MISMATCH 真实规则的第二期基线',
|
||||||
|
'admin',
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM ccdi_asset_info
|
||||||
|
WHERE asset_name = 'LSFX Mock P2 TAX_ASSET_REGISTRATION_MISMATCH';
|
||||||
|
|
||||||
|
INSERT INTO ccdi_asset_info (
|
||||||
|
family_id,
|
||||||
|
person_id,
|
||||||
|
asset_main_type,
|
||||||
|
asset_sub_type,
|
||||||
|
asset_name,
|
||||||
|
ownership_ratio,
|
||||||
|
purchase_eval_date,
|
||||||
|
original_value,
|
||||||
|
current_value,
|
||||||
|
valuation_date,
|
||||||
|
asset_status,
|
||||||
|
remarks,
|
||||||
|
create_by,
|
||||||
|
update_by
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'330101198801010011',
|
||||||
|
'330101199202020044',
|
||||||
|
'房产',
|
||||||
|
'商铺',
|
||||||
|
'LSFX Mock P2 TAX_ASSET_REGISTRATION_MISMATCH',
|
||||||
|
100.00,
|
||||||
|
CURRENT_DATE,
|
||||||
|
1880000.00,
|
||||||
|
1880000.00,
|
||||||
|
CURRENT_DATE,
|
||||||
|
'正常',
|
||||||
|
'用于命中 TAX_ASSET_REGISTRATION_MISMATCH 真实规则的第二期基线',
|
||||||
|
'admin',
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user