refactor(backend): update reimbursement and related services

- endpoints/reimbursements.py: update reimbursement API endpoint
- schemas/reimbursement.py: update reimbursement data schemas
- services/expense_claims.py: update expense claims service
- services/ontology.py: update ontology service
- services/user_agent.py: update user agent service
This commit is contained in:
caoxiaozhu
2026-05-13 06:45:04 +00:00
parent 4db5e8ec16
commit 6317fc0ccd
5 changed files with 1154 additions and 7 deletions

View File

@@ -2,13 +2,18 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
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,
ExpenseClaimItemCreate,
ExpenseClaimItemActionResponse,
ExpenseClaimItemUpdate,
ExpenseClaimRead,
ReimbursementCreate,
@@ -113,6 +118,238 @@ def update_expense_claim_item(
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",
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,