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 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import Token, UserCreate, UserLogin, UserResponse
|
||||
from app.services.auth import AuthService, auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
注册新用户
|
||||
"""
|
||||
user = await auth_service.register_user(db, user_data)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=dict)
|
||||
async def login(
|
||||
login_data: UserLogin,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
用户登录
|
||||
"""
|
||||
user, access_token = await auth_service.authenticate_user(db, login_data)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": UserResponse.model_validate(user)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
current_user: User = Depends(auth_service.get_current_user),
|
||||
):
|
||||
"""
|
||||
用户登出(客户端需清除 Token)
|
||||
"""
|
||||
return {"message": "登出成功"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(
|
||||
current_user: User = Depends(auth_service.get_current_user),
|
||||
):
|
||||
"""
|
||||
获取当前用户信息
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
current_user: User = Depends(auth_service.get_current_user),
|
||||
):
|
||||
"""
|
||||
刷新 Token
|
||||
"""
|
||||
from app.core.security import create_access_token
|
||||
access_token = create_access_token(data={"sub": str(current_user.id), "email": current_user.email})
|
||||
return Token(access_token=access_token)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
异常处理 API
|
||||
|
||||
提供异常的查询、更新、批量操作等接口
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
||||
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.services.exception_service import (
|
||||
get_exceptions,
|
||||
update_exception,
|
||||
batch_resolve_exceptions,
|
||||
ExceptionService,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/exceptions", tags=["异常处理"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_exceptions(
|
||||
task_id: Optional[int] = Query(None, description="任务ID"),
|
||||
status: Optional[str] = Query(None, description="状态"),
|
||||
severity: Optional[str] = Query(None, description="严重程度"),
|
||||
exception_type: Optional[str] = Query(None, description="异常类型"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
查询异常列表
|
||||
|
||||
支持按任务、状态、严重程度、类型筛选
|
||||
"""
|
||||
exceptions, total = await get_exceptions(
|
||||
db=db,
|
||||
task_id=task_id,
|
||||
company_id=company_id,
|
||||
status=status,
|
||||
severity=severity,
|
||||
exception_type=exception_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": e.id,
|
||||
"task_id": e.task_id,
|
||||
"exception_type": e.exception_type,
|
||||
"severity": e.severity.lower() if e.severity else None,
|
||||
"status": e.status.lower() if e.status else None,
|
||||
"employee_id": e.employee_id,
|
||||
"employee_name": e.employee_name,
|
||||
"description": e.description,
|
||||
"detail": e.detail,
|
||||
"salary_amount": e.salary_amount,
|
||||
"social_security_amount": e.social_security_amount,
|
||||
"tax_amount": e.tax_amount,
|
||||
"bank_amount": e.bank_amount,
|
||||
"difference_amount": e.difference_amount,
|
||||
"suggested_action": e.suggested_action,
|
||||
"ai_suggestion": e.ai_suggestion,
|
||||
"handling_note": e.handling_note,
|
||||
"handled_by": e.handled_by,
|
||||
"handler_name": e.handler.full_name if e.handler else None,
|
||||
"handled_at": e.handled_at.isoformat() if e.handled_at else None,
|
||||
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||||
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
|
||||
}
|
||||
for e in exceptions
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_exception_summary(
|
||||
task_id: Optional[int] = Query(None, description="任务ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> Dict[str, Any]:
|
||||
"""获取异常汇总统计"""
|
||||
service = ExceptionService(db)
|
||||
summary = await service.get_exception_summary(
|
||||
task_id=task_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
@router.get("/{exception_id}")
|
||||
async def get_exception_detail(
|
||||
exception_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> Dict[str, Any]:
|
||||
"""获取异常详情"""
|
||||
service = ExceptionService(db)
|
||||
exception = await service.get_exception(exception_id)
|
||||
|
||||
if not exception:
|
||||
raise HTTPException(status_code=404, detail="异常不存在")
|
||||
|
||||
if exception.company_id != company_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该异常")
|
||||
|
||||
return {
|
||||
"id": exception.id,
|
||||
"task_id": exception.task_id,
|
||||
"exception_type": exception.exception_type,
|
||||
"severity": exception.severity,
|
||||
"status": exception.status,
|
||||
"employee_id": exception.employee_id,
|
||||
"employee_name": exception.employee_name,
|
||||
"description": exception.description,
|
||||
"detail": exception.detail,
|
||||
"salary_amount": exception.salary_amount,
|
||||
"social_security_amount": exception.social_security_amount,
|
||||
"tax_amount": exception.tax_amount,
|
||||
"bank_amount": exception.bank_amount,
|
||||
"difference_amount": exception.difference_amount,
|
||||
"suggested_action": exception.suggested_action,
|
||||
"ai_suggestion": exception.ai_suggestion,
|
||||
"handling_note": exception.handling_note,
|
||||
"handled_by": exception.handled_by,
|
||||
"handler_name": exception.handler.full_name if exception.handler else None,
|
||||
"handled_at": exception.handled_at.isoformat() if exception.handled_at else None,
|
||||
"created_at": exception.created_at.isoformat() if exception.created_at else None,
|
||||
"updated_at": exception.updated_at.isoformat() if exception.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{exception_id}")
|
||||
async def update_exception_status(
|
||||
exception_id: int,
|
||||
status: Optional[str] = Body(None),
|
||||
handling_note: Optional[str] = Body(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
):
|
||||
"""更新异常状态"""
|
||||
service = ExceptionService(db)
|
||||
|
||||
# 检查权限
|
||||
exception = await service.get_exception(exception_id)
|
||||
if not exception:
|
||||
raise HTTPException(status_code=404, detail="异常不存在")
|
||||
if exception.company_id != company_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该异常")
|
||||
|
||||
updated = await service.update_exception(
|
||||
exception_id=exception_id,
|
||||
status=status,
|
||||
handling_note=handling_note,
|
||||
)
|
||||
|
||||
return {"success": True, "data": updated}
|
||||
|
||||
|
||||
@router.post("/batch-update")
|
||||
async def batch_update_exceptions(
|
||||
exception_ids: List[int] = Body(..., description="异常ID列表"),
|
||||
status: str = Body(..., description="新状态"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> Dict[str, Any]:
|
||||
"""批量更新异常状态"""
|
||||
service = ExceptionService(db)
|
||||
|
||||
# 检查权限
|
||||
query = select(ExceptionItem).where(ExceptionItem.id.in_(exception_ids))
|
||||
result = await db.execute(query)
|
||||
exceptions = result.scalars().all()
|
||||
|
||||
# 验证权限
|
||||
for e in exceptions:
|
||||
if e.company_id != company_id:
|
||||
raise HTTPException(status_code=403, detail="存在无权访问的异常")
|
||||
|
||||
count = await service.batch_update_status(exception_ids, status)
|
||||
|
||||
return {"success": True, "updated_count": count}
|
||||
|
||||
|
||||
@router.post("/batch-resolve")
|
||||
async def batch_resolve(
|
||||
exception_ids: List[int] = Body(..., description="异常ID列表"),
|
||||
handling_note: str = Body(..., description="处理备注"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> Dict[str, Any]:
|
||||
"""批量处理异常(标记为已解决)"""
|
||||
service = ExceptionService(db)
|
||||
|
||||
# 检查权限
|
||||
query = select(ExceptionItem).where(ExceptionItem.id.in_(exception_ids))
|
||||
result = await db.execute(query)
|
||||
exceptions = result.scalars().all()
|
||||
|
||||
# 验证权限
|
||||
for e in exceptions:
|
||||
if e.company_id != company_id:
|
||||
raise HTTPException(status_code=403, detail="存在无权访问的异常")
|
||||
|
||||
count = await service.batch_resolve(exception_ids, handling_note)
|
||||
|
||||
return {"success": True, "resolved_count": count}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
导出 API
|
||||
|
||||
提供对账结果导出、金蝶凭证导出等接口
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant import get_current_company_id
|
||||
from app.services.export_service import export_task_result, export_kingdee_voucher
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/exports", tags=["导出"])
|
||||
|
||||
|
||||
@router.get("/task/{task_id}")
|
||||
async def export_task(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
):
|
||||
"""
|
||||
导出任务对账结果
|
||||
|
||||
返回 Excel 文件,包含:
|
||||
- 概览
|
||||
- 异常列表
|
||||
- 异常统计
|
||||
"""
|
||||
try:
|
||||
data = await export_task_result(db, task_id)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([data]),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=对账结果_{task_id}.xlsx"
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ImportError as e:
|
||||
raise HTTPException(status_code=500, detail="导出功能未启用,请安装 openpyxl")
|
||||
|
||||
|
||||
@router.get("/kingdee/{task_id}")
|
||||
async def export_kingdee(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
):
|
||||
"""
|
||||
导出金蝶凭证
|
||||
|
||||
返回 Excel 文件,可导入到金蝶系统
|
||||
"""
|
||||
try:
|
||||
data = await export_kingdee_voucher(db, task_id)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([data]),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=金蝶凭证_{task_id}.xlsx"
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ImportError as e:
|
||||
raise HTTPException(status_code=500, detail="导出功能未启用,请安装 openpyxl")
|
||||
|
||||
|
||||
@router.post("/task/{task_id}/async")
|
||||
async def export_task_async(
|
||||
task_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
):
|
||||
"""
|
||||
异步导出任务对账结果
|
||||
|
||||
适用于大数据量导出的场景,返回导出任务ID
|
||||
"""
|
||||
# TODO: 实现异步导出任务
|
||||
return {
|
||||
"success": True,
|
||||
"message": "异步导出任务已创建",
|
||||
"task_id": task_id,
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
字段映射 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": "规则已删除"}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
对账执行 API
|
||||
|
||||
执行对账任务
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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.reconciliation_task import ReconciliationTask
|
||||
from app.services.reconciliation.engine import run_reconciliation
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/reconciliation", tags=["对账执行"])
|
||||
|
||||
|
||||
@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]:
|
||||
"""
|
||||
执行对账
|
||||
|
||||
触发对账引擎,处理上传的数据并返回结果
|
||||
"""
|
||||
# 获取任务
|
||||
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:
|
||||
# 获取上传的数据
|
||||
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
|
||||
|
||||
# 执行对账
|
||||
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)}")
|
||||
|
||||
|
||||
@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]:
|
||||
"""
|
||||
获取对账结果
|
||||
|
||||
返回已执行的对账结果汇总
|
||||
"""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@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]:
|
||||
"""
|
||||
重试对账
|
||||
|
||||
重新执行已完成的或失败的对账任务
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
对账任务 API
|
||||
|
||||
管理对账任务的生命周期
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import Float
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant import get_current_company_id
|
||||
from app.models.reconciliation_task import ReconciliationTask
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["对账任务"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Dict[str, Any]])
|
||||
async def list_tasks(
|
||||
period: Optional[str] = Query(None, description="筛选月份,格式: 2026-04"),
|
||||
status: Optional[str] = Query(None, description="筛选状态"),
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(50, ge=1, le=100, description="返回记录数"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: Optional[int] = Depends(get_current_company_id),
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取对账任务列表
|
||||
|
||||
支持按月份和状态筛选
|
||||
"""
|
||||
# 构建查询
|
||||
query = select(ReconciliationTask).where(
|
||||
ReconciliationTask.company_id == company_id
|
||||
)
|
||||
|
||||
if period:
|
||||
query = query.where(ReconciliationTask.period == period)
|
||||
|
||||
if status:
|
||||
query = query.where(ReconciliationTask.status == status)
|
||||
|
||||
# 按创建时间倒序
|
||||
query = query.order_by(ReconciliationTask.created_at.desc())
|
||||
|
||||
# 分页
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": task.id,
|
||||
"period": task.period,
|
||||
"status": task.status,
|
||||
"total_employees": task.total_employees,
|
||||
"matched_count": task.matched_count,
|
||||
"exception_count": task.exception_count,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats", response_model=Dict[str, Any])
|
||||
async def get_task_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取任务统计信息
|
||||
|
||||
返回本月任务数、待处理异常、已完成、匹配率等统计
|
||||
"""
|
||||
from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
# 计算本月时间范围
|
||||
now = datetime.now()
|
||||
first_day_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
next_month = first_day_of_month + relativedelta(months=1)
|
||||
current_period = now.strftime("%Y-%m")
|
||||
|
||||
# 统计本月任务
|
||||
from sqlalchemy import case as sql_case
|
||||
|
||||
query = select(
|
||||
func.count(ReconciliationTask.id).label("total"),
|
||||
func.sum(
|
||||
sql_case(
|
||||
(ReconciliationTask.status == "COMPLETED", 1),
|
||||
else_=0
|
||||
)
|
||||
).label("completed"),
|
||||
func.sum(
|
||||
sql_case(
|
||||
(ReconciliationTask.exception_count > 0, 1),
|
||||
else_=0
|
||||
)
|
||||
).label("with_exceptions"),
|
||||
).where(
|
||||
ReconciliationTask.company_id == company_id,
|
||||
ReconciliationTask.period == current_period,
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
row = result.one()
|
||||
|
||||
total_tasks = row.total or 0
|
||||
completed_tasks = row.completed or 0
|
||||
|
||||
# 获取待处理异常数
|
||||
from app.models.exception_item import ExceptionItem
|
||||
exception_query = select(
|
||||
func.count(ExceptionItem.id)
|
||||
).where(
|
||||
ExceptionItem.company_id == company_id,
|
||||
ExceptionItem.status == "PENDING",
|
||||
)
|
||||
exception_result = await db.execute(exception_query)
|
||||
pending_exceptions = exception_result.scalar() or 0
|
||||
|
||||
# 计算匹配率
|
||||
match_rate = 0.0
|
||||
if total_tasks > 0:
|
||||
match_query = select(
|
||||
func.avg(
|
||||
func.cast(ReconciliationTask.matched_count * 100.0 / func.nullif(ReconciliationTask.total_employees, 0), Float)
|
||||
)
|
||||
).where(
|
||||
ReconciliationTask.company_id == company_id,
|
||||
ReconciliationTask.period == current_period,
|
||||
ReconciliationTask.status == "COMPLETED",
|
||||
)
|
||||
match_result = await db.execute(match_query)
|
||||
match_rate = match_result.scalar() or 0.0
|
||||
|
||||
return {
|
||||
"monthly_tasks": total_tasks,
|
||||
"pending_exceptions": pending_exceptions,
|
||||
"completed_tasks": completed_tasks,
|
||||
"match_rate": round(match_rate, 1),
|
||||
"current_period": current_period,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=Dict[str, Any])
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
company_id: int = Depends(get_current_company_id),
|
||||
) -> 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="无权访问该任务")
|
||||
|
||||
return {
|
||||
"id": task.id,
|
||||
"period": task.period,
|
||||
"status": task.status,
|
||||
"total_employees": task.total_employees,
|
||||
"matched_count": task.matched_count,
|
||||
"exception_count": task.exception_count,
|
||||
"file_ids": task.file_ids,
|
||||
"reconciliation_result": task.reconciliation_result,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"error_message": task.error_message,
|
||||
}
|
||||
Reference in New Issue
Block a user