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
@@ -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