Files
s2f/backend/app/api/reconciliation.py
T
freedakgmail b93929eb1a feat: UI优化和功能完善
- 添加公共分页组件,支持上边显示、分页大小10
- 侧边栏/Header按Liner风格优化,添加动画效果
- 异常处理列表支持分页和明细弹窗
- 任务结果页面支持已匹配列表分页
- 修复Dialog弹窗背景透明问题
- 新增dropdown-menu组件
- 添加parsed_file_record模型和回填脚本
- 前端枚举中文化
2026-07-07 12:57:15 +08:00

575 lines
19 KiB
Python

"""
对账执行 API
执行对账任务
"""
from pathlib import Path
from typing import Annotated, Any
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.tenant import get_current_company_id
from app.models.exception_item import ExceptionItem
from app.models.field_mapping import FieldMapping
from app.models.parsed_file_record import ParsedFileRecord
from app.models.reconciliation_task import ReconciliationTask
from app.models.uploaded_file import UploadedFile
from app.services.file_storage import FileStorageService
from app.services.reconciliation.engine import run_reconciliation
router = APIRouter(prefix="/api/reconciliation", tags=["对账执行"])
DBSession = Annotated[AsyncSession, Depends(get_db)]
CompanyId = Annotated[int, Depends(get_current_company_id)]
EMPLOYEE_ID_KEYS = ("employee_id", "员工编号", "员工ID", "员工id", "工号", "员工工号")
EMPLOYEE_NAME_KEYS = ("employee_name", "员工姓名", "姓名", "name")
SALARY_AMOUNT_KEYS = ("salary_amount", "gross_salary", "应发工资", "工资", "税前工资")
SOCIAL_SECURITY_AMOUNT_KEYS = (
"social_security_amount",
"social_security",
"社保",
"社保金额",
"社保个人部分",
)
TAX_AMOUNT_KEYS = ("tax_amount", "personal_income_tax", "tax", "个税", "个人所得税", "税额")
NET_SALARY_KEYS = ("net_salary", "实发工资", "实发")
BANK_AMOUNT_KEYS = ("net_salary", "bank_amount", "银行金额", "实发工资", "实发")
@router.post("/execute/{task_id}")
async def execute_reconciliation(
task_id: int,
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
执行对账
触发对账引擎,处理上传的数据并返回结果
"""
# 获取任务
task = await db.get(ReconciliationTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.company_id != company_id:
raise HTTPException(status_code=403, detail="无权访问该任务")
if task.status not in ["MAPPING_COMPLETED", "DATA_UPLOADED"]:
raise HTTPException(status_code=400, detail="任务状态不允许执行对账")
try:
# 优先从 ParsedFileRecord 读取解析后的数据
records_result = await db.execute(
select(ParsedFileRecord).where(
ParsedFileRecord.task_id == task_id,
ParsedFileRecord.company_id == company_id,
)
)
parsed_records = list(records_result.scalars().all())
salary_data: list[dict[str, Any]] = []
social_security_data: list[dict[str, Any]] = []
tax_data: list[dict[str, Any]] = []
bank_data: list[dict[str, Any]] = []
for rec in parsed_records:
normalized = rec.normalized_data or {}
row = {"employee_id": rec.employee_id, "employee_name": rec.employee_name, **normalized}
bucket = rec.file_bucket
if bucket == "salary":
salary_data.append(row)
elif bucket == "social_security":
social_security_data.append(row)
elif bucket == "tax":
tax_data.append(row)
elif bucket == "bank":
bank_data.append(row)
# 若没有 ParsedFileRecord 数据,降级到直接从上传文件解析
if not parsed_records:
salary_data = await _raw_records_from_files(
db, task_id, company_id, "salary"
)
social_security_data = await _raw_records_from_files(
db, task_id, company_id, "social_security"
)
tax_data = await _raw_records_from_files(db, task_id, company_id, "tax")
bank_data = await _raw_records_from_files(db, task_id, company_id, "bank")
# 执行对账
result = await run_reconciliation(
db=db,
company_id=company_id,
task_id=task_id,
period=task.period,
salary_records=salary_data,
social_security_records=social_security_data,
tax_records=tax_data,
bank_records=bank_data,
enable_bank_rules=bool(bank_data),
)
return {
"success": True,
"task_id": task_id,
"result": {
"total_employees": result.total_employees,
"matched_employees": result.matched_employees,
"exception_count": result.exception_count,
"exceptions_by_type": result.exceptions_by_type,
"exceptions_by_severity": result.exceptions_by_severity,
"execution_time_ms": result.execution_time_ms,
"completed_at": result.completed_at,
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"对账执行失败: {str(e)}") from e
@router.get("/result/{task_id}")
async def get_reconciliation_result(
task_id: int,
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
获取对账结果
返回已执行的对账结果汇总
"""
task = await db.get(ReconciliationTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.company_id != company_id:
raise HTTPException(status_code=403, detail="无权访问该任务")
# 获取异常统计
from app.services.exception_service import ExceptionService
service = ExceptionService(db)
summary = await service.get_exception_summary(task_id=task_id, company_id=company_id)
return {
"task_id": task_id,
"period": task.period,
"total_employees": task.total_employees,
"matched_employees": task.matched_count,
"exception_count": task.exception_count,
"exceptions_by_type": summary.get("by_type", {}),
"exceptions_by_severity": summary.get("by_severity", {}),
"exceptions_by_status": summary.get("by_status", {}),
"completed_at": task.updated_at.isoformat() if task.updated_at else None,
}
def _to_number(value: Any) -> float | None:
if value in (None, ""):
return None
try:
if pd.isna(value):
return None
except TypeError:
pass
try:
return float(value)
except (TypeError, ValueError):
return None
def _pick(record: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = record.get(key)
if value not in (None, ""):
return value
return None
def _employee_id(record: dict[str, Any]) -> str | None:
value = _pick(record, *EMPLOYEE_ID_KEYS)
return str(value).strip() if value not in (None, "") else None
def _employee_name(record: dict[str, Any]) -> str | None:
value = _pick(record, *EMPLOYEE_NAME_KEYS)
return str(value).strip() if value not in (None, "") else None
def _normalize_matched_record(record: dict[str, Any]) -> dict[str, Any] | None:
employee_id = _employee_id(record)
if not employee_id:
return None
return {
"employee_id": employee_id,
"employee_name": _employee_name(record) or employee_id,
"salary_amount": _to_number(_pick(record, *SALARY_AMOUNT_KEYS)),
"social_security_amount": _to_number(_pick(record, *SOCIAL_SECURITY_AMOUNT_KEYS)),
"tax_amount": _to_number(_pick(record, *TAX_AMOUNT_KEYS)),
"net_salary": _to_number(_pick(record, *NET_SALARY_KEYS, *BANK_AMOUNT_KEYS)),
}
def _records_from_reconciliation_result(result: dict | None) -> list[dict[str, Any]]:
if not isinstance(result, dict):
return []
candidates = [
result.get("matched_records"),
result.get("matched_employees_list"),
result.get("matched_items"),
result.get("matched"),
]
for candidate in candidates:
if not isinstance(candidate, list):
continue
records = []
for item in candidate:
if isinstance(item, dict):
normalized = _normalize_matched_record(item)
if normalized:
records.append(normalized)
if records:
return records
return []
def _standardize_row(row: dict[str, Any], mappings: list[FieldMapping]) -> dict[str, Any]:
standardized = dict(row)
for mapping in mappings:
if mapping.is_skipped or not mapping.standard_field:
continue
if mapping.source_field in row and row[mapping.source_field] not in (None, ""):
standardized[mapping.standard_field] = row[mapping.source_field]
return standardized
def _read_uploaded_file_rows(
file: UploadedFile,
mappings: list[FieldMapping],
) -> list[dict[str, Any]]:
file_path = FileStorageService.get_file_path(file.stored_filename)
if not file_path.exists() or not file_path.is_file():
return []
suffix = Path(file.original_filename or file.stored_filename).suffix.lower()
try:
if suffix in {".xlsx", ".xls"}:
df = pd.read_excel(file_path)
else:
df = None
for encoding in ("utf-8", "utf-8-sig", "gbk", "gb2312"):
try:
df = pd.read_csv(file_path, encoding=encoding)
break
except UnicodeDecodeError:
continue
if df is None:
return []
except Exception:
return []
df = df.where(pd.notna(df), None)
return [_standardize_row(row, mappings) for row in df.to_dict(orient="records")]
def _file_bucket(file_type: str) -> str:
lowered = file_type.lower()
if "工资" in file_type or "salary" in lowered:
return "salary"
if "社保" in file_type or "social" in lowered:
return "social_security"
if "个税" in file_type or "tax" in lowered:
return "tax"
if "银行" in file_type or "bank" in lowered:
return "bank"
return "other"
async def _raw_records_from_files(
db: AsyncSession,
task_id: int,
company_id: int,
bucket: str,
) -> list[dict[str, Any]]:
"""从上传文件按 bucket 直接读取原始记录(未走 ParsedFileRecord 时的降级路径)"""
type_map = {
"salary": ("salary", "SALARY", "工资", "工资表"),
"social_security": ("social_security", "SOCIAL_SECURITY", "社保", "社保表"),
"tax": ("tax", "TAX", "个税", "个人所得税"),
"bank": ("bank", "BANK", "银行", "银行数据"),
}
type_keywords = type_map.get(bucket, (bucket,))
del type_keywords # 仅用于文档,逻辑中用 bucket
files_result = await db.execute(
select(UploadedFile).where(
UploadedFile.task_id == task_id,
UploadedFile.company_id == company_id,
)
)
files = [f for f in files_result.scalars().all() if _file_bucket(f.file_type) == bucket]
if not files:
return []
file_ids = [f.id for f in files]
mappings_result = await db.execute(
select(FieldMapping).where(
FieldMapping.company_id == company_id,
FieldMapping.file_id.in_(file_ids),
)
)
mappings_by_file: dict[int, list[FieldMapping]] = {}
for m in mappings_result.scalars().all():
mappings_by_file.setdefault(m.file_id, []).append(m)
records: list[dict[str, Any]] = []
for file in files:
rows = _read_uploaded_file_rows(file, mappings_by_file.get(file.id, []))
records.extend(rows)
return records
async def _records_from_uploaded_files(
db: AsyncSession,
task_id: int,
company_id: int,
exception_employee_ids: set[str],
) -> list[dict[str, Any]]:
files_result = await db.execute(
select(UploadedFile).where(
UploadedFile.task_id == task_id,
UploadedFile.company_id == company_id,
)
)
files = list(files_result.scalars().all())
if not files:
return []
file_ids = [file.id for file in files]
mappings_result = await db.execute(
select(FieldMapping).where(
FieldMapping.company_id == company_id,
FieldMapping.file_id.in_(file_ids),
)
)
mappings_by_file: dict[int, list[FieldMapping]] = {}
for mapping in mappings_result.scalars().all():
mappings_by_file.setdefault(mapping.file_id, []).append(mapping)
records_by_bucket: dict[str, dict[str, dict[str, Any]]] = {
"salary": {},
"social_security": {},
"tax": {},
"bank": {},
}
for file in files:
bucket = _file_bucket(file.file_type)
if bucket not in records_by_bucket:
continue
rows = _read_uploaded_file_rows(file, mappings_by_file.get(file.id, []))
for row in rows:
employee_id = _employee_id(row)
if employee_id:
records_by_bucket[bucket][employee_id] = row
employee_ids = set(records_by_bucket["salary"].keys())
if not employee_ids:
employee_ids.update(records_by_bucket["social_security"].keys())
employee_ids.update(records_by_bucket["tax"].keys())
employee_ids.update(records_by_bucket["bank"].keys())
matched_records = []
for employee_id in sorted(employee_ids):
if employee_id in exception_employee_ids:
continue
salary_record = records_by_bucket["salary"].get(employee_id, {})
social_record = records_by_bucket["social_security"].get(employee_id, {})
tax_record = records_by_bucket["tax"].get(employee_id, {})
bank_record = records_by_bucket["bank"].get(employee_id, {})
salary_amount = _to_number(_pick(salary_record, *SALARY_AMOUNT_KEYS))
social_security_amount = _to_number(
_pick(social_record, *SOCIAL_SECURITY_AMOUNT_KEYS, "total")
)
if social_security_amount is None:
social_security_amount = _to_number(_pick(salary_record, *SOCIAL_SECURITY_AMOUNT_KEYS))
tax_amount = _to_number(_pick(tax_record, *TAX_AMOUNT_KEYS))
if tax_amount is None:
tax_amount = _to_number(_pick(salary_record, *TAX_AMOUNT_KEYS))
net_salary = _to_number(_pick(salary_record, *NET_SALARY_KEYS))
if net_salary is None:
net_salary = _to_number(_pick(bank_record, *BANK_AMOUNT_KEYS))
matched_records.append({
"employee_id": employee_id,
"employee_name": (
_employee_name(salary_record)
or _employee_name(social_record)
or _employee_name(tax_record)
or _employee_name(bank_record)
or employee_id
),
"salary_amount": salary_amount,
"social_security_amount": social_security_amount,
"tax_amount": tax_amount,
"net_salary": net_salary,
})
return matched_records
@router.get("/matched/{task_id}")
async def get_matched_records(
task_id: int,
db: DBSession,
company_id: CompanyId,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""
获取已匹配记录明细
返回对账中匹配成功的员工记录列表
"""
task = await db.get(ReconciliationTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.company_id != company_id:
raise HTTPException(status_code=403, detail="无权访问该任务")
page = max(page, 1)
page_size = max(page_size, 1)
exception_result = await db.execute(
select(ExceptionItem.employee_id).where(
ExceptionItem.task_id == task_id,
ExceptionItem.company_id == company_id,
ExceptionItem.employee_id.is_not(None),
)
)
exception_employee_ids = {
str(employee_id) for employee_id in exception_result.scalars().all() if employee_id
}
# 优先从 ParsedFileRecord 读取
matched_records: list[dict[str, Any]] = []
parsed_result = await db.execute(
select(ParsedFileRecord).where(
ParsedFileRecord.task_id == task_id,
ParsedFileRecord.company_id == company_id,
ParsedFileRecord.employee_id.is_not(None),
)
)
parsed_records = list(parsed_result.scalars().all())
if parsed_records:
# 按 bucket 聚合
by_emp: dict[str, dict[str, Any]] = {}
for rec in parsed_records:
emp_id = rec.employee_id
if not emp_id or emp_id in exception_employee_ids:
continue
if emp_id not in by_emp:
by_emp[emp_id] = {
"employee_id": emp_id,
"employee_name": rec.employee_name or emp_id,
"salary_amount": None,
"social_security_amount": None,
"tax_amount": None,
"net_salary": None,
}
nd = rec.normalized_data or {}
# 尝试填充各金额字段
for key in SALARY_AMOUNT_KEYS:
if by_emp[emp_id]["salary_amount"] is None:
v = nd.get(key)
if v is not None:
by_emp[emp_id]["salary_amount"] = _to_number(v)
for key in SOCIAL_SECURITY_AMOUNT_KEYS:
if by_emp[emp_id]["social_security_amount"] is None:
v = nd.get(key)
if v is not None:
by_emp[emp_id]["social_security_amount"] = _to_number(v)
for key in TAX_AMOUNT_KEYS:
if by_emp[emp_id]["tax_amount"] is None:
v = nd.get(key)
if v is not None:
by_emp[emp_id]["tax_amount"] = _to_number(v)
for key in (*NET_SALARY_KEYS, *BANK_AMOUNT_KEYS):
if by_emp[emp_id]["net_salary"] is None:
v = nd.get(key)
if v is not None:
by_emp[emp_id]["net_salary"] = _to_number(v)
matched_records = [by_emp[k] for k in sorted(by_emp)]
if not matched_records:
matched_records = _records_from_reconciliation_result(task.reconciliation_result)
if not matched_records:
matched_records = await _records_from_uploaded_files(
db,
task_id,
company_id,
exception_employee_ids,
)
total = len(matched_records)
start = (page - 1) * page_size
end = start + page_size
items = matched_records[start:end]
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
"message": None if total > 0 else "暂无可展示的已匹配明细",
}
@router.post("/retry/{task_id}")
async def retry_reconciliation(
task_id: int,
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
重试对账
重新执行已完成的或失败的对账任务
"""
task = await db.get(ReconciliationTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.company_id != company_id:
raise HTTPException(status_code=403, detail="无权访问该任务")
# 重置任务状态
task.status = "PROCESSING"
task.matched_count = 0
task.exception_count = 0
await db.commit()
# 执行对账
return await execute_reconciliation(task_id, db, company_id)