33b4c734aa
后端: - 新增认证(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 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
288 lines
9.1 KiB
Python
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": "规则已删除"} |