修复Mock流水按数据库员工及亲属绑定身份证
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# LSFX Mock 数据库身份绑定实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 `lsfx-mock-server` 在创建 `logId` 时从员工信息库随机选择一个员工,并读取该员工亲属,后续同一 `logId` 的流水仅使用这组数据库身份。
|
||||
|
||||
**Architecture:** 在 Mock 服务中新增一个只读身份仓储,负责从 `ccdi_base_staff` 与 `ccdi_staff_fmy_relation` 读取员工及亲属。`FileService` 在创建 `FileRecord` 时写入选中的员工/亲属身份,`StatementService` 只复用该记录中的证件号,不再依赖内置固定身份证池。
|
||||
|
||||
**Tech Stack:** FastAPI, Python 3.9, PyMySQL, pytest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 补数据库身份绑定失败测试
|
||||
|
||||
**Files:**
|
||||
- Modify: `lsfx-mock-server/tests/test_file_service.py`
|
||||
- Modify: `lsfx-mock-server/tests/test_statement_service.py`
|
||||
|
||||
- [ ] Step 1: 写出 `FileService` 需要保存员工与亲属身份的失败测试
|
||||
- [ ] Step 2: 运行对应测试,确认当前实现失败
|
||||
- [ ] Step 3: 写出 `StatementService` 只能使用该员工及其亲属证件号的失败测试
|
||||
- [ ] Step 4: 运行对应测试,确认当前实现失败
|
||||
|
||||
### Task 2: 新增只读身份仓储
|
||||
|
||||
**Files:**
|
||||
- Create: `lsfx-mock-server/services/staff_identity_repository.py`
|
||||
- Modify: `lsfx-mock-server/config/settings.py`
|
||||
- Modify: `lsfx-mock-server/requirements.txt`
|
||||
|
||||
- [ ] Step 1: 定义员工/亲属身份查询结果结构与仓储接口
|
||||
- [ ] Step 2: 接入 MySQL 只读查询配置
|
||||
- [ ] Step 3: 实现“随机取一个有身份证号员工 + 读取该员工有效亲属”的最小查询逻辑
|
||||
- [ ] Step 4: 保持仓储可被测试替身替换
|
||||
|
||||
### Task 3: 将 logId 与数据库身份绑定
|
||||
|
||||
**Files:**
|
||||
- Modify: `lsfx-mock-server/services/file_service.py`
|
||||
- Modify: `lsfx-mock-server/services/statement_service.py`
|
||||
- Modify: `lsfx-mock-server/routers/api.py`
|
||||
|
||||
- [ ] Step 1: 在 `FileRecord` 中增加员工/亲属身份字段
|
||||
- [ ] Step 2: 在上传文件与拉取行内流水时写入随机员工及其亲属
|
||||
- [ ] Step 3: 在流水生成阶段只使用 `FileRecord` 中的员工/亲属证件号
|
||||
- [ ] Step 4: 保留 deterministic fallback,但不影响真实记录链路
|
||||
|
||||
### Task 4: 回归验证与文档沉淀
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/reports/implementation/2026-03-19-lsfx-mock-db-backed-identity-selection-fix.md`
|
||||
|
||||
- [ ] Step 1: 运行 `lsfx-mock-server` 相关测试
|
||||
- [ ] Step 2: 记录根因、改动点和验证结果
|
||||
@@ -0,0 +1,58 @@
|
||||
# 2026-03-19 LSFX Mock 数据库身份绑定修复记录
|
||||
|
||||
## 背景
|
||||
|
||||
- 调整目标:一个 `logId` 需要从员工信息库随机选取一个员工身份证号,并且该员工的亲属证件号也必须从数据库读取。
|
||||
- 业务口径:同一个 `logId` 的流水证件号范围仅允许使用“该员工本人 + 该员工亲属”。
|
||||
|
||||
## 根因
|
||||
|
||||
- `lsfx-mock-server` 之前使用内置固定身份证池生成 `cretNo`,没有接入 `ccdi_base_staff` 和 `ccdi_staff_fmy_relation`。
|
||||
- 即使前一版已经收敛到单员工域,身份来源仍然是 mock 常量,不满足“从数据库读取员工及亲属”的要求。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. 新增只读员工身份仓储
|
||||
|
||||
- 新增 `lsfx-mock-server/services/staff_identity_repository.py`
|
||||
- 仓储使用 `PyMySQL` 只读查询:
|
||||
- 从 `ccdi_base_staff` 随机选取一个有身份证号的在职员工
|
||||
- 优先选择存在有效亲属记录的员工
|
||||
- 从 `ccdi_staff_fmy_relation` 读取该员工的有效亲属证件号
|
||||
|
||||
### 2. FileRecord 绑定数据库身份
|
||||
|
||||
- `lsfx-mock-server/services/file_service.py`
|
||||
- `FileService` 支持注入身份仓储
|
||||
- `FileRecord` 新增:
|
||||
- `staff_name`
|
||||
- `staff_id_card`
|
||||
- `family_id_cards`
|
||||
- 上传文件与拉取行内流水时,创建 `logId` 的同时写入该员工及亲属身份
|
||||
|
||||
### 3. 流水生成只复用绑定身份
|
||||
|
||||
- `lsfx-mock-server/services/statement_service.py`
|
||||
- 若 `logId` 已存在真实 `FileRecord`,则流水生成阶段只使用该记录中的 `staff_id_card` 与 `family_id_cards`
|
||||
- `lsfx-mock-server/services/statement_rule_samples.py`
|
||||
- 固定命中样本支持接收外部传入的员工/亲属证件号,避免继续依赖内置固定身份证
|
||||
|
||||
### 4. 配置与测试
|
||||
|
||||
- `lsfx-mock-server/config/settings.py`
|
||||
- 从主项目 `ruoyi-admin/src/main/resources/application-dev.yml` 提取数据库连接默认值
|
||||
- `lsfx-mock-server/requirements.txt`
|
||||
- 新增 `PyMySQL`
|
||||
- 测试层新增假仓储,避免单元/集成测试访问真实数据库
|
||||
|
||||
## 验证结果
|
||||
|
||||
- 执行:
|
||||
- `cd lsfx-mock-server && python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/integration/test_full_workflow.py -q`
|
||||
- 结果:
|
||||
- `17 passed`
|
||||
|
||||
## 影响说明
|
||||
|
||||
- 本次变更仅作用于 `lsfx-mock-server`。
|
||||
- 真实运行时会按数据库随机绑定员工与亲属;测试环境仍通过假仓储保持可控与稳定。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 2026-03-19 LSFX Mock 单员工域修复记录
|
||||
|
||||
## 背景
|
||||
|
||||
- 现象:新建项目后导入单个流水文件,解析完成后结果总览出现两个员工信息。
|
||||
- 预期:单个上传文件只应归属到一个员工域,结果总览最多出现该员工本人对应的一条员工信息。
|
||||
|
||||
## 根因
|
||||
|
||||
- `lsfx-mock-server/services/statement_rule_samples.py` 中的大额交易固定样本同时混入了两名员工及两名家属的证件号。
|
||||
- `lsfx-mock-server/services/statement_service.py` 中的随机噪声流水继续从四个证件号的全量池随机取值。
|
||||
- 因此同一个 `logId` 返回的流水天然会覆盖两个员工域,主系统按 `cret_no` 聚合后就会出现两个员工。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### Mock 造数收敛为单员工域
|
||||
|
||||
- 在 `lsfx-mock-server/services/statement_rule_samples.py` 新增 `IDENTITY_SCOPES`。
|
||||
- 新增 `resolve_identity_scope(log_id)` 和 `resolve_identity_cards(log_id)`,按 `logId` 稳定选择单个员工域。
|
||||
- 将固定命中样本从“跨两个员工域混用”改为“只使用当前员工域的员工本人及家属”。
|
||||
|
||||
### 随机噪声不再污染其他员工
|
||||
|
||||
- 在 `lsfx-mock-server/services/statement_service.py` 中改为按 `logId` 读取允许的证件号范围。
|
||||
- 随机噪声流水的 `cretNo` 仅能从当前员工域的两张证件号中选择。
|
||||
|
||||
### 测试补充
|
||||
|
||||
- 在 `lsfx-mock-server/tests/test_statement_service.py` 增加“同一 `logId` 只能落在单一员工域”测试。
|
||||
- 调整大额交易样本测试,改为基于 `resolve_identity_scope(log_id)` 校验,避免继续依赖固定第二名员工。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- 执行:
|
||||
- `cd lsfx-mock-server && python3 -m pytest tests/test_statement_service.py tests/test_file_service.py tests/integration/test_full_workflow.py -q`
|
||||
- 结果:
|
||||
- `17 passed`
|
||||
|
||||
## 影响说明
|
||||
|
||||
- 本次只修改 `lsfx-mock-server` 的 mock 造数逻辑,不影响主系统 Java/Vue 代码。
|
||||
- 已存在库中的历史测试项目数据不会自动回写;如需验证页面结果,需要重启 mock 服务后重新上传文件生成新流水。
|
||||
@@ -1,5 +1,30 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def _load_ruoyi_mysql_defaults() -> dict:
|
||||
"""从主项目开发配置中提取 MySQL 连接默认值。"""
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
config_path = project_root / "ruoyi-admin" / "src" / "main" / "resources" / "application-dev.yml"
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
|
||||
text = config_path.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"master:\s*\n"
|
||||
r"\s*url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<database>[^?\s]+)[^\n]*\n"
|
||||
r"\s*username:\s*(?P<username>[^\n]+)\n"
|
||||
r"\s*password:\s*(?P<password>[^\n]+)",
|
||||
text,
|
||||
)
|
||||
if not match:
|
||||
return {}
|
||||
|
||||
return {key: value.strip() for key, value in match.groupdict().items()}
|
||||
|
||||
|
||||
MYSQL_DEFAULTS = _load_ruoyi_mysql_defaults()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -22,6 +47,14 @@ class Settings(BaseSettings):
|
||||
INITIAL_PROJECT_ID: int = 1000
|
||||
INITIAL_LOG_ID: int = 10000
|
||||
|
||||
# 员工库只读配置
|
||||
CCDI_DB_HOST: str = MYSQL_DEFAULTS.get("host", "")
|
||||
CCDI_DB_PORT: int = int(MYSQL_DEFAULTS.get("port", 3306))
|
||||
CCDI_DB_NAME: str = MYSQL_DEFAULTS.get("database", "")
|
||||
CCDI_DB_USERNAME: str = MYSQL_DEFAULTS.get("username", "")
|
||||
CCDI_DB_PASSWORD: str = MYSQL_DEFAULTS.get("password", "")
|
||||
CCDI_DB_CONNECT_TIMEOUT_SECONDS: int = 5
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
@@ -6,3 +6,4 @@ python-multipart==0.0.6
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
httpx>=0.25.0
|
||||
PyMySQL==1.1.1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import BackgroundTasks, UploadFile
|
||||
from utils.response_builder import ResponseBuilder
|
||||
from config.settings import settings
|
||||
from services.staff_identity_repository import StaffIdentityRepository
|
||||
from typing import Dict, List, Union
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
@@ -59,13 +60,19 @@ class FileRecord:
|
||||
trx_date_start_id: int = 20240101
|
||||
trx_date_end_id: int = 20241231
|
||||
|
||||
# 新增字段 - 身份绑定
|
||||
staff_name: str = ""
|
||||
staff_id_card: str = ""
|
||||
family_id_cards: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class FileService:
|
||||
"""文件上传和解析服务"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, staff_identity_repository=None):
|
||||
self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord
|
||||
self.log_counter = settings.INITIAL_LOG_ID
|
||||
self.staff_identity_repository = staff_identity_repository or StaffIdentityRepository()
|
||||
|
||||
def get_file_record(self, log_id: int) -> FileRecord:
|
||||
"""按 logId 获取已存在的文件记录。"""
|
||||
@@ -118,6 +125,9 @@ class FileService:
|
||||
trx_date_end_id: int,
|
||||
le_id: int,
|
||||
login_le_id: int,
|
||||
staff_name: str = "",
|
||||
staff_id_card: str = "",
|
||||
family_id_cards: List[str] = None,
|
||||
parsing: bool = True,
|
||||
status: int = -5,
|
||||
) -> FileRecord:
|
||||
@@ -145,10 +155,17 @@ class FileService:
|
||||
total_records=total_records,
|
||||
trx_date_start_id=trx_date_start_id,
|
||||
trx_date_end_id=trx_date_end_id,
|
||||
staff_name=staff_name,
|
||||
staff_id_card=staff_id_card,
|
||||
family_id_cards=list(family_id_cards or []),
|
||||
parsing=parsing,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def _select_staff_identity_scope(self) -> dict:
|
||||
"""读取一个员工及其亲属身份范围。"""
|
||||
return self.staff_identity_repository.select_random_staff_with_families()
|
||||
|
||||
async def upload_file(
|
||||
self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks
|
||||
) -> Dict:
|
||||
@@ -177,6 +194,7 @@ class FileService:
|
||||
|
||||
# 生成单一主绑定
|
||||
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
|
||||
identity_scope = self._select_staff_identity_scope()
|
||||
|
||||
# 创建完整的文件记录
|
||||
file_record = self._create_file_record(
|
||||
@@ -194,6 +212,9 @@ class FileService:
|
||||
trx_date_end_id=trx_date_end_id,
|
||||
le_id=10000 + random.randint(0, 9999),
|
||||
login_le_id=10000 + random.randint(0, 9999),
|
||||
staff_name=identity_scope["staff_name"],
|
||||
staff_id_card=identity_scope["staff_id_card"],
|
||||
family_id_cards=identity_scope["family_id_cards"],
|
||||
)
|
||||
|
||||
# 存储记录
|
||||
@@ -500,6 +521,7 @@ class FileService:
|
||||
log_id = self.log_counter
|
||||
|
||||
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
|
||||
identity_scope = self._select_staff_identity_scope()
|
||||
file_record = self._create_file_record(
|
||||
log_id=log_id,
|
||||
group_id=group_id,
|
||||
@@ -515,6 +537,9 @@ class FileService:
|
||||
trx_date_end_id=data_end_date_id,
|
||||
le_id=10000 + random.randint(0, 9999),
|
||||
login_le_id=10000 + random.randint(0, 9999),
|
||||
staff_name=identity_scope["staff_name"],
|
||||
staff_id_card=identity_scope["staff_id_card"],
|
||||
family_id_cards=identity_scope["family_id_cards"],
|
||||
parsing=False,
|
||||
)
|
||||
|
||||
|
||||
87
lsfx-mock-server/services/staff_identity_repository.py
Normal file
87
lsfx-mock-server/services/staff_identity_repository.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from typing import Dict, List
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
|
||||
class StaffIdentityRepository:
|
||||
"""从员工信息库中读取员工及亲属身份证信息。"""
|
||||
|
||||
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,
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
|
||||
def select_random_staff_with_families(self) -> Dict[str, object]:
|
||||
"""随机选择一个员工,并读取其有效亲属证件号。"""
|
||||
with self._connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT s.name AS staff_name, s.id_card AS staff_id_card
|
||||
FROM ccdi_base_staff s
|
||||
WHERE COALESCE(TRIM(s.id_card), '') <> ''
|
||||
AND s.status = '0'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM ccdi_staff_fmy_relation r
|
||||
WHERE r.person_id = s.id_card
|
||||
AND r.status = 1
|
||||
AND COALESCE(TRIM(r.relation_cert_no), '') <> ''
|
||||
)
|
||||
ORDER BY RAND()
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
staff = cursor.fetchone()
|
||||
if not staff:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT s.name AS staff_name, s.id_card AS staff_id_card
|
||||
FROM ccdi_base_staff s
|
||||
WHERE COALESCE(TRIM(s.id_card), '') <> ''
|
||||
AND s.status = '0'
|
||||
ORDER BY RAND()
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
staff = cursor.fetchone()
|
||||
|
||||
if not staff:
|
||||
raise RuntimeError("员工信息库中不存在可用身份证号")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT DISTINCT relation_cert_no
|
||||
FROM ccdi_staff_fmy_relation
|
||||
WHERE person_id = %s
|
||||
AND status = 1
|
||||
AND COALESCE(TRIM(relation_cert_no), '') <> ''
|
||||
ORDER BY relation_cert_no
|
||||
""",
|
||||
(staff["staff_id_card"],),
|
||||
)
|
||||
family_rows = cursor.fetchall()
|
||||
|
||||
family_id_cards: List[str] = [
|
||||
row["relation_cert_no"]
|
||||
for row in family_rows
|
||||
if row["relation_cert_no"] != staff["staff_id_card"]
|
||||
]
|
||||
|
||||
return {
|
||||
"staff_name": staff["staff_name"],
|
||||
"staff_id_card": staff["staff_id_card"],
|
||||
"family_id_cards": family_id_cards,
|
||||
}
|
||||
@@ -34,11 +34,33 @@ IDENTITY_POOL = {
|
||||
},
|
||||
}
|
||||
|
||||
IDENTITY_SCOPES = {
|
||||
"primary": {
|
||||
"staff": IDENTITY_POOL["staff_primary"],
|
||||
"family": IDENTITY_POOL["family_primary"],
|
||||
},
|
||||
"secondary": {
|
||||
"staff": IDENTITY_POOL["staff_secondary"],
|
||||
"family": IDENTITY_POOL["family_secondary"],
|
||||
},
|
||||
}
|
||||
|
||||
IDENTITY_CARD_POOL = tuple(identity["id_card"] for identity in IDENTITY_POOL.values())
|
||||
|
||||
REFERENCE_NOW = datetime(2026, 3, 18, 9, 0, 0)
|
||||
|
||||
|
||||
def resolve_identity_scope(log_id: int) -> Dict[str, Dict[str, str]]:
|
||||
"""按 logId 稳定选择单个员工域。"""
|
||||
return IDENTITY_SCOPES["primary"] if log_id % 2 == 1 else IDENTITY_SCOPES["secondary"]
|
||||
|
||||
|
||||
def resolve_identity_cards(log_id: int) -> tuple:
|
||||
"""返回指定 logId 允许出现的证件号集合(员工本人及家属)。"""
|
||||
identity_scope = resolve_identity_scope(log_id)
|
||||
return tuple(identity["id_card"] for identity in identity_scope.values())
|
||||
|
||||
|
||||
def _format_datetime(value: datetime) -> str:
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@@ -124,9 +146,17 @@ def build_large_transaction_seed_statements(
|
||||
log_id: int,
|
||||
primary_enterprise_name: Optional[str] = None,
|
||||
primary_account_no: Optional[str] = None,
|
||||
staff_id_card: Optional[str] = None,
|
||||
family_id_cards: Optional[List[str]] = None,
|
||||
) -> List[Dict]:
|
||||
le_name = primary_enterprise_name or "模型测试主体"
|
||||
account_no = primary_account_no or "6222024999999999"
|
||||
identity_scope = resolve_identity_scope(log_id)
|
||||
staff_identity = identity_scope["staff"]
|
||||
family_identity = identity_scope["family"]
|
||||
selected_staff_id_card = staff_id_card or staff_identity["id_card"]
|
||||
selected_family_id_cards = list(family_id_cards or [family_identity["id_card"]])
|
||||
primary_family_id_card = selected_family_id_cards[0] if selected_family_id_cards else selected_staff_id_card
|
||||
|
||||
statements: List[Dict] = []
|
||||
|
||||
@@ -135,7 +165,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=9, hours=1),
|
||||
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="杭州贝壳房地产经纪有限公司",
|
||||
user_memo="购买房产首付款",
|
||||
cash_type="对公转账",
|
||||
@@ -148,7 +178,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=8, hours=2),
|
||||
cret_no=IDENTITY_POOL["family_primary"]["id_card"],
|
||||
cret_no=primary_family_id_card,
|
||||
customer_name="兰溪星耀汽车销售服务有限公司",
|
||||
user_memo="购车首付款",
|
||||
cash_type="对公转账",
|
||||
@@ -161,7 +191,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=7, hours=1),
|
||||
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="国家金库兰溪市中心支库",
|
||||
user_memo="个人所得税税款",
|
||||
cash_type="税务缴款",
|
||||
@@ -174,7 +204,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=6, hours=3),
|
||||
cret_no=IDENTITY_POOL["family_secondary"]["id_card"],
|
||||
cret_no=primary_family_id_card,
|
||||
customer_name="兰溪市税务局",
|
||||
user_memo="房产税务缴税",
|
||||
cash_type="税务缴款",
|
||||
@@ -187,7 +217,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=2),
|
||||
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="浙江远望贸易有限公司",
|
||||
user_memo="经营往来收入",
|
||||
cash_type="对公转账",
|
||||
@@ -200,7 +230,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=1),
|
||||
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="浙江远望贸易有限公司",
|
||||
user_memo="项目回款收入",
|
||||
cash_type="对公转账",
|
||||
@@ -213,7 +243,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=4, hours=4),
|
||||
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="浙江远望贸易有限公司",
|
||||
user_memo="业务合作收入",
|
||||
cash_type="对公转账",
|
||||
@@ -226,7 +256,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=datetime(2026, 3, 10, 9, 0, 0),
|
||||
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="",
|
||||
user_memo="现金存款",
|
||||
cash_type="现金存款",
|
||||
@@ -238,7 +268,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=datetime(2026, 3, 10, 9, 30, 0),
|
||||
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="",
|
||||
user_memo="ATM现金存款",
|
||||
cash_type="现金存款",
|
||||
@@ -250,7 +280,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=datetime(2026, 3, 10, 10, 0, 0),
|
||||
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="",
|
||||
user_memo="自助存款现金存入",
|
||||
cash_type="现金存款",
|
||||
@@ -262,7 +292,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=datetime(2026, 3, 10, 10, 30, 0),
|
||||
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="",
|
||||
user_memo="CRS存款",
|
||||
cash_type="现金存款",
|
||||
@@ -274,7 +304,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=datetime(2026, 3, 10, 11, 0, 0),
|
||||
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="",
|
||||
user_memo="本行ATM存款",
|
||||
cash_type="现金存款",
|
||||
@@ -286,7 +316,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=datetime(2026, 3, 10, 11, 30, 0),
|
||||
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="",
|
||||
user_memo="柜面现金存款",
|
||||
cash_type="现金存款",
|
||||
@@ -298,7 +328,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=1),
|
||||
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="异地转账平台",
|
||||
user_memo="手机银行转账",
|
||||
cash_type="转账支出",
|
||||
@@ -311,7 +341,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=2),
|
||||
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="跨行转账中心",
|
||||
user_memo="对外转账",
|
||||
cash_type="转账支出",
|
||||
@@ -324,7 +354,7 @@ def build_large_transaction_seed_statements(
|
||||
group_id,
|
||||
log_id,
|
||||
trx_datetime=REFERENCE_NOW - timedelta(days=2, hours=5),
|
||||
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
|
||||
cret_no=selected_staff_id_card,
|
||||
customer_name="跨境转账服务平台",
|
||||
user_memo="网银转账",
|
||||
cash_type="转账支出",
|
||||
|
||||
@@ -5,8 +5,8 @@ import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from services.statement_rule_samples import (
|
||||
IDENTITY_CARD_POOL,
|
||||
build_large_transaction_seed_statements,
|
||||
resolve_identity_cards,
|
||||
)
|
||||
|
||||
# 配置日志
|
||||
@@ -39,6 +39,7 @@ class StatementService:
|
||||
log_id: int,
|
||||
primary_enterprise_name: str,
|
||||
primary_account_no: str,
|
||||
allowed_identity_cards: tuple,
|
||||
rng: random.Random,
|
||||
) -> Dict:
|
||||
"""生成单条随机噪声流水记录。"""
|
||||
@@ -87,7 +88,7 @@ class StatementService:
|
||||
"crAmount": cr_amount,
|
||||
"createDate": reference_now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"createdBy": "902001",
|
||||
"cretNo": rng.choice(IDENTITY_CARD_POOL),
|
||||
"cretNo": rng.choice(allowed_identity_cards),
|
||||
"currency": "CNY",
|
||||
"customerAccountMaskNo": str(rng.randint(100000000, 999999999)),
|
||||
"customerBank": "",
|
||||
@@ -140,12 +141,19 @@ class StatementService:
|
||||
def _generate_statements(self, group_id: int, log_id: int, count: int) -> List[Dict]:
|
||||
"""生成指定数量的流水记录。"""
|
||||
primary_enterprise_name, primary_account_no = self._resolve_primary_binding(log_id)
|
||||
record = self.file_service.get_file_record(log_id) if self.file_service is not None else None
|
||||
if record is not None and record.staff_id_card:
|
||||
allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards])
|
||||
else:
|
||||
allowed_identity_cards = resolve_identity_cards(log_id)
|
||||
rng = random.Random(f"statement:{log_id}")
|
||||
seeded_statements = build_large_transaction_seed_statements(
|
||||
group_id=group_id,
|
||||
log_id=log_id,
|
||||
primary_enterprise_name=primary_enterprise_name,
|
||||
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,
|
||||
)
|
||||
|
||||
total_count = max(count, len(seeded_statements))
|
||||
@@ -157,6 +165,7 @@ class StatementService:
|
||||
log_id,
|
||||
primary_enterprise_name,
|
||||
primary_account_no,
|
||||
allowed_identity_cards,
|
||||
rng,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -14,11 +14,21 @@ from config.settings import settings
|
||||
from routers.api import file_service
|
||||
|
||||
|
||||
class FakeStaffIdentityRepository:
|
||||
def select_random_staff_with_families(self):
|
||||
return {
|
||||
"staff_name": "测试员工",
|
||||
"staff_id_card": "320101199001010030",
|
||||
"family_id_cards": ["320101199201010051"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_file_service_state():
|
||||
"""避免 file_service 单例状态影响测试顺序。"""
|
||||
file_service.file_records.clear()
|
||||
file_service.log_counter = settings.INITIAL_LOG_ID
|
||||
file_service.staff_identity_repository = FakeStaffIdentityRepository()
|
||||
yield
|
||||
file_service.file_records.clear()
|
||||
file_service.log_counter = settings.INITIAL_LOG_ID
|
||||
|
||||
@@ -11,9 +11,18 @@ from fastapi.datastructures import UploadFile
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
class FakeStaffIdentityRepository:
|
||||
def select_random_staff_with_families(self):
|
||||
return {
|
||||
"staff_name": "数据库员工",
|
||||
"staff_id_card": "320101199001010030",
|
||||
"family_id_cards": ["320101199201010051", "320101199301010052"],
|
||||
}
|
||||
|
||||
|
||||
def test_upload_file_primary_binding_response(monkeypatch):
|
||||
"""同一 logId 的主绑定必须稳定且只保留一组主体/账号信息。"""
|
||||
service = FileService()
|
||||
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
@@ -42,7 +51,7 @@ def test_upload_file_primary_binding_response(monkeypatch):
|
||||
|
||||
def test_upload_file_total_records_range(monkeypatch):
|
||||
"""上传文件返回的流水条数必须限制在 150-200 条。"""
|
||||
service = FileService()
|
||||
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
@@ -69,7 +78,7 @@ def test_upload_file_total_records_range(monkeypatch):
|
||||
|
||||
def test_upload_file_then_upload_status_reads_same_record(monkeypatch):
|
||||
"""上传后再查状态时,上传状态接口必须读取同一条真实记录。"""
|
||||
service = FileService()
|
||||
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
@@ -108,8 +117,8 @@ def test_upload_file_then_upload_status_reads_same_record(monkeypatch):
|
||||
|
||||
|
||||
def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
|
||||
"""拉取行内流水必须创建并保存绑定记录。"""
|
||||
service = FileService()
|
||||
"""拉取行内流水必须创建并保存数据库员工及亲属身份。"""
|
||||
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
@@ -137,6 +146,9 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
|
||||
assert record.parsing is False
|
||||
assert record.primary_enterprise_name
|
||||
assert record.primary_account_no
|
||||
assert record.staff_name == "数据库员工"
|
||||
assert record.staff_id_card == "320101199001010030"
|
||||
assert record.family_id_cards == ["320101199201010051", "320101199301010052"]
|
||||
assert record.primary_enterprise_name == "行内主体"
|
||||
assert record.primary_account_no == "6210987654321098"
|
||||
assert record.enterprise_name_list == ["行内主体"]
|
||||
|
||||
@@ -12,6 +12,15 @@ from services.statement_rule_samples import (
|
||||
)
|
||||
|
||||
|
||||
class FakeStaffIdentityRepository:
|
||||
def select_random_staff_with_families(self):
|
||||
return {
|
||||
"staff_name": "数据库员工",
|
||||
"staff_id_card": "320101199001010030",
|
||||
"family_id_cards": ["320101199201010051", "320101199301010052"],
|
||||
}
|
||||
|
||||
|
||||
def test_generate_statements_should_include_seeded_samples_before_noise():
|
||||
"""生成流水时必须先混入固定命中样本,而不是纯随机噪声。"""
|
||||
service = StatementService()
|
||||
@@ -89,6 +98,30 @@ def test_generate_statements_should_fill_noise_up_to_requested_count():
|
||||
assert len(statements) == 80
|
||||
|
||||
|
||||
def test_generate_statements_should_stay_within_single_employee_scope_per_log_id():
|
||||
"""同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。"""
|
||||
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||
service = StatementService(file_service=file_service)
|
||||
response = file_service.fetch_inner_flow(
|
||||
{
|
||||
"groupId": 1001,
|
||||
"customerNo": "customer_scope",
|
||||
"dataChannelCode": "test",
|
||||
"requestDateId": 20240101,
|
||||
"dataStartDateId": 20240101,
|
||||
"dataEndDateId": 20240131,
|
||||
"uploadUserId": 902001,
|
||||
}
|
||||
)
|
||||
log_id = response["data"][0]
|
||||
record = file_service.file_records[log_id]
|
||||
allowed_id_cards = {record.staff_id_card, *record.family_id_cards}
|
||||
|
||||
statements = service._generate_statements(group_id=1000, log_id=log_id, count=1600)
|
||||
|
||||
assert {item["cretNo"] for item in statements}.issubset(allowed_id_cards)
|
||||
|
||||
|
||||
def test_generate_statements_should_only_use_recognizable_identity_cards():
|
||||
"""命中样本和随机噪声都只能使用现库可识别的身份证号。"""
|
||||
service = StatementService()
|
||||
@@ -121,7 +154,7 @@ def test_get_bank_statement_should_keep_same_cached_result_for_same_log_id():
|
||||
|
||||
def test_get_bank_statement_uses_primary_binding_from_file_service(monkeypatch):
|
||||
"""同一 logId 的流水记录必须复用 FileService 中的主体与账号绑定。"""
|
||||
file_service = FileService()
|
||||
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||
statement_service = StatementService(file_service=file_service)
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -161,7 +194,7 @@ def test_get_bank_statement_uses_primary_binding_from_file_service(monkeypatch):
|
||||
|
||||
def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
|
||||
"""流水 Mock 首次生成时必须稳定包含可命中大额交易规则的样本簇。"""
|
||||
file_service = FileService()
|
||||
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
|
||||
statement_service = StatementService(file_service=file_service)
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -182,6 +215,9 @@ def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
|
||||
}
|
||||
)
|
||||
log_id = response["data"][0]
|
||||
record = file_service.file_records[log_id]
|
||||
staff_id_card = record.staff_id_card
|
||||
family_id_card = record.family_id_cards[0]
|
||||
|
||||
statement_response = statement_service.get_bank_statement(
|
||||
{
|
||||
@@ -195,12 +231,7 @@ def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
|
||||
|
||||
assert statements
|
||||
assert any(
|
||||
item["cretNo"] in {
|
||||
"330101198801010011",
|
||||
"330101199001010022",
|
||||
"330101198802020033",
|
||||
"330101199202020044",
|
||||
}
|
||||
item["cretNo"] in {staff_id_card, family_id_card}
|
||||
for item in statements
|
||||
)
|
||||
assert any("房产首付款" in item["userMemo"] for item in statements)
|
||||
@@ -212,7 +243,7 @@ def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
|
||||
|
||||
for item in statements:
|
||||
if (
|
||||
item["cretNo"] == "330101198802020033"
|
||||
item["cretNo"] == staff_id_card
|
||||
and item["customerName"] == "浙江远望贸易有限公司"
|
||||
and item["crAmount"] > 0
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user