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:
freedakgmail
2026-07-07 09:04:47 +08:00
parent 8487f6eadf
commit 33b4c734aa
117 changed files with 22070 additions and 295 deletions
+71
View File
@@ -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)
+219
View File
@@ -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}
+95
View File
@@ -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,
}
+288
View File
@@ -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": "规则已删除"}
+141
View File
@@ -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)
+181
View File
@@ -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,
}