Files
s2f/backend/app/api/mappings.py
T
freedakgmail 33b4c734aa feat: 完善前后端核心功能模块
后端:
- 新增认证(auth)、任务(tasks)、映射(mappings)、对账(reconciliation)、异常(exceptions)、导出(exports) API
- 新增核心模块: database, security, permissions, tenant, exceptions, error_handlers
- 新增数据模型: user, company, reconciliation_task, field_mapping, uploaded_file 等
- 新增服务层: ai_recognizer, file_parser, file_storage, mapping, reconciliation 等
- 添加数据库迁移脚本

前端:
- 新增登录页面和仪表盘页面
- 新增任务列表、任务详情、字段映射页面
- 新增异常处理页面和规则设置页面
- 新增 API 代理路由 /api/[...path]
- 新增 UI 组件库 (button, card, dialog, input, table 等)
- 新增 auth 组件 (ProtectedRoute, PermissionGate)
- 新增 layout 组件 (Header, Sidebar)
- 新增 mapping 组件 (FieldMappingTable, AISuggestionPanel)
- 新增 API 客户端和 hooks (useAsync, useToast, usePermission 等)
- 新增状态管理 (auth-store, company-store, ui-store)
- 集成 Tailwind CSS 和 shadcn/ui 组件库

其他:
- 添加 Alembic 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
2026-07-07 09:04:47 +08:00

288 lines
9.1 KiB
Python

