feat: UI优化和功能完善

- 添加公共分页组件,支持上边显示、分页大小10
- 侧边栏/Header按Liner风格优化,添加动画效果
- 异常处理列表支持分页和明细弹窗
- 任务结果页面支持已匹配列表分页
- 修复Dialog弹窗背景透明问题
- 新增dropdown-menu组件
- 添加parsed_file_record模型和回填脚本
- 前端枚举中文化
This commit is contained in:
freedakgmail
2026-07-07 12:57:15 +08:00
parent 78bd27a59a
commit b93929eb1a
24 changed files with 3155 additions and 608 deletions
+451 -17
View File
@@ -4,26 +4,49 @@
执行对账任务
"""
from typing import Any, Dict, List, Optional
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: AsyncSession = Depends(get_db),
company_id: int = Depends(get_current_company_id),
) -> Dict[str, Any]:
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
执行对账
@@ -41,11 +64,43 @@ async def execute_reconciliation(
raise HTTPException(status_code=400, detail="任务状态不允许执行对账")
try:
# 获取上传的数据
salary_data = task.salary_data or []
social_security_data = task.social_security_data or []
tax_data = task.tax_data or []
bank_data = task.bank_data if hasattr(task, 'bank_data') else None
# 优先从 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(
@@ -74,15 +129,15 @@ async def execute_reconciliation(
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"对账执行失败: {str(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: AsyncSession = Depends(get_db),
company_id: int = Depends(get_current_company_id),
) -> Dict[str, Any]:
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
获取对账结果
@@ -113,12 +168,391 @@ async def get_reconciliation_result(
}
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: AsyncSession = Depends(get_db),
company_id: int = Depends(get_current_company_id),
) -> Dict[str, Any]:
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
重试对账
+62
View File
@@ -0,0 +1,62 @@
"""
解析后文件记录模型
存储上传文件解析后的每行原始数据,供已匹配列表等场景消费
"""
from typing import Any, Optional
from sqlalchemy import ForeignKey, Integer, String, JSON, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BaseModel
class ParsedFileRecord(BaseModel):
"""
解析后文件记录
每个上传文件解析后的一行原始数据对应一条记录
"""
__tablename__ = "parsed_file_records"
# 归属
company_id: Mapped[int] = mapped_column(
Integer, ForeignKey("companies.id"), nullable=False, index=True
)
task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("reconciliation_tasks.id"), nullable=False, index=True
)
uploaded_file_id: Mapped[int] = mapped_column(
Integer, ForeignKey("uploaded_files.id"), nullable=False, index=True
)
# 数据桶/文件类型
file_bucket: Mapped[str] = mapped_column(
String(30), nullable=False, default="salary",
comment="数据桶: salary / social_security / tax / bank / other"
)
# 行号
row_index: Mapped[int] = mapped_column(Integer, nullable=False)
# 原始行数据(JSON
raw_data: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
# 标准化后数据(JSON
normalized_data: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=True)
# 员工标识(冗余存储,加速已匹配列表查询)
employee_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
employee_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
__table_args__ = (
Index("ix_parsed_file_records_task_bucket", "task_id", "file_bucket"),
)
def __repr__(self) -> str:
return (
f"<ParsedFileRecord(id={self.id}, task_id={self.task_id}, "
f"file_bucket='{self.file_bucket}', row_index={self.row_index})>"
)
@@ -0,0 +1,216 @@
"""
解析后文件记录服务
提供文件解析→标准→入库的完整链路
"""
from pathlib import Path
from typing import Any
import pandas as pd
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.field_mapping import FieldMapping
from app.models.parsed_file_record import ParsedFileRecord
from app.models.uploaded_file import UploadedFile
from app.services.file_storage import FileStorageService
EMPLOYEE_ID_KEYS = (
"employee_id", "员工编号", "员工ID", "员工id", "工号", "员工工号",
)
EMPLOYEE_NAME_KEYS = ("employee_name", "员工姓名", "姓名", "name")
def _normalize_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 _extract_employee_id(row: dict[str, Any]) -> str | None:
for key in EMPLOYEE_ID_KEYS:
value = row.get(key)
if value not in (None, ""):
return str(value).strip()
return None
def _extract_employee_name(row: dict[str, Any]) -> str | None:
for key in EMPLOYEE_NAME_KEYS:
value = row.get(key)
if value not in (None, ""):
return str(value).strip()
return None
def _bucket_of(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"
def _read_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)
records = []
for idx, row in enumerate(df.to_dict(orient="records")):
normalized = _normalize_row(row, mappings)
records.append({
"row_index": idx,
"raw_data": {k: v for k, v in row.items() if k is not None},
"normalized_data": normalized,
"employee_id": _extract_employee_id(normalized),
"employee_name": _extract_employee_name(normalized),
})
return records
class ParsedFileRecordService:
"""解析后文件记录服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def parse_and_save_file(self, file: UploadedFile) -> int:
"""
解析单个上传文件并写入 parsed_file_records
Returns:
写入的记录数
"""
# 已有记录则跳过(幂等)
existing = await self.db.execute(
select(ParsedFileRecord)
.where(ParsedFileRecord.uploaded_file_id == file.id)
.limit(1)
)
if existing.scalars().first() is not None:
return 0
# 加载字段映射
mappings_result = await self.db.execute(
select(FieldMapping).where(
FieldMapping.file_id == file.id,
FieldMapping.company_id == file.company_id,
)
)
mappings = list(mappings_result.scalars().all())
# 解析文件行
rows = _read_file_rows(file, mappings)
bucket = _bucket_of(file.file_type)
# 批量写入
records = [
ParsedFileRecord(
company_id=file.company_id,
task_id=file.task_id,
uploaded_file_id=file.id,
file_bucket=bucket,
row_index=r["row_index"],
raw_data=r["raw_data"],
normalized_data=r["normalized_data"],
employee_id=r["employee_id"],
employee_name=r["employee_name"],
)
for r in rows
]
self.db.add_all(records)
await self.db.commit()
return len(records)
async def parse_and_save_task_files(self, task_id: int, company_id: int) -> dict[str, int]:
"""
解析并保存任务关联的所有上传文件
Returns:
{bucket: count} 各桶写入的记录数
"""
files_result = await self.db.execute(
select(UploadedFile).where(
UploadedFile.task_id == task_id,
UploadedFile.company_id == company_id,
)
)
files = list(files_result.scalars().all())
counts: dict[str, int] = {}
for file in files:
cnt = await self.parse_and_save_file(file)
if cnt > 0:
bucket = _bucket_of(file.file_type)
counts[bucket] = counts.get(bucket, 0) + cnt
return counts
async def get_records_by_task(
self,
task_id: int,
company_id: int,
buckets: list[str] | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[ParsedFileRecord], int]:
"""
按任务读取解析记录
Returns:
(records, total)
"""
query = select(ParsedFileRecord).where(
ParsedFileRecord.task_id == task_id,
ParsedFileRecord.company_id == company_id,
)
if buckets:
query = query.where(ParsedFileRecord.file_bucket.in_(buckets))
count_result = await self.db.execute(
select(ParsedFileRecord.id).where(*query.whereclause.compile().compile() if query.whereclause is not None else [])
)
# 简化计数
all_result = await self.db.execute(query)
all_records = list(all_result.scalars().all())
total = len(all_records)
start = (page - 1) * page_size
return all_records[start:start + page_size], total
@@ -0,0 +1,53 @@
"""添加解析后文件记录模型
Revision ID: e4f8a1b2c56d
Revises: 52067074731d
Create Date: 2026-07-07 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "e4f8a1b2c56d"
down_revision: Union[str, None] = "52067074731d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"parsed_file_records",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("task_id", sa.Integer(), nullable=False),
sa.Column("uploaded_file_id", sa.Integer(), nullable=False),
sa.Column("file_bucket", sa.String(length=30), nullable=False),
sa.Column("row_index", sa.Integer(), nullable=False),
sa.Column("raw_data", sa.JSON(), nullable=False),
sa.Column("normalized_data", sa.JSON(), nullable=True),
sa.Column("employee_id", sa.String(length=100), nullable=True),
sa.Column("employee_name", sa.String(length=200), nullable=True),
sa.ForeignKeyConstraint(["company_id"], ["companies.id"]),
sa.ForeignKeyConstraint(["task_id"], ["reconciliation_tasks.id"]),
sa.ForeignKeyConstraint(["uploaded_file_id"], ["uploaded_files.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_parsed_file_records_company_id", "parsed_file_records", ["company_id"])
op.create_index("ix_parsed_file_records_task_id", "parsed_file_records", ["task_id"])
op.create_index("ix_parsed_file_records_employee_id", "parsed_file_records", ["employee_id"])
op.create_index(
"ix_parsed_file_records_task_bucket", "parsed_file_records", ["task_id", "file_bucket"]
)
def downgrade() -> None:
op.drop_index("ix_parsed_file_records_task_bucket")
op.drop_index("ix_parsed_file_records_employee_id")
op.drop_index("ix_parsed_file_records_task_id")
op.drop_index("ix_parsed_file_records_company_id")
op.drop_table("parsed_file_records")
+340
View File
@@ -0,0 +1,340 @@
"""
回填 ParsedFileRecord 数据
从现有 UploadedFile 记录中:
1. 生成真实 CSV/Excel 文件(模拟原始上传文件)
2. 解析并写入 ParsedFileRecord 表
用法:
cd backend
python scripts/backfill_parsed_records.py
"""
import sys
import os
import csv
import io
import random
from pathlib import Path
from sqlalchemy import select
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.database import async_session_maker as AsyncSessionLocal
from app.models.uploaded_file import UploadedFile, FileType
from app.models.parsed_file_record import ParsedFileRecord
from app.models.field_mapping import FieldMapping # noqa: F401 — SQLAlchemy relationship 需要
# 以下为解决 relationship 字符串引用的传递依赖
from app.models.company import Company # noqa: F401
from app.models.user import User # noqa: F401
from app.models.reconciliation_task import ReconciliationTask # noqa: F401
from app.models.exception_item import ExceptionItem # noqa: F401
from app.models.company_rule import CompanyRule # noqa: F401
from app.models.standard_field import StandardField # noqa: F401
from app.services.file_storage import file_storage_service
# ── 从 reconciliation.py 复制的工具函数 ──────────────────────────────────────
EMPLOYEE_ID_KEYS = (
"employee_id", "员工编号", "员工ID", "员工id", "工号", "员工工号"
)
EMPLOYEE_NAME_KEYS = (
"employee_name", "员工姓名", "姓名", "name", "Name"
)
SALARY_COLUMNS = [
"员工编号", "姓名", "部门", "基本工资", "岗位工资", "绩效工资",
"奖金", "加班费", "应发工资", "社保", "公积金", "个税", "实发工资"
]
SOCIAL_COLUMNS = [
"工号", "姓名", "部门", "养老保险", "医疗保险", "失业保险",
"工伤保险", "生育保险", "公积金", "社保合计"
]
TAX_COLUMNS = [
"工号", "姓名", "部门", "应发工资", "养老保险", "医疗保险",
"失业保险", "公积金", "专项附加扣除", "应税所得", "税率", "个税"
]
EMPLOYEES = [
("EMP001", "张明华", "管理层", 35000),
("EMP002", "李晓燕", "财务部", 28000),
("EMP003", "王建国", "技术部", 30000),
("EMP004", "刘伟强", "技术部", 22000),
("EMP005", "陈志明", "技术部", 18000),
("EMP006", "周建军", "技术部", 17000),
("EMP007", "吴海涛", "技术部", 12000),
("EMP008", "郑晓峰", "技术部", 15000),
("EMP009", "孙丽娜", "产品部", 22000),
("EMP010", "马俊杰", "产品部", 14000),
("EMP011", "朱婷婷", "产品部", 16000),
("EMP012", "胡文静", "运营部", 25000),
("EMP013", "林浩然", "运营部", 13000),
("EMP014", "何雨晴", "运营部", 12000),
("EMP015", "高建峰", "运营部", 9000),
("EMP016", "罗晓东", "市场部", 26000),
("EMP017", "宋志远", "市场部", 11000),
("EMP018", "唐思远", "市场部", 15000),
("EMP019", "韩冰冰", "市场部", 8000),
("EMP020", "冯婉君", "行政部", 14000),
("EMP021", "许志刚", "行政部", 8000),
("EMP022", "邓小丽", "行政部", 6000),
("EMP023", "曹丽华", "人事部", 15000),
("EMP024", "彭海燕", "人事部", 9000),
("EMP025", "曾敏仪", "人事部", 8500),
]
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"
def _to_number(value) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip().replace(",", "").replace("¥", "").replace("", "")
if not s:
return None
try:
return float(s)
except ValueError:
return None
def _normalize_field(key: str) -> str:
key = key.strip().lower()
mapping = {
"employee_id": "employee_id",
"员工编号": "employee_id", "员工id": "employee_id",
"工号": "employee_id", "员工工号": "employee_id",
"employee_name": "employee_name",
"姓名": "employee_name", "员工姓名": "employee_name",
"name": "employee_name",
"base_salary": "base_salary", "基本工资": "base_salary", "岗位工资": "base_salary",
"gross_salary": "gross_salary", "应发工资": "gross_salary",
"net_salary": "net_salary", "实发工资": "net_salary",
"social_security": "social_security", "社保": "social_security",
"pension": "pension", "养老保险": "pension",
"medical": "medical", "医疗保险": "medical",
"housing_fund": "housing_fund", "公积金": "housing_fund",
"personal_income_tax": "personal_income_tax",
"tax": "tax", "个税": "tax", "个人所得税": "personal_income_tax",
}
return mapping.get(key, key)
def _generate_salary_rows() -> list[dict]:
rows = []
for emp_id, name, dept, base in EMPLOYEES:
performance = round(base * random.uniform(0.1, 0.3), 2)
bonus = round(base * random.uniform(0.5, 1.5), 2) if random.random() > 0.3 else 0
overtime = random.choice([0, 500, 1000, 1500])
gross = base + performance + bonus + overtime
social = round(base * 0.105, 2)
housing = round(base * 0.12, 2)
taxable = gross - social - housing - 2000
if taxable <= 3000:
tax = round(taxable * 0.03, 2)
elif taxable <= 12000:
tax = round(taxable * 0.10 - 210, 2)
elif taxable <= 25000:
tax = round(taxable * 0.20 - 1410, 2)
else:
tax = round(taxable * 0.25 - 2660, 2)
net = round(gross - social - housing - tax, 2)
rows.append({
"员工编号": emp_id,
"姓名": name,
"部门": dept,
"基本工资": base,
"岗位工资": base,
"绩效工资": performance,
"奖金": bonus,
"加班费": overtime,
"应发工资": round(gross, 2),
"社保": social,
"公积金": housing,
"个税": tax,
"实发工资": net,
})
return rows
def _generate_social_rows() -> list[dict]:
rows = []
for emp_id, name, dept, base in EMPLOYEES:
pension = round(base * 0.08, 2)
medical = round(base * 0.02, 2)
unemployment = round(base * 0.005, 2)
total = round(pension + medical + unemployment + base * 0.12, 2)
rows.append({
"工号": emp_id,
"姓名": name,
"部门": dept,
"养老保险": pension,
"医疗保险": medical,
"失业保险": unemployment,
"工伤保险": 0,
"生育保险": 0,
"公积金": round(base * 0.12, 2),
"社保合计": total,
})
return rows
def _generate_tax_rows() -> list[dict]:
rows = []
for emp_id, name, dept, base in EMPLOYEES:
gross = base * random.uniform(1.1, 1.3)
social = base * 0.105
housing = base * 0.12
taxable = gross - social - housing - 2000 - 5000
if taxable <= 0:
tax = 0
rate = 0
elif taxable <= 3000:
tax = round(taxable * 0.03, 2)
rate = 0.03
elif taxable <= 12000:
tax = round(taxable * 0.10 - 210, 2)
rate = 0.10
else:
tax = round(taxable * 0.20 - 1410, 2)
rate = 0.20
rows.append({
"工号": emp_id,
"姓名": name,
"部门": dept,
"应发工资": round(gross, 2),
"养老保险": round(social, 2),
"医疗保险": round(medical if (medical := base * 0.02) else 0, 2),
"失业保险": round(unemployment if (unemployment := base * 0.005) else 0, 2),
"公积金": round(housing, 2),
"专项附加扣除": 2000,
"应税所得": round(max(taxable, 0), 2),
"税率": rate,
"个税": tax,
})
return rows
def _parse_csv(content: str, bucket: str) -> list[dict]:
"""解析 CSV 内容并标准化字段"""
reader = csv.DictReader(io.StringIO(content))
records = []
for row in reader:
normalized = {_normalize_field(k): v for k, v in row.items()}
emp_id = None
for key in EMPLOYEE_ID_KEYS:
if key in normalized and normalized[key]:
emp_id = str(normalized[key]).strip()
break
emp_name = None
for key in EMPLOYEE_NAME_KEYS:
if key in normalized and normalized[key]:
emp_name = str(normalized[key]).strip()
break
records.append({
"raw_data": dict(row),
"normalized_data": normalized,
"employee_id": emp_id,
"employee_name": emp_name,
})
return records
async def backfill():
upload_dir = file_storage_service.get_upload_dir()
upload_dir.mkdir(parents=True, exist_ok=True)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(UploadedFile).where(
UploadedFile.parse_status == "解析成功",
UploadedFile.stored_filename.isnot(None),
)
)
files = list(result.scalars().all())
print(f"找到 {len(files)} 个已解析的上传文件")
if not files:
print("没有需要回填的文件")
return
created = 0
for file in files:
# 确保目录存在
company_dir = upload_dir / str(file.company_id)
company_dir.mkdir(parents=True, exist_ok=True)
bucket = _file_bucket(file.file_type)
# 生成对应 bucket 的行数据
if bucket == "salary":
rows = _generate_salary_rows()
elif bucket == "social_security":
rows = _generate_social_rows()
elif bucket == "tax":
rows = _generate_tax_rows()
else:
rows = []
if not rows:
print(f" 跳过文件 {file.id} (bucket={bucket}, 无对应生成器)")
continue
# 生成 CSV 内容
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
csv_content = buffer.getvalue()
# 保存文件(覆盖 stub
file_ext = ".csv"
stored_path = company_dir / f"{file.id}{file_ext}"
with open(stored_path, "w", encoding="utf-8-sig") as f:
f.write(csv_content)
# 更新 stored_filename(移除 company_id 前缀,因为 FileStorageService 会拼接)
actual_stored = f"{file.company_id}/{file.id}{file_ext}"
file.stored_filename = actual_stored
await db.flush()
# 解析并写入 ParsedFileRecord
parsed = _parse_csv(csv_content, bucket)
for idx, rec in enumerate(parsed):
pfr = ParsedFileRecord(
company_id=file.company_id,
task_id=file.task_id,
uploaded_file_id=file.id,
file_bucket=bucket,
row_index=idx,
raw_data=rec["raw_data"],
normalized_data=rec["normalized_data"],
employee_id=rec["employee_id"],
employee_name=rec["employee_name"],
)
db.add(pfr)
created += 1
print(f" 文件 {file.id}: bucket={bucket}, {len(parsed)}")
await db.commit()
print(f"\n完成:创建 {created} 条 ParsedFileRecord")
if __name__ == "__main__":
import asyncio
asyncio.run(backfill())
+27
View File
@@ -0,0 +1,27 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import asyncio
from app.core.database import async_session_maker
from sqlalchemy import text
async def check():
async with async_session_maker() as db:
r1 = await db.execute(text("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"))
print('Tables:', [row[0] for row in r1.fetchall()])
r2 = await db.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'companies' AND table_schema = 'public'"))
print('companies cols:', [row[0] for row in r2.fetchall()])
r3 = await db.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'reconciliation_tasks' AND table_schema = 'public'"))
print('reconciliation_tasks cols:', [row[0] for row in r3.fetchall()])
r4 = await db.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'uploaded_files' AND table_schema = 'public'"))
print('uploaded_files cols:', [row[0] for row in r4.fetchall()])
r5 = await db.execute(text("SELECT id, name FROM companies LIMIT 5"))
print('Companies:', r5.fetchall())
r6 = await db.execute(text("SELECT id, company_id, name, status FROM reconciliation_tasks LIMIT 10"))
print('Tasks:', r6.fetchall())
r7 = await db.execute(text("SELECT id, task_id, company_id, file_type, parse_status, stored_filename FROM uploaded_files LIMIT 10"))
print('UploadedFiles:', r7.fetchall())
asyncio.run(check())