2026-03-13 15:13:18 +08:00
|
|
|
|
from fastapi import BackgroundTasks, UploadFile
|
|
|
|
|
|
from utils.response_builder import ResponseBuilder
|
|
|
|
|
|
from config.settings import settings
|
2026-03-22 11:48:22 +08:00
|
|
|
|
from services.phase2_baseline_service import Phase2BaselineService
|
2026-03-19 16:07:28 +08:00
|
|
|
|
from services.staff_identity_repository import StaffIdentityRepository
|
2026-03-13 15:13:18 +08:00
|
|
|
|
from typing import Dict, List, Union
|
2026-03-13 16:38:07 +08:00
|
|
|
|
from dataclasses import dataclass, field
|
2026-03-13 15:13:18 +08:00
|
|
|
|
import time
|
2026-03-13 16:38:07 +08:00
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
import random
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
2026-03-20 14:42:11 +08:00
|
|
|
|
LARGE_TRANSACTION_RULE_CODES = [
|
|
|
|
|
|
"HOUSE_OR_CAR_EXPENSE",
|
|
|
|
|
|
"TAX_EXPENSE",
|
|
|
|
|
|
"SINGLE_LARGE_INCOME",
|
|
|
|
|
|
"CUMULATIVE_INCOME",
|
|
|
|
|
|
"ANNUAL_TURNOVER",
|
|
|
|
|
|
"LARGE_CASH_DEPOSIT",
|
|
|
|
|
|
"FREQUENT_CASH_DEPOSIT",
|
|
|
|
|
|
"LARGE_TRANSFER",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
PHASE1_RULE_CODES = [
|
|
|
|
|
|
"GAMBLING_SENSITIVE_KEYWORD",
|
|
|
|
|
|
"SPECIAL_AMOUNT_TRANSACTION",
|
|
|
|
|
|
"SUSPICIOUS_INCOME_KEYWORD",
|
|
|
|
|
|
"FOREX_BUY_AMT",
|
|
|
|
|
|
"FOREX_SELL_AMT",
|
|
|
|
|
|
"STOCK_TFR_LARGE",
|
|
|
|
|
|
"LARGE_STOCK_TRADING",
|
|
|
|
|
|
"WITHDRAW_CNT",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-22 11:48:22 +08:00
|
|
|
|
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",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-31 20:40:38 +08:00
|
|
|
|
ABNORMAL_ACCOUNT_RULE_CODES = [
|
|
|
|
|
|
"SUDDEN_ACCOUNT_CLOSURE",
|
|
|
|
|
|
"DORMANT_ACCOUNT_LARGE_ACTIVATION",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-25 10:28:08 +08:00
|
|
|
|
MONTHLY_FIXED_INCOME_ISOLATED_LARGE_TRANSACTION_RULE_CODES = {
|
|
|
|
|
|
"SINGLE_LARGE_INCOME",
|
|
|
|
|
|
"CUMULATIVE_INCOME",
|
|
|
|
|
|
"ANNUAL_TURNOVER",
|
|
|
|
|
|
"LARGE_CASH_DEPOSIT",
|
|
|
|
|
|
"FREQUENT_CASH_DEPOSIT",
|
|
|
|
|
|
}
|
|
|
|
|
|
MONTHLY_FIXED_INCOME_ISOLATED_PHASE1_RULE_CODES = {
|
|
|
|
|
|
"SUSPICIOUS_INCOME_KEYWORD",
|
|
|
|
|
|
"FOREX_SELL_AMT",
|
|
|
|
|
|
}
|
|
|
|
|
|
MONTHLY_FIXED_INCOME_ISOLATED_PHASE2_RULE_CODES = {
|
|
|
|
|
|
"FIXED_COUNTERPARTY_TRANSFER",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 16:14:38 +08:00
|
|
|
|
RULE_CONFLICT_GROUPS = []
|
2026-03-25 10:05:30 +08:00
|
|
|
|
ALL_MODE_STATEMENT_BASELINE_RULE_CODES = {
|
|
|
|
|
|
"LOW_INCOME_RELATIVE_LARGE_TRANSACTION",
|
|
|
|
|
|
}
|
2026-03-22 16:14:38 +08:00
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class FileRecord:
|
|
|
|
|
|
"""文件记录模型(扩展版)"""
|
|
|
|
|
|
# 原有字段
|
|
|
|
|
|
log_id: int
|
|
|
|
|
|
group_id: int
|
|
|
|
|
|
file_name: str
|
|
|
|
|
|
status: int = -5 # -5 表示解析成功
|
|
|
|
|
|
upload_status_desc: str = "data.wait.confirm.newaccount"
|
|
|
|
|
|
parsing: bool = True # True表示正在解析
|
|
|
|
|
|
|
|
|
|
|
|
# 新增字段 - 账号和主体信息
|
2026-03-18 14:51:09 +08:00
|
|
|
|
primary_enterprise_name: str = ""
|
|
|
|
|
|
primary_account_no: str = ""
|
2026-03-13 16:38:07 +08:00
|
|
|
|
account_no_list: List[str] = field(default_factory=list)
|
|
|
|
|
|
enterprise_name_list: List[str] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
# 新增字段 - 银行和模板信息
|
|
|
|
|
|
bank_name: str = "ZJRCU"
|
|
|
|
|
|
real_bank_name: str = "ZJRCU"
|
|
|
|
|
|
template_name: str = "ZJRCU_T251114"
|
|
|
|
|
|
data_type_info: List[str] = field(default_factory=lambda: ["CSV", ","])
|
|
|
|
|
|
|
|
|
|
|
|
# 新增字段 - 文件元数据
|
|
|
|
|
|
file_size: int = 50000
|
|
|
|
|
|
download_file_name: str = ""
|
|
|
|
|
|
file_package_id: str = field(default_factory=lambda: str(uuid.uuid4()).replace('-', ''))
|
|
|
|
|
|
|
|
|
|
|
|
# 新增字段 - 上传用户信息
|
|
|
|
|
|
file_upload_by: int = 448
|
|
|
|
|
|
file_upload_by_user_name: str = "admin@support.com"
|
|
|
|
|
|
file_upload_time: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
|
|
|
|
|
|
|
|
|
|
|
# 新增字段 - 法律实体信息
|
|
|
|
|
|
le_id: int = 10000
|
|
|
|
|
|
login_le_id: int = 10000
|
|
|
|
|
|
log_type: str = "bankstatement"
|
|
|
|
|
|
log_meta: str = "{\"lostHeader\":[],\"balanceAmount\":\"-1\"}"
|
|
|
|
|
|
lost_header: List[str] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
# 新增字段 - 记录统计
|
|
|
|
|
|
rows: int = 0
|
|
|
|
|
|
source: str = "http"
|
|
|
|
|
|
total_records: int = 150
|
|
|
|
|
|
is_split: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
# 新增字段 - 交易日期范围
|
|
|
|
|
|
trx_date_start_id: int = 20240101
|
|
|
|
|
|
trx_date_end_id: int = 20241231
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-19 16:07:28 +08:00
|
|
|
|
# 新增字段 - 身份绑定
|
|
|
|
|
|
staff_name: str = ""
|
|
|
|
|
|
staff_id_card: str = ""
|
|
|
|
|
|
family_id_cards: List[str] = field(default_factory=list)
|
2026-03-20 14:42:11 +08:00
|
|
|
|
large_transaction_hit_rules: List[str] = field(default_factory=list)
|
|
|
|
|
|
phase1_hit_rules: List[str] = field(default_factory=list)
|
2026-03-22 11:48:22 +08:00
|
|
|
|
phase2_statement_hit_rules: List[str] = field(default_factory=list)
|
|
|
|
|
|
phase2_baseline_hit_rules: List[str] = field(default_factory=list)
|
2026-03-31 20:40:38 +08:00
|
|
|
|
abnormal_account_hit_rules: List[str] = field(default_factory=list)
|
|
|
|
|
|
abnormal_accounts: List[dict] = field(default_factory=list)
|
2026-03-19 16:07:28 +08:00
|
|
|
|
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-31 22:14:03 +08:00
|
|
|
|
class _NoopAbnormalAccountBaselineService:
|
|
|
|
|
|
def apply(self, staff_id_card: str, abnormal_accounts: List[dict]) -> None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 15:13:18 +08:00
|
|
|
|
class FileService:
|
|
|
|
|
|
"""文件上传和解析服务"""
|
|
|
|
|
|
|
2026-03-19 17:18:02 +08:00
|
|
|
|
INNER_FLOW_TOTAL_RECORDS = 200
|
2026-03-20 16:25:22 +08:00
|
|
|
|
LOG_ID_MIN = settings.INITIAL_LOG_ID
|
|
|
|
|
|
LOG_ID_MAX = 99999
|
2026-03-19 17:18:02 +08:00
|
|
|
|
|
2026-03-31 22:14:03 +08:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
staff_identity_repository=None,
|
|
|
|
|
|
phase2_baseline_service=None,
|
|
|
|
|
|
abnormal_account_baseline_service=None,
|
|
|
|
|
|
):
|
2026-03-13 16:38:07 +08:00
|
|
|
|
self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord
|
2026-03-13 15:13:18 +08:00
|
|
|
|
self.log_counter = settings.INITIAL_LOG_ID
|
2026-03-19 16:07:28 +08:00
|
|
|
|
self.staff_identity_repository = staff_identity_repository or StaffIdentityRepository()
|
2026-03-22 11:48:22 +08:00
|
|
|
|
self.phase2_baseline_service = phase2_baseline_service or Phase2BaselineService()
|
2026-03-31 22:14:03 +08:00
|
|
|
|
self.abnormal_account_baseline_service = (
|
|
|
|
|
|
abnormal_account_baseline_service or _NoopAbnormalAccountBaselineService()
|
|
|
|
|
|
)
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-18 15:54:11 +08:00
|
|
|
|
def get_file_record(self, log_id: int) -> FileRecord:
|
|
|
|
|
|
"""按 logId 获取已存在的文件记录。"""
|
|
|
|
|
|
return self.file_records.get(log_id)
|
|
|
|
|
|
|
2026-03-20 16:25:22 +08:00
|
|
|
|
def _generate_log_id(self) -> int:
|
|
|
|
|
|
"""生成当前进程内未占用的随机 logId。"""
|
|
|
|
|
|
available_capacity = self.LOG_ID_MAX - self.LOG_ID_MIN + 1
|
|
|
|
|
|
if len(self.file_records) >= available_capacity:
|
|
|
|
|
|
raise RuntimeError("可用 logId 已耗尽")
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
candidate = random.randint(self.LOG_ID_MIN, self.LOG_ID_MAX)
|
|
|
|
|
|
if candidate not in self.file_records:
|
|
|
|
|
|
self.log_counter = candidate
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
def _infer_bank_name(self, filename: str) -> tuple:
|
|
|
|
|
|
"""根据文件名推断银行名称和模板名称"""
|
|
|
|
|
|
if "支付宝" in filename or "alipay" in filename.lower():
|
|
|
|
|
|
return "ALIPAY", "ALIPAY_T220708"
|
|
|
|
|
|
elif "绍兴银行" in filename or "BSX" in filename:
|
|
|
|
|
|
return "BSX", "BSX_T240925"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return "ZJRCU", "ZJRCU_T251114"
|
|
|
|
|
|
|
2026-03-18 14:51:09 +08:00
|
|
|
|
def _generate_primary_binding(self) -> tuple:
|
|
|
|
|
|
"""生成单一稳定的本方主体/本方账号绑定。"""
|
|
|
|
|
|
primary_account_no = f"{random.randint(10000000000, 99999999999)}"
|
|
|
|
|
|
primary_enterprise_name = "测试主体"
|
|
|
|
|
|
return primary_enterprise_name, primary_account_no
|
|
|
|
|
|
|
|
|
|
|
|
def _generate_primary_binding_from_rng(self, rng: random.Random) -> tuple:
|
|
|
|
|
|
"""使用局部随机源生成单一稳定的本方主体/本方账号绑定。"""
|
|
|
|
|
|
primary_account_no = f"{rng.randint(10000000000, 99999999999)}"
|
|
|
|
|
|
primary_enterprise_name = "测试主体"
|
|
|
|
|
|
return primary_enterprise_name, primary_account_no
|
|
|
|
|
|
|
|
|
|
|
|
def _build_primary_binding_lists(
|
|
|
|
|
|
self, primary_enterprise_name: str, primary_account_no: str
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""基于主绑定事实源构建列表字段。"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"accountNoList": [primary_account_no],
|
|
|
|
|
|
"enterpriseNameList": [primary_enterprise_name],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 14:42:11 +08:00
|
|
|
|
def _pick_rule_subset(
|
|
|
|
|
|
self,
|
|
|
|
|
|
rng: random.Random,
|
|
|
|
|
|
rule_codes: List[str],
|
|
|
|
|
|
min_count: int,
|
|
|
|
|
|
max_count: int,
|
|
|
|
|
|
) -> List[str]:
|
|
|
|
|
|
"""按固定随机源选择稳定规则子集,并保留规则池原始顺序。"""
|
|
|
|
|
|
selected_codes = set(rng.sample(rule_codes, rng.randint(min_count, max_count)))
|
|
|
|
|
|
return [rule_code for rule_code in rule_codes if rule_code in selected_codes]
|
|
|
|
|
|
|
2026-03-22 16:14:38 +08:00
|
|
|
|
def _build_subset_rule_hit_plan(self, log_id: int) -> dict:
|
|
|
|
|
|
"""基于 logId 生成稳定的规则子集命中计划。"""
|
2026-03-20 14:42:11 +08:00
|
|
|
|
rng = random.Random(f"rule-plan:{log_id}")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"large_transaction_hit_rules": self._pick_rule_subset(
|
|
|
|
|
|
rng, LARGE_TRANSACTION_RULE_CODES, 2, 4
|
|
|
|
|
|
),
|
|
|
|
|
|
"phase1_hit_rules": self._pick_rule_subset(rng, PHASE1_RULE_CODES, 2, 4),
|
2026-03-22 11:48:22 +08:00
|
|
|
|
"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
|
|
|
|
|
|
),
|
2026-03-31 20:40:38 +08:00
|
|
|
|
"abnormal_account_hit_rules": self._pick_rule_subset(
|
|
|
|
|
|
rng, ABNORMAL_ACCOUNT_RULE_CODES, 1, len(ABNORMAL_ACCOUNT_RULE_CODES)
|
|
|
|
|
|
),
|
2026-03-20 14:42:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 16:14:38 +08:00
|
|
|
|
def _build_all_compatible_rule_hit_plan(self) -> dict:
|
|
|
|
|
|
"""生成全部兼容规则命中计划。"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"large_transaction_hit_rules": list(LARGE_TRANSACTION_RULE_CODES),
|
|
|
|
|
|
"phase1_hit_rules": list(PHASE1_RULE_CODES),
|
|
|
|
|
|
"phase2_statement_hit_rules": list(PHASE2_STATEMENT_RULE_CODES),
|
|
|
|
|
|
"phase2_baseline_hit_rules": list(PHASE2_BASELINE_RULE_CODES),
|
2026-03-31 20:40:38 +08:00
|
|
|
|
"abnormal_account_hit_rules": list(ABNORMAL_ACCOUNT_RULE_CODES),
|
2026-03-22 16:14:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 10:28:08 +08:00
|
|
|
|
def _build_monthly_fixed_income_isolated_rule_hit_plan(self) -> dict:
|
|
|
|
|
|
"""为月固定收入准备不受正向流入污染的 all 模式计划。"""
|
|
|
|
|
|
full_plan = self._build_all_compatible_rule_hit_plan()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"large_transaction_hit_rules": [
|
|
|
|
|
|
rule_code
|
|
|
|
|
|
for rule_code in full_plan["large_transaction_hit_rules"]
|
|
|
|
|
|
if rule_code not in MONTHLY_FIXED_INCOME_ISOLATED_LARGE_TRANSACTION_RULE_CODES
|
|
|
|
|
|
],
|
|
|
|
|
|
"phase1_hit_rules": [
|
|
|
|
|
|
rule_code
|
|
|
|
|
|
for rule_code in full_plan["phase1_hit_rules"]
|
|
|
|
|
|
if rule_code not in MONTHLY_FIXED_INCOME_ISOLATED_PHASE1_RULE_CODES
|
|
|
|
|
|
],
|
|
|
|
|
|
"phase2_statement_hit_rules": [
|
|
|
|
|
|
rule_code
|
|
|
|
|
|
for rule_code in full_plan["phase2_statement_hit_rules"]
|
|
|
|
|
|
if rule_code not in MONTHLY_FIXED_INCOME_ISOLATED_PHASE2_RULE_CODES
|
|
|
|
|
|
],
|
|
|
|
|
|
"phase2_baseline_hit_rules": list(full_plan["phase2_baseline_hit_rules"]),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 16:14:38 +08:00
|
|
|
|
def _apply_conflict_groups(self, rule_plan: dict) -> dict:
|
|
|
|
|
|
"""按显式互斥组裁剪规则计划,同组仅保留固定优先级的首个规则。"""
|
|
|
|
|
|
resolved_plan = {plan_key: list(rule_codes) for plan_key, rule_codes in rule_plan.items()}
|
|
|
|
|
|
for plan_key, rule_codes in resolved_plan.items():
|
|
|
|
|
|
filtered_codes = list(rule_codes)
|
|
|
|
|
|
for conflict_group in RULE_CONFLICT_GROUPS:
|
|
|
|
|
|
kept_rule_code = next(
|
|
|
|
|
|
(rule_code for rule_code in conflict_group if rule_code in filtered_codes),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
if kept_rule_code is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
filtered_codes = [
|
|
|
|
|
|
rule_code
|
|
|
|
|
|
for rule_code in filtered_codes
|
|
|
|
|
|
if rule_code == kept_rule_code or rule_code not in conflict_group
|
|
|
|
|
|
]
|
|
|
|
|
|
resolved_plan[plan_key] = filtered_codes
|
|
|
|
|
|
return resolved_plan
|
|
|
|
|
|
|
|
|
|
|
|
def _build_rule_hit_plan(self, log_id: int) -> dict:
|
|
|
|
|
|
"""按配置模式生成规则命中计划。"""
|
|
|
|
|
|
if settings.RULE_HIT_MODE == "all":
|
|
|
|
|
|
return self._apply_conflict_groups(self._build_all_compatible_rule_hit_plan())
|
|
|
|
|
|
return self._build_subset_rule_hit_plan(log_id)
|
|
|
|
|
|
|
2026-03-25 10:28:08 +08:00
|
|
|
|
def _apply_rule_hit_plan_to_record(self, file_record: FileRecord, rule_hit_plan: dict) -> None:
|
|
|
|
|
|
"""将规则命中计划回填到指定文件记录。"""
|
|
|
|
|
|
file_record.large_transaction_hit_rules = list(
|
|
|
|
|
|
rule_hit_plan.get("large_transaction_hit_rules", [])
|
|
|
|
|
|
)
|
|
|
|
|
|
file_record.phase1_hit_rules = list(rule_hit_plan.get("phase1_hit_rules", []))
|
|
|
|
|
|
file_record.phase2_statement_hit_rules = list(
|
|
|
|
|
|
rule_hit_plan.get("phase2_statement_hit_rules", [])
|
|
|
|
|
|
)
|
|
|
|
|
|
file_record.phase2_baseline_hit_rules = list(
|
|
|
|
|
|
rule_hit_plan.get("phase2_baseline_hit_rules", [])
|
|
|
|
|
|
)
|
2026-03-31 20:40:38 +08:00
|
|
|
|
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
|
2026-03-25 10:28:08 +08:00
|
|
|
|
|
|
|
|
|
|
def _rebalance_all_mode_group_rule_plans(self, group_id: int) -> None:
|
|
|
|
|
|
"""同项目存在多文件时,隔离月固定收入样本,避免被其他正向流入规则污染。"""
|
|
|
|
|
|
if settings.RULE_HIT_MODE != "all":
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
group_records = [
|
|
|
|
|
|
record for record in self.file_records.values()
|
|
|
|
|
|
if record.group_id == group_id
|
|
|
|
|
|
]
|
|
|
|
|
|
if not group_records:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
full_plan = self._apply_conflict_groups(self._build_all_compatible_rule_hit_plan())
|
|
|
|
|
|
if len(group_records) == 1:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
monthly_safe_plan = self._apply_conflict_groups(
|
|
|
|
|
|
self._build_monthly_fixed_income_isolated_rule_hit_plan()
|
|
|
|
|
|
)
|
|
|
|
|
|
self._apply_rule_hit_plan_to_record(group_records[0], monthly_safe_plan)
|
|
|
|
|
|
for record in group_records[1:]:
|
|
|
|
|
|
self._apply_rule_hit_plan_to_record(record, full_plan)
|
|
|
|
|
|
|
2026-03-18 15:01:58 +08:00
|
|
|
|
def _create_file_record(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
log_id: int,
|
|
|
|
|
|
group_id: int,
|
|
|
|
|
|
file_name: str,
|
|
|
|
|
|
download_file_name: str,
|
|
|
|
|
|
bank_name: str,
|
|
|
|
|
|
template_name: str,
|
|
|
|
|
|
primary_enterprise_name: str,
|
|
|
|
|
|
primary_account_no: str,
|
|
|
|
|
|
file_size: int,
|
|
|
|
|
|
total_records: int,
|
|
|
|
|
|
trx_date_start_id: int,
|
|
|
|
|
|
trx_date_end_id: int,
|
|
|
|
|
|
le_id: int,
|
|
|
|
|
|
login_le_id: int,
|
2026-03-19 16:07:28 +08:00
|
|
|
|
staff_name: str = "",
|
|
|
|
|
|
staff_id_card: str = "",
|
|
|
|
|
|
family_id_cards: List[str] = None,
|
2026-03-20 14:42:11 +08:00
|
|
|
|
large_transaction_hit_rules: List[str] = None,
|
|
|
|
|
|
phase1_hit_rules: List[str] = None,
|
2026-03-22 11:48:22 +08:00
|
|
|
|
phase2_statement_hit_rules: List[str] = None,
|
|
|
|
|
|
phase2_baseline_hit_rules: List[str] = None,
|
2026-03-31 20:40:38 +08:00
|
|
|
|
abnormal_account_hit_rules: List[str] = None,
|
|
|
|
|
|
abnormal_accounts: List[dict] = None,
|
2026-03-18 15:01:58 +08:00
|
|
|
|
parsing: bool = True,
|
|
|
|
|
|
status: int = -5,
|
|
|
|
|
|
) -> FileRecord:
|
|
|
|
|
|
"""创建文件记录并写入主绑定信息。"""
|
|
|
|
|
|
binding_lists = self._build_primary_binding_lists(
|
|
|
|
|
|
primary_enterprise_name,
|
|
|
|
|
|
primary_account_no,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return FileRecord(
|
|
|
|
|
|
log_id=log_id,
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
file_name=file_name,
|
|
|
|
|
|
download_file_name=download_file_name,
|
|
|
|
|
|
bank_name=bank_name,
|
|
|
|
|
|
real_bank_name=bank_name,
|
|
|
|
|
|
template_name=template_name,
|
|
|
|
|
|
primary_enterprise_name=primary_enterprise_name,
|
|
|
|
|
|
primary_account_no=primary_account_no,
|
|
|
|
|
|
account_no_list=binding_lists["accountNoList"],
|
|
|
|
|
|
enterprise_name_list=binding_lists["enterpriseNameList"],
|
|
|
|
|
|
le_id=le_id,
|
|
|
|
|
|
login_le_id=login_le_id,
|
|
|
|
|
|
file_size=file_size,
|
|
|
|
|
|
total_records=total_records,
|
|
|
|
|
|
trx_date_start_id=trx_date_start_id,
|
|
|
|
|
|
trx_date_end_id=trx_date_end_id,
|
2026-03-19 16:07:28 +08:00
|
|
|
|
staff_name=staff_name,
|
|
|
|
|
|
staff_id_card=staff_id_card,
|
|
|
|
|
|
family_id_cards=list(family_id_cards or []),
|
2026-03-20 14:42:11 +08:00
|
|
|
|
large_transaction_hit_rules=list(large_transaction_hit_rules or []),
|
|
|
|
|
|
phase1_hit_rules=list(phase1_hit_rules or []),
|
2026-03-22 11:48:22 +08:00
|
|
|
|
phase2_statement_hit_rules=list(phase2_statement_hit_rules or []),
|
|
|
|
|
|
phase2_baseline_hit_rules=list(phase2_baseline_hit_rules or []),
|
2026-03-31 20:40:38 +08:00
|
|
|
|
abnormal_account_hit_rules=list(abnormal_account_hit_rules or []),
|
|
|
|
|
|
abnormal_accounts=[dict(account) for account in (abnormal_accounts or [])],
|
2026-03-18 15:01:58 +08:00
|
|
|
|
parsing=parsing,
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-19 16:07:28 +08:00
|
|
|
|
def _select_staff_identity_scope(self) -> dict:
|
|
|
|
|
|
"""读取一个员工及其亲属身份范围。"""
|
|
|
|
|
|
return self.staff_identity_repository.select_random_staff_with_families()
|
|
|
|
|
|
|
2026-03-22 11:48:22 +08:00
|
|
|
|
def _apply_phase2_baselines(self, file_record: FileRecord) -> None:
|
|
|
|
|
|
"""按当前记录命中的第二期基线规则幂等补齐外部事实。"""
|
2026-03-25 10:05:30 +08:00
|
|
|
|
baseline_rule_codes = list(file_record.phase2_baseline_hit_rules)
|
|
|
|
|
|
if settings.RULE_HIT_MODE == "all":
|
|
|
|
|
|
for rule_code in file_record.phase2_statement_hit_rules:
|
|
|
|
|
|
if (
|
|
|
|
|
|
rule_code in ALL_MODE_STATEMENT_BASELINE_RULE_CODES
|
|
|
|
|
|
and rule_code not in baseline_rule_codes
|
|
|
|
|
|
):
|
|
|
|
|
|
baseline_rule_codes.append(rule_code)
|
|
|
|
|
|
|
2026-03-22 11:48:22 +08:00
|
|
|
|
self.phase2_baseline_service.apply(
|
|
|
|
|
|
staff_id_card=file_record.staff_id_card,
|
|
|
|
|
|
family_id_cards=file_record.family_id_cards,
|
2026-03-25 10:05:30 +08:00
|
|
|
|
baseline_rule_codes=baseline_rule_codes,
|
2026-03-22 11:48:22 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-31 22:14:03 +08:00
|
|
|
|
def _apply_abnormal_account_baselines(self, file_record: FileRecord) -> None:
|
|
|
|
|
|
"""按当前记录命中的异常账户规则幂等补齐账户事实。"""
|
|
|
|
|
|
if not file_record.abnormal_account_hit_rules:
|
|
|
|
|
|
return
|
|
|
|
|
|
if not file_record.abnormal_accounts:
|
|
|
|
|
|
raise RuntimeError("异常账户命中计划存在,但未生成账户事实")
|
|
|
|
|
|
self.abnormal_account_baseline_service.apply(
|
|
|
|
|
|
staff_id_card=file_record.staff_id_card,
|
|
|
|
|
|
abnormal_accounts=file_record.abnormal_accounts,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-13 15:13:18 +08:00
|
|
|
|
async def upload_file(
|
|
|
|
|
|
self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks
|
|
|
|
|
|
) -> Dict:
|
|
|
|
|
|
"""上传文件并启动后台解析任务
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
group_id: 项目ID
|
|
|
|
|
|
file: 上传的文件
|
|
|
|
|
|
background_tasks: FastAPI后台任务
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
上传响应字典
|
|
|
|
|
|
"""
|
2026-03-20 16:25:22 +08:00
|
|
|
|
# 生成唯一 logId
|
|
|
|
|
|
log_id = self._generate_log_id()
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
# 推断银行信息
|
|
|
|
|
|
bank_name, template_name = self._infer_bank_name(file.filename)
|
2026-03-20 14:42:11 +08:00
|
|
|
|
rule_hit_plan = self._build_rule_hit_plan(log_id)
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
# 生成合理的交易日期范围
|
|
|
|
|
|
end_date = datetime.now()
|
|
|
|
|
|
start_date = end_date - timedelta(days=random.randint(90, 365))
|
|
|
|
|
|
trx_date_start_id = int(start_date.strftime("%Y%m%d"))
|
|
|
|
|
|
trx_date_end_id = int(end_date.strftime("%Y%m%d"))
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-18 14:51:09 +08:00
|
|
|
|
# 生成单一主绑定
|
|
|
|
|
|
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
|
2026-03-19 16:07:28 +08:00
|
|
|
|
identity_scope = self._select_staff_identity_scope()
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
# 创建完整的文件记录
|
2026-03-18 15:01:58 +08:00
|
|
|
|
file_record = self._create_file_record(
|
2026-03-13 16:38:07 +08:00
|
|
|
|
log_id=log_id,
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
file_name=file.filename,
|
|
|
|
|
|
download_file_name=file.filename,
|
|
|
|
|
|
bank_name=bank_name,
|
|
|
|
|
|
template_name=template_name,
|
2026-03-18 14:51:09 +08:00
|
|
|
|
primary_enterprise_name=primary_enterprise_name,
|
|
|
|
|
|
primary_account_no=primary_account_no,
|
2026-03-13 16:38:07 +08:00
|
|
|
|
file_size=random.randint(10000, 100000),
|
2026-03-19 15:23:44 +08:00
|
|
|
|
total_records=random.randint(150, 200),
|
2026-03-13 16:38:07 +08:00
|
|
|
|
trx_date_start_id=trx_date_start_id,
|
|
|
|
|
|
trx_date_end_id=trx_date_end_id,
|
2026-03-18 15:01:58 +08:00
|
|
|
|
le_id=10000 + random.randint(0, 9999),
|
|
|
|
|
|
login_le_id=10000 + random.randint(0, 9999),
|
2026-03-19 16:07:28 +08:00
|
|
|
|
staff_name=identity_scope["staff_name"],
|
|
|
|
|
|
staff_id_card=identity_scope["staff_id_card"],
|
|
|
|
|
|
family_id_cards=identity_scope["family_id_cards"],
|
2026-03-22 11:48:22 +08:00
|
|
|
|
large_transaction_hit_rules=rule_hit_plan.get("large_transaction_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", []),
|
2026-03-31 20:40:38 +08:00
|
|
|
|
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", []),
|
|
|
|
|
|
),
|
2026-03-13 15:13:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-31 22:14:03 +08:00
|
|
|
|
self._apply_abnormal_account_baselines(file_record)
|
2026-03-13 16:38:07 +08:00
|
|
|
|
self.file_records[log_id] = file_record
|
2026-03-25 10:28:08 +08:00
|
|
|
|
self._rebalance_all_mode_group_rule_plans(group_id)
|
2026-03-22 11:48:22 +08:00
|
|
|
|
self._apply_phase2_baselines(file_record)
|
2026-03-13 16:38:07 +08:00
|
|
|
|
|
|
|
|
|
|
# 添加后台任务(延迟解析)
|
|
|
|
|
|
background_tasks.add_task(self._delayed_parse, log_id)
|
|
|
|
|
|
|
2026-03-13 15:13:18 +08:00
|
|
|
|
# 构建响应
|
2026-03-13 16:38:07 +08:00
|
|
|
|
return self._build_upload_response(file_record)
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
def _build_upload_response(self, file_record: FileRecord) -> dict:
|
|
|
|
|
|
"""构建上传接口的完整响应"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": "200",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"accountsOfLog": {
|
|
|
|
|
|
str(file_record.log_id): [
|
|
|
|
|
|
{
|
|
|
|
|
|
"bank": file_record.bank_name,
|
2026-03-18 14:51:09 +08:00
|
|
|
|
"accountName": file_record.primary_enterprise_name,
|
|
|
|
|
|
"accountNo": file_record.primary_account_no,
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"currency": "CNY"
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"uploadLogList": [
|
|
|
|
|
|
{
|
2026-03-18 14:51:09 +08:00
|
|
|
|
**self._build_primary_binding_lists(
|
|
|
|
|
|
file_record.primary_enterprise_name,
|
|
|
|
|
|
file_record.primary_account_no,
|
|
|
|
|
|
),
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"bankName": file_record.bank_name,
|
|
|
|
|
|
"dataTypeInfo": file_record.data_type_info,
|
|
|
|
|
|
"downloadFileName": file_record.download_file_name,
|
|
|
|
|
|
"filePackageId": file_record.file_package_id,
|
|
|
|
|
|
"fileSize": file_record.file_size,
|
|
|
|
|
|
"fileUploadBy": file_record.file_upload_by,
|
|
|
|
|
|
"fileUploadByUserName": file_record.file_upload_by_user_name,
|
|
|
|
|
|
"fileUploadTime": file_record.file_upload_time,
|
|
|
|
|
|
"leId": file_record.le_id,
|
|
|
|
|
|
"logId": file_record.log_id,
|
|
|
|
|
|
"logMeta": file_record.log_meta,
|
|
|
|
|
|
"logType": file_record.log_type,
|
|
|
|
|
|
"loginLeId": file_record.login_le_id,
|
|
|
|
|
|
"lostHeader": file_record.lost_header,
|
|
|
|
|
|
"realBankName": file_record.real_bank_name,
|
|
|
|
|
|
"rows": file_record.rows,
|
|
|
|
|
|
"source": file_record.source,
|
|
|
|
|
|
"status": file_record.status,
|
|
|
|
|
|
"templateName": file_record.template_name,
|
|
|
|
|
|
"totalRecords": file_record.total_records,
|
|
|
|
|
|
"trxDateEndId": file_record.trx_date_end_id,
|
|
|
|
|
|
"trxDateStartId": file_record.trx_date_start_id,
|
|
|
|
|
|
"uploadFileName": file_record.file_name,
|
|
|
|
|
|
"uploadStatusDesc": file_record.upload_status_desc
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"uploadStatus": 1
|
|
|
|
|
|
},
|
|
|
|
|
|
"status": "200",
|
|
|
|
|
|
"successResponse": True
|
|
|
|
|
|
}
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
def _delayed_parse(self, log_id: int):
|
2026-03-13 15:13:18 +08:00
|
|
|
|
"""后台任务:模拟文件解析过程
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
log_id: 日志ID
|
|
|
|
|
|
"""
|
2026-03-13 16:38:07 +08:00
|
|
|
|
time.sleep(settings.PARSE_DELAY_SECONDS)
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
|
|
|
|
|
# 解析完成,更新状态
|
|
|
|
|
|
if log_id in self.file_records:
|
2026-03-13 16:38:07 +08:00
|
|
|
|
self.file_records[log_id].parsing = False
|
|
|
|
|
|
|
2026-03-18 14:51:09 +08:00
|
|
|
|
def _generate_deterministic_record(
|
|
|
|
|
|
self, log_id: int, group_id: int, rng: random.Random
|
|
|
|
|
|
) -> dict:
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
基于 logId 生成确定性的文件记录
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
log_id: 文件ID(用作随机种子)
|
|
|
|
|
|
group_id: 项目ID
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
文件记录字典(26个字段)
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 银行类型选项
|
|
|
|
|
|
bank_options = [
|
|
|
|
|
|
("ALIPAY", "ALIPAY_T220708"),
|
|
|
|
|
|
("BSX", "BSX_T240925"),
|
|
|
|
|
|
("ZJRCU", "ZJRCU_T251114")
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-18 14:51:09 +08:00
|
|
|
|
bank_name, template_name = rng.choice(bank_options)
|
2026-03-13 16:38:07 +08:00
|
|
|
|
|
2026-03-18 15:50:28 +08:00
|
|
|
|
# 生成基于种子的稳定时间范围,确保同一 logId 重复查询完全一致
|
|
|
|
|
|
base_datetime = datetime(2024, 1, 1, 8, 0, 0)
|
|
|
|
|
|
end_date = base_datetime + timedelta(days=rng.randint(180, 540))
|
2026-03-18 14:51:09 +08:00
|
|
|
|
start_date = end_date - timedelta(days=rng.randint(90, 365))
|
2026-03-18 15:50:28 +08:00
|
|
|
|
file_upload_time = (
|
|
|
|
|
|
base_datetime
|
|
|
|
|
|
+ timedelta(
|
|
|
|
|
|
days=rng.randint(0, 540),
|
|
|
|
|
|
hours=rng.randint(0, 23),
|
|
|
|
|
|
minutes=rng.randint(0, 59),
|
|
|
|
|
|
seconds=rng.randint(0, 59),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-03-13 16:38:07 +08:00
|
|
|
|
|
|
|
|
|
|
# 生成账号和主体
|
2026-03-18 14:51:09 +08:00
|
|
|
|
primary_enterprise_name, primary_account_no = self._generate_primary_binding_from_rng(rng)
|
|
|
|
|
|
binding_lists = self._build_primary_binding_lists(
|
|
|
|
|
|
primary_enterprise_name, primary_account_no
|
|
|
|
|
|
)
|
2026-03-13 16:38:07 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-18 14:51:09 +08:00
|
|
|
|
**binding_lists,
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"bankName": bank_name,
|
|
|
|
|
|
"dataTypeInfo": ["CSV", ","],
|
|
|
|
|
|
"downloadFileName": f"测试文件_{log_id}.csv",
|
2026-03-18 14:51:09 +08:00
|
|
|
|
"fileSize": rng.randint(10000, 100000),
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"fileUploadBy": 448,
|
|
|
|
|
|
"fileUploadByUserName": "admin@support.com",
|
2026-03-18 15:50:28 +08:00
|
|
|
|
"fileUploadTime": file_upload_time.strftime("%Y-%m-%d %H:%M:%S"),
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"isSplit": 0,
|
2026-03-18 14:51:09 +08:00
|
|
|
|
"leId": 10000 + rng.randint(0, 9999),
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"logId": log_id,
|
|
|
|
|
|
"logMeta": "{\"lostHeader\":[],\"balanceAmount\":\"-1\"}",
|
|
|
|
|
|
"logType": "bankstatement",
|
2026-03-18 14:51:09 +08:00
|
|
|
|
"loginLeId": 10000 + rng.randint(0, 9999),
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"lostHeader": [],
|
|
|
|
|
|
"realBankName": bank_name,
|
|
|
|
|
|
"rows": 0,
|
|
|
|
|
|
"source": "http",
|
|
|
|
|
|
"status": -5,
|
|
|
|
|
|
"templateName": template_name,
|
2026-03-18 14:51:09 +08:00
|
|
|
|
"totalRecords": rng.randint(100, 300),
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"trxDateEndId": int(end_date.strftime("%Y%m%d")),
|
|
|
|
|
|
"trxDateStartId": int(start_date.strftime("%Y%m%d")),
|
|
|
|
|
|
"uploadFileName": f"测试文件_{log_id}.pdf",
|
|
|
|
|
|
"uploadStatusDesc": "data.wait.confirm.newaccount"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 15:50:28 +08:00
|
|
|
|
def _build_deterministic_log_detail(self, log_id: int, group_id: int) -> dict:
|
|
|
|
|
|
"""构建 deterministic 回退的单条日志详情。"""
|
|
|
|
|
|
rng = random.Random(log_id)
|
|
|
|
|
|
return self._generate_deterministic_record(log_id, group_id, rng)
|
|
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
def _build_log_detail(self, record: FileRecord) -> dict:
|
|
|
|
|
|
"""构建日志详情对象"""
|
|
|
|
|
|
return {
|
2026-03-18 14:51:09 +08:00
|
|
|
|
**self._build_primary_binding_lists(
|
|
|
|
|
|
record.primary_enterprise_name,
|
|
|
|
|
|
record.primary_account_no,
|
|
|
|
|
|
),
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"bankName": record.bank_name,
|
|
|
|
|
|
"dataTypeInfo": record.data_type_info,
|
|
|
|
|
|
"downloadFileName": record.download_file_name,
|
|
|
|
|
|
"fileSize": record.file_size,
|
|
|
|
|
|
"fileUploadBy": record.file_upload_by,
|
|
|
|
|
|
"fileUploadByUserName": record.file_upload_by_user_name,
|
|
|
|
|
|
"fileUploadTime": record.file_upload_time,
|
|
|
|
|
|
"isSplit": record.is_split,
|
|
|
|
|
|
"leId": record.le_id,
|
|
|
|
|
|
"logId": record.log_id,
|
|
|
|
|
|
"logMeta": record.log_meta,
|
|
|
|
|
|
"logType": record.log_type,
|
|
|
|
|
|
"loginLeId": record.login_le_id,
|
|
|
|
|
|
"lostHeader": record.lost_header,
|
|
|
|
|
|
"realBankName": record.real_bank_name,
|
|
|
|
|
|
"rows": record.rows,
|
|
|
|
|
|
"source": record.source,
|
|
|
|
|
|
"status": record.status,
|
|
|
|
|
|
"templateName": record.template_name,
|
|
|
|
|
|
"totalRecords": record.total_records,
|
|
|
|
|
|
"trxDateEndId": record.trx_date_end_id,
|
|
|
|
|
|
"trxDateStartId": record.trx_date_start_id,
|
|
|
|
|
|
"uploadFileName": record.file_name,
|
|
|
|
|
|
"uploadStatusDesc": record.upload_status_desc
|
|
|
|
|
|
}
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
|
|
|
|
|
def check_parse_status(self, group_id: int, inprogress_list: str) -> Dict:
|
|
|
|
|
|
"""检查文件解析状态
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
group_id: 项目ID
|
|
|
|
|
|
inprogress_list: 文件ID列表(逗号分隔)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
解析状态响应字典
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 解析logId列表
|
|
|
|
|
|
log_ids = [int(x.strip()) for x in inprogress_list.split(",") if x.strip()]
|
|
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
pending_list = []
|
|
|
|
|
|
all_parsing_complete = True
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
2026-03-13 16:38:07 +08:00
|
|
|
|
for log_id in log_ids:
|
|
|
|
|
|
if log_id in self.file_records:
|
|
|
|
|
|
record = self.file_records[log_id]
|
|
|
|
|
|
if record.parsing:
|
|
|
|
|
|
all_parsing_complete = False
|
|
|
|
|
|
|
|
|
|
|
|
pending_list.append(self._build_log_detail(record))
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": "200",
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"data": {
|
|
|
|
|
|
"parsing": not all_parsing_complete,
|
|
|
|
|
|
"pendingList": pending_list
|
|
|
|
|
|
},
|
2026-03-13 15:13:18 +08:00
|
|
|
|
"status": "200",
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"successResponse": True
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def get_upload_status(self, group_id: int, log_id: int = None) -> dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取文件上传状态(基于 logId 生成确定性数据)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
group_id: 项目ID
|
|
|
|
|
|
log_id: 文件ID(可选)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
上传状态响应字典
|
|
|
|
|
|
"""
|
|
|
|
|
|
logs = []
|
|
|
|
|
|
|
2026-03-18 15:50:28 +08:00
|
|
|
|
if log_id is not None:
|
2026-03-18 15:01:58 +08:00
|
|
|
|
if log_id in self.file_records:
|
2026-03-18 15:50:28 +08:00
|
|
|
|
log_detail = self._build_log_detail(self.file_records[log_id])
|
2026-03-18 15:01:58 +08:00
|
|
|
|
else:
|
2026-03-18 15:50:28 +08:00
|
|
|
|
log_detail = self._build_deterministic_log_detail(log_id, group_id)
|
2026-03-13 16:38:07 +08:00
|
|
|
|
|
2026-03-18 15:50:28 +08:00
|
|
|
|
logs.append(log_detail)
|
2026-03-13 16:38:07 +08:00
|
|
|
|
|
|
|
|
|
|
# 返回响应
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": "200",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"logs": logs,
|
|
|
|
|
|
"status": "",
|
|
|
|
|
|
"accountId": 8954,
|
|
|
|
|
|
"currency": "CNY"
|
|
|
|
|
|
},
|
|
|
|
|
|
"status": "200",
|
|
|
|
|
|
"successResponse": True
|
2026-03-13 15:13:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def delete_files(self, group_id: int, log_ids: List[int], user_id: int) -> Dict:
|
|
|
|
|
|
"""删除文件
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
group_id: 项目ID
|
|
|
|
|
|
log_ids: 文件ID列表
|
|
|
|
|
|
user_id: 用户ID
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
删除响应字典
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 删除文件记录
|
2026-03-13 16:38:07 +08:00
|
|
|
|
deleted_count = 0
|
2026-03-13 15:13:18 +08:00
|
|
|
|
for log_id in log_ids:
|
2026-03-13 16:38:07 +08:00
|
|
|
|
if log_id in self.file_records:
|
|
|
|
|
|
del self.file_records[log_id]
|
|
|
|
|
|
deleted_count += 1
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"code": "200 OK", # 注意:这里是 "200 OK" 不是 "200"
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"message": "delete.files.success"
|
|
|
|
|
|
},
|
|
|
|
|
|
"message": "delete.files.success",
|
2026-03-13 15:13:18 +08:00
|
|
|
|
"status": "200",
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"successResponse": True
|
2026-03-13 15:13:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_inner_flow(self, request: Union[Dict, object]) -> Dict:
|
2026-03-18 15:01:58 +08:00
|
|
|
|
"""拉取行内流水(创建并保存绑定记录)
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-03-18 15:01:58 +08:00
|
|
|
|
request: 拉取流水请求(可以是字典或对象)
|
2026-03-13 15:13:18 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-03-18 15:01:58 +08:00
|
|
|
|
流水响应字典,包含创建并保存的logId数组
|
2026-03-13 15:13:18 +08:00
|
|
|
|
"""
|
2026-03-18 15:01:58 +08:00
|
|
|
|
# 支持 dict 或对象
|
|
|
|
|
|
if isinstance(request, dict):
|
|
|
|
|
|
group_id = request.get("groupId", 1000)
|
|
|
|
|
|
customer_no = request.get("customerNo", "")
|
|
|
|
|
|
data_start_date_id = request.get("dataStartDateId", 20240101)
|
|
|
|
|
|
data_end_date_id = request.get("dataEndDateId", 20241231)
|
|
|
|
|
|
else:
|
|
|
|
|
|
group_id = request.groupId
|
|
|
|
|
|
customer_no = request.customerNo
|
|
|
|
|
|
data_start_date_id = request.dataStartDateId
|
|
|
|
|
|
data_end_date_id = request.dataEndDateId
|
|
|
|
|
|
|
2026-03-20 16:25:22 +08:00
|
|
|
|
# 使用随机 logId,确保与上传链路一致且不覆盖现有记录
|
|
|
|
|
|
log_id = self._generate_log_id()
|
2026-03-20 14:42:11 +08:00
|
|
|
|
rule_hit_plan = self._build_rule_hit_plan(log_id)
|
2026-03-18 15:01:58 +08:00
|
|
|
|
|
|
|
|
|
|
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
|
2026-03-19 16:07:28 +08:00
|
|
|
|
identity_scope = self._select_staff_identity_scope()
|
2026-03-18 15:01:58 +08:00
|
|
|
|
file_record = self._create_file_record(
|
|
|
|
|
|
log_id=log_id,
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
file_name=f"{customer_no or 'inner_flow'}_{log_id}.csv",
|
|
|
|
|
|
download_file_name=f"{customer_no or 'inner_flow'}_{log_id}.csv",
|
|
|
|
|
|
bank_name="ZJRCU",
|
|
|
|
|
|
template_name="ZJRCU_T251114",
|
|
|
|
|
|
primary_enterprise_name=primary_enterprise_name,
|
|
|
|
|
|
primary_account_no=primary_account_no,
|
|
|
|
|
|
file_size=random.randint(10000, 100000),
|
2026-03-19 17:18:02 +08:00
|
|
|
|
total_records=self.INNER_FLOW_TOTAL_RECORDS,
|
2026-03-18 15:01:58 +08:00
|
|
|
|
trx_date_start_id=data_start_date_id,
|
|
|
|
|
|
trx_date_end_id=data_end_date_id,
|
|
|
|
|
|
le_id=10000 + random.randint(0, 9999),
|
|
|
|
|
|
login_le_id=10000 + random.randint(0, 9999),
|
2026-03-19 16:07:28 +08:00
|
|
|
|
staff_name=identity_scope["staff_name"],
|
|
|
|
|
|
staff_id_card=identity_scope["staff_id_card"],
|
|
|
|
|
|
family_id_cards=identity_scope["family_id_cards"],
|
2026-03-22 11:48:22 +08:00
|
|
|
|
large_transaction_hit_rules=rule_hit_plan.get("large_transaction_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", []),
|
2026-03-31 20:40:38 +08:00
|
|
|
|
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", []),
|
|
|
|
|
|
),
|
2026-03-18 15:01:58 +08:00
|
|
|
|
parsing=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-31 22:14:03 +08:00
|
|
|
|
self._apply_abnormal_account_baselines(file_record)
|
2026-03-18 15:01:58 +08:00
|
|
|
|
self.file_records[log_id] = file_record
|
2026-03-25 10:28:08 +08:00
|
|
|
|
self._rebalance_all_mode_group_rule_plans(group_id)
|
2026-03-22 11:48:22 +08:00
|
|
|
|
self._apply_phase2_baselines(file_record)
|
2026-03-13 16:38:07 +08:00
|
|
|
|
|
|
|
|
|
|
# 返回成功的响应,包含logId数组
|
2026-03-13 15:13:18 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"code": "200",
|
2026-03-13 16:38:07 +08:00
|
|
|
|
"data": [log_id],
|
2026-03-13 15:13:18 +08:00
|
|
|
|
"status": "200",
|
|
|
|
|
|
"successResponse": True,
|
|
|
|
|
|
}
|