"""
字段映射 API 端点
提供字段识别、映射确认和规则管理功能
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.auth import AuthService
from app.core.tenant import get_current_company
from app.schemas.mapping import (
FieldMappingResponse,
FieldMappingUpdate,
ConfirmMappingsRequest,
ConfirmMappingsResponse,
SaveAsRuleRequest,
SaveAsRuleResponse,
CompanyRuleResponse,
UpdateRuleStatusRequest,
RecognizeFieldsRequest,
RecognizeFieldsResponse,
TaskMappingOverview,
FileMappingSummary,
)
from app.services.mapping import MappingService
from app.models.user import User
from app.models.company import Company
router = APIRouter(prefix="/mappings", tags=["字段映射"])
def get_mapping_service(db: AsyncSession = Depends(get_db)) -> MappingService:
return MappingService(db)
@router.post("/recognize", response_model=RecognizeFieldsResponse)
async def recognize_fields(
request: RecognizeFieldsRequest,
company_id: int = Depends(get_current_company),
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""
触发 AI 字段识别
- 读取已上传文件的表头和样例数据
- 调用 AI 识别服务
- 保存映射建议
- 返回映射结果
"""
try:
mappings = await service.recognize_and_save(
company_id=company_id,
file_id=request.file_id,
file_type=request.file_type
)
# 统计置信度分布
high = sum(1 for m in mappings if m.confidence > 0.9)
medium = sum(1 for m in mappings if 0.7 <= m.confidence <= 0.9)
low = sum(1 for m in mappings if m.confidence < 0.7)
return RecognizeFieldsResponse(
file_id=request.file_id,
mappings=[FieldMappingResponse.model_validate(m) for m in mappings],
high_confidence_count=high,
medium_confidence_count=medium,
low_confidence_count=low,
needs_review=low > 0 or medium > 0
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"字段识别失败: {str(e)}")
@router.get("/task/{task_id}", response_model=TaskMappingOverview)
async def get_task_mappings(
task_id: int,
company_id: int = Depends(get_current_company),
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""
获取任务的字段映射概览
- 按文件分组返回所有映射
- 包含置信度统计
"""
try:
grouped_mappings = await service.get_mappings_by_task(task_id, company_id)
files = []
total_fields = 0
confirmed_fields = 0
for file_id, mappings in grouped_mappings.items():
file = mappings[0].file if mappings else None
high = sum(1 for m in mappings if m.confidence > 0.9)
medium = sum(1 for m in mappings if 0.7 <= m.confidence <= 0.9)
low = sum(1 for m in mappings if m.confidence < 0.7)
confirmed = sum(1 for m in mappings if m.confirmed)
files.append(FileMappingSummary(
file_id=file_id,
file_type=file.file_type if file else "",
file_name=file.original_filename if file else "",
total_fields=len(mappings),
confirmed_fields=confirmed,
high_confidence=high,
medium_confidence=medium,
low_confidence=low,
mappings=[FieldMappingResponse.model_validate(m) for m in mappings]
))
total_fields += len(mappings)
confirmed_fields += confirmed
needs_review = any(
f.medium_confidence > 0 or f.low_confidence > 0
for f in files
)
return TaskMappingOverview(
task_id=task_id,
total_files=len(files),
total_fields=total_fields,
confirmed_fields=confirmed_fields,
needs_review=needs_review,
files=files
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取映射失败: {str(e)}")
@router.get("/{mapping_id}", response_model=FieldMappingResponse)
async def get_mapping(
mapping_id: int,
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""获取单个字段映射"""
# 这里需要添加 company_id 检查
mapping = await service.db.get(mapping_id)
if not mapping:
raise HTTPException(status_code=404, detail="映射不存在")
return FieldMappingResponse.model_validate(mapping)
@router.put("/{mapping_id}", response_model=FieldMappingResponse)
async def update_mapping(
mapping_id: int,
update: FieldMappingUpdate,
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""
修改字段映射
- 修改标准字段
- 标记跳过
"""
mapping = await service.update_mapping(
mapping_id=mapping_id,
standard_field=update.standard_field,
is_skipped=update.is_skipped
)
if not mapping:
raise HTTPException(status_code=404, detail="映射不存在")
return FieldMappingResponse.model_validate(mapping)
@router.post("/confirm", response_model=ConfirmMappingsResponse)
async def confirm_mappings(
request: ConfirmMappingsRequest,
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""
批量确认字段映射
- 确认后映射将用于数据处理
- 不会自动保存为规则
"""
confirmed = await service.confirm_mappings(
mapping_ids=request.mapping_ids,
user_id=current_user.id
)
return ConfirmMappingsResponse(
confirmed_count=len(confirmed),
mappings=[FieldMappingResponse.model_validate(m) for m in confirmed]
)
@router.post("/{mapping_id}/save-as-rule", response_model=SaveAsRuleResponse)
async def save_mapping_as_rule(
mapping_id: int,
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""
将字段映射保存为企业规则
- 下次上传同类文件时自动应用规则
- 可在规则管理页面查看和管理
"""
from app.models.field_mapping import FieldMapping
# 获取映射
mapping = await service.db.get(FieldMapping, mapping_id)
if not mapping:
raise HTTPException(status_code=404, detail="映射不存在")
# 保存为规则
rule = await service.save_as_rule(mapping, current_user.id)
return SaveAsRuleResponse(
rule_id=rule.id,
source_field=mapping.source_field,
target_field=mapping.standard_field,
message=f"规则已保存:'{mapping.source_field}' -> '{mapping.standard_field}'"
)
# ===== 规则管理 API =====
@router.get("/rules/list", response_model=List[CompanyRuleResponse])
async def list_rules(
rule_type: Optional[str] = Query(None, description="规则类型"),
company_id: int = Depends(get_current_company),
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""获取企业的规则列表"""
rules = await service.get_company_rules(company_id, rule_type)
return [CompanyRuleResponse.model_validate(r) for r in rules]
@router.patch("/rules/{rule_id}/status", response_model=CompanyRuleResponse)
async def update_rule_status(
rule_id: int,
request: UpdateRuleStatusRequest,
company_id: int = Depends(get_current_company),
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""更新规则状态(启用/停用)"""
rule = await service.update_rule_status(rule_id, request.status)
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
if rule.company_id != company_id:
raise HTTPException(status_code=403, detail="无权操作此规则")
return CompanyRuleResponse.model_validate(rule)
@router.delete("/rules/{rule_id}")
async def delete_rule(
rule_id: int,
company_id: int = Depends(get_current_company),
current_user: User = Depends(AuthService.get_current_user),
service: MappingService = Depends(get_mapping_service),
):
"""删除规则"""
from app.models.company_rule import CompanyRule
rule = await service.db.get(CompanyRule, rule_id)
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
if rule.company_id != company_id:
raise HTTPException(status_code=403, detail="无权操作此规则")
success = await service.delete_rule(rule_id)
if not success:
raise HTTPException(status_code=500, detail="删除失败")
return {"message": "规则已删除"}