b93929eb1a
- 添加公共分页组件,支持上边显示、分页大小10 - 侧边栏/Header按Liner风格优化,添加动画效果 - 异常处理列表支持分页和明细弹窗 - 任务结果页面支持已匹配列表分页 - 修复Dialog弹窗背景透明问题 - 新增dropdown-menu组件 - 添加parsed_file_record模型和回填脚本 - 前端枚举中文化
575 lines
19 KiB
Python
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) |