Files
X-Financial/server/src/app/api/v1/endpoints/reimbursements.py
caoxiaozhu 88ff04bef8 feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
2026-05-22 16:00:19 +08:00

620 lines
21 KiB
Python

from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.reimbursement import (
ExpenseClaimAttachmentActionResponse,
ExpenseClaimActionResponse,
ExpenseClaimAttachmentRead,
ExpenseClaimApprovalPayload,
ExpenseClaimItemCreate,
ExpenseClaimItemActionResponse,
ExpenseClaimItemUpdate,
ExpenseClaimRead,
ExpenseClaimReturnPayload,
ExpenseClaimUpdate,
ReimbursementCreate,
ReimbursementRead,
TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"",
response_model=list[ReimbursementRead],
summary="查询报销申请列表",
description="返回当前系统中的报销申请列表。",
)
def list_reimbursements(db: DbSession) -> list[ReimbursementRead]:
return ReimbursementService(db).list_reimbursements()
@router.post(
"",
response_model=ReimbursementRead,
status_code=status.HTTP_201_CREATED,
summary="创建报销申请",
description="创建一条新的报销申请记录,初始状态为 `draft`。",
)
def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> ReimbursementRead:
return ReimbursementService(db).create_reimbursement(payload)
@router.post(
"/travel-calculator",
response_model=TravelReimbursementCalculatorResponse,
summary="差旅报销标准测算",
description="根据规则中心的差旅报销表、当前员工职级、出差天数与地点测算住宿和补贴参考金额。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "测算入参或规则匹配失败。",
}
},
)
def calculate_travel_reimbursement(
payload: TravelReimbursementCalculatorRequest,
db: DbSession,
current_user: CurrentUser,
) -> TravelReimbursementCalculatorResponse:
try:
return TravelReimbursementCalculatorService(db).calculate(payload, current_user)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
@router.get(
"/claims",
response_model=list[ExpenseClaimRead],
summary="查询个人报销单列表",
description="返回当前登录用户可见的真实个人报销单据列表。",
)
def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
return ExpenseClaimService(db).list_claims(current_user)
@router.get(
"/claims/approvals",
response_model=list[ExpenseClaimRead],
summary="查询当前用户审批待办报销单列表",
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
)
def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
return ExpenseClaimService(db).list_approval_claims(current_user)
@router.get(
"/claims/archives",
response_model=list[ExpenseClaimRead],
summary="查询归档中心报销单列表",
description="返回公司已归档入账的报销单据,供财务与审计角色集中查阅。",
)
def list_archived_expense_claims(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
return ExpenseClaimService(db).list_archived_claims(current_user)
@router.get(
"/claims/{claim_id}",
response_model=ExpenseClaimRead,
summary="读取个人报销单详情",
description="根据报销单主键读取真实报销详情与费用明细。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
}
},
)
def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
claim = ExpenseClaimService(db).get_claim(claim_id, current_user)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.patch(
"/claims/{claim_id}",
response_model=ExpenseClaimRead,
summary="更新草稿报销单",
description="更新草稿待提交报销单的主说明等草稿字段。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "报销单状态不允许更新。",
},
},
)
def update_expense_claim(
claim_id: str,
payload: ExpenseClaimUpdate,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.update_claim(
claim_id=claim_id,
payload=payload,
current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.patch(
"/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead,
summary="更新草稿费用明细",
description="更新草稿报销单中的单条费用明细。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单或费用明细不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "草稿状态校验失败或字段校验失败。",
},
},
)
def update_expense_claim_item(
claim_id: str,
item_id: str,
payload: ExpenseClaimItemUpdate,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.update_claim_item(
claim_id=claim_id,
item_id=item_id,
payload=payload,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/items",
response_model=ExpenseClaimRead,
summary="新增草稿费用明细",
description="在草稿报销单中新增一条费用明细,供用户继续补充附件与字段。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "草稿状态校验失败或字段校验失败。",
},
},
)
def create_expense_claim_item(
claim_id: str,
payload: ExpenseClaimItemCreate | None,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.create_claim_item(
claim_id=claim_id,
payload=payload,
current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.delete(
"/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimItemActionResponse,
summary="删除草稿费用明细",
description="删除草稿报销单中的一条费用明细,并同步清理该行已上传的附件与 OCR 元数据。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单或费用明细不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "草稿状态校验失败。",
},
},
)
def delete_expense_claim_item(
claim_id: str,
item_id: str,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimItemActionResponse:
service = ExpenseClaimService(db)
try:
payload = service.delete_claim_item(
claim_id=claim_id,
item_id=item_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return ExpenseClaimItemActionResponse(**payload)
@router.post(
"/claims/{claim_id}/items/{item_id}/attachment",
response_model=ExpenseClaimAttachmentActionResponse,
summary="上传费用明细附件",
description="为草稿费用明细上传真实附件文件,并返回附件元信息与 AI 校验结果。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单或费用明细不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "草稿状态校验失败或文件不合法。",
},
},
)
async def upload_expense_claim_item_attachment(
claim_id: str,
item_id: str,
file: Annotated[UploadFile, File(description="待上传的附件文件。")],
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimAttachmentActionResponse:
service = ExpenseClaimService(db)
try:
payload = service.upload_claim_item_attachment(
claim_id=claim_id,
item_id=item_id,
filename=str(file.filename or "attachment.bin"),
content=await file.read(),
media_type=file.content_type,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return ExpenseClaimAttachmentActionResponse(**payload)
@router.get(
"/claims/{claim_id}/items/{item_id}/attachment/meta",
response_model=ExpenseClaimAttachmentRead,
summary="读取费用明细附件元信息",
description="返回当前费用明细已上传附件的文件信息与 AI 校验结果。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单、费用明细或附件不存在。",
},
},
)
def get_expense_claim_item_attachment_meta(
claim_id: str,
item_id: str,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimAttachmentRead:
service = ExpenseClaimService(db)
try:
payload = service.get_claim_item_attachment_meta(
claim_id=claim_id,
item_id=item_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FileNotFoundError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return ExpenseClaimAttachmentRead(**payload)
@router.get(
"/claims/{claim_id}/items/{item_id}/attachment/preview",
response_class=FileResponse,
summary="读取费用明细附件预览资源",
description="优先返回票据预览图;若无单独预览图,则回退到原附件内容。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单、费用明细或附件预览不存在。",
},
},
)
def get_expense_claim_item_attachment_preview(
claim_id: str,
item_id: str,
db: DbSession,
current_user: CurrentUser,
) -> FileResponse:
service = ExpenseClaimService(db)
try:
payload = service.get_claim_item_attachment_preview_content(
claim_id=claim_id,
item_id=item_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FileNotFoundError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
file_path, media_type, filename = payload
return FileResponse(file_path, media_type=media_type, filename=filename)
@router.get(
"/claims/{claim_id}/items/{item_id}/attachment",
response_class=FileResponse,
summary="读取费用明细附件内容",
description="用于详情页预览当前费用明细已上传的附件文件。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单、费用明细或附件不存在。",
},
},
)
def get_expense_claim_item_attachment(
claim_id: str,
item_id: str,
db: DbSession,
current_user: CurrentUser,
) -> FileResponse:
service = ExpenseClaimService(db)
try:
payload = service.get_claim_item_attachment_content(
claim_id=claim_id,
item_id=item_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FileNotFoundError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
file_path, media_type, filename = payload
return FileResponse(file_path, media_type=media_type, filename=filename)
@router.delete(
"/claims/{claim_id}/items/{item_id}/attachment",
response_model=ExpenseClaimAttachmentActionResponse,
summary="删除费用明细附件",
description="删除草稿费用明细当前已上传的附件文件,并清空票据关联。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单、费用明细或附件不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前状态不允许删除附件。",
},
},
)
def delete_expense_claim_item_attachment(
claim_id: str,
item_id: str,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimAttachmentActionResponse:
service = ExpenseClaimService(db)
try:
payload = service.delete_claim_item_attachment(
claim_id=claim_id,
item_id=item_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FileNotFoundError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return ExpenseClaimAttachmentActionResponse(**payload)
@router.post(
"/claims/{claim_id}/submit",
response_model=ExpenseClaimRead,
summary="提交个人报销草稿",
description="校验草稿信息完整性后,将报销单提交到审批流程。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "草稿信息不完整或状态不允许提交。",
},
},
)
def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.submit_claim(claim_id, current_user)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/return",
response_model=ExpenseClaimRead,
summary="退回报销单",
description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许退回。",
},
},
)
def return_expense_claim(
claim_id: str,
payload: ExpenseClaimReturnPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/approve",
response_model=ExpenseClaimRead,
summary="审批通过报销单",
description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许审批通过。",
},
},
)
def approve_expense_claim(
claim_id: str,
payload: ExpenseClaimApprovalPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.delete(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
summary="删除报销单",
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许删除。",
},
},
)
def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimActionResponse:
service = ExpenseClaimService(db)
try:
claim = service.delete_claim(claim_id, current_user)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return ExpenseClaimActionResponse(
message=f"{claim.claim_no} 报销单已删除。",
claim_id=claim.id,
status="deleted",
)
@router.get(
"/{request_id}",
response_model=ReimbursementRead,
summary="读取报销申请详情",
description="根据报销申请主键读取单据详情。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销申请不存在。",
}
},
)
def get_reimbursement(request_id: str, db: DbSession) -> ReimbursementRead:
request = ReimbursementService(db).get_reimbursement(request_id)
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Request not found")
return request