""" 对账执行 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)