- user_agent_application 新增草稿分支:识别'保存草稿/存草稿/先保存'等意图,复用可编辑记录更新或建草稿,提交前单据重叠仍拦截 - 草稿态返回单号与待提交提示,submit 仅在确认提交分支触发,避免草稿进入审批流 - reimbursements 删除接口文案与判定统一为系统管理员可删、申请人删自有草稿/退回单,申请单判定改用 is_application_claim_no - 更新财务规则表与 reimbursement 端点测试
891 lines
32 KiB
Python
891 lines
32 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, 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.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
|
from app.schemas.budget import BudgetClaimAnalysisRead
|
|
from app.schemas.common import ErrorResponse, PaginatedResponse
|
|
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
|
from app.schemas.reimbursement import (
|
|
ExpenseApplicationPreviewActionPayload,
|
|
ExpenseApplicationPreviewActionResponse,
|
|
ExpenseApplicationPreviewActionResult,
|
|
ExpenseClaimActionResponse,
|
|
ExpenseClaimApprovalPayload,
|
|
ExpenseClaimAttachmentActionResponse,
|
|
ExpenseClaimAttachmentRead,
|
|
ExpenseClaimItemActionResponse,
|
|
ExpenseClaimItemCreate,
|
|
ExpenseClaimItemUpdate,
|
|
ExpenseClaimRead,
|
|
ExpenseClaimReturnPayload,
|
|
ExpenseClaimStandardAdjustmentPayload,
|
|
ExpenseClaimUpdate,
|
|
ReimbursementCreate,
|
|
ReimbursementRead,
|
|
TravelReimbursementCalculatorRequest,
|
|
TravelReimbursementCalculatorResponse,
|
|
)
|
|
from app.schemas.user_agent import UserAgentRequest
|
|
from app.services.budget import BudgetService
|
|
from app.services.document_numbering import is_application_claim_no
|
|
from app.services.expense_claims import ExpenseClaimService
|
|
from app.services.reimbursement import ReimbursementService
|
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
|
from app.services.user_agent import UserAgentService
|
|
|
|
router = APIRouter()
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=list[ReimbursementRead] | PaginatedResponse[ReimbursementRead],
|
|
summary="查询报销申请列表",
|
|
description="返回当前系统中的报销申请列表。",
|
|
)
|
|
def list_reimbursements(
|
|
db: DbSession,
|
|
page: PageNumber = None,
|
|
page_size: PageSize = None,
|
|
) -> list[ReimbursementRead] | PaginatedResponse[ReimbursementRead]:
|
|
service = ReimbursementService(db)
|
|
if wants_page(page, page_size):
|
|
return page_payload(service.list_reimbursements_page(page=page, page_size=page_size))
|
|
return service.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
|
|
|
|
|
|
def _build_application_preview_action_context(
|
|
payload: ExpenseApplicationPreviewActionPayload,
|
|
current_user: CurrentUserContext,
|
|
) -> dict[str, object]:
|
|
context_json = dict(payload.context_json or {})
|
|
context_json.setdefault("session_type", "application")
|
|
context_json.setdefault("entry_source", "workbench_ai_inline")
|
|
context_json.setdefault("document_type", "expense_application")
|
|
context_json.setdefault("application_stage", "expense_application")
|
|
context_json.setdefault("role_codes", current_user.role_codes)
|
|
context_json.setdefault("is_admin", current_user.is_admin)
|
|
context_json.setdefault("username", current_user.username)
|
|
context_json.setdefault("name", current_user.name)
|
|
context_json.setdefault("department_name", current_user.department_name)
|
|
context_json.setdefault("position", current_user.position)
|
|
context_json.setdefault("grade", current_user.grade)
|
|
context_json.setdefault("employee_no", current_user.employee_no)
|
|
context_json.setdefault("manager_name", current_user.manager_name)
|
|
return context_json
|
|
|
|
|
|
@router.post(
|
|
"/application-preview-action",
|
|
response_model=ExpenseApplicationPreviewActionResponse,
|
|
summary="按申请核对预览快速保存或提交申请单",
|
|
description=(
|
|
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,"
|
|
"避免重复进入通用 Orchestrator 编排。"
|
|
),
|
|
)
|
|
def run_application_preview_action(
|
|
payload: ExpenseApplicationPreviewActionPayload,
|
|
db: DbSession,
|
|
current_user: CurrentUser,
|
|
) -> ExpenseApplicationPreviewActionResponse:
|
|
context_json = _build_application_preview_action_context(payload, current_user)
|
|
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
|
|
request = UserAgentRequest(
|
|
run_id=run_id,
|
|
user_id=payload.user_id or current_user.username or current_user.name,
|
|
message=payload.message,
|
|
ontology=OntologyParseResult(
|
|
scenario="expense",
|
|
intent="operate",
|
|
permission=OntologyPermission(
|
|
level="approval_required",
|
|
allowed=True,
|
|
reason="application preview fast action",
|
|
),
|
|
confidence=1.0,
|
|
run_id=run_id,
|
|
),
|
|
context_json=context_json,
|
|
tool_payload={},
|
|
selected_capability_codes=[],
|
|
degraded=False,
|
|
requires_confirmation=False,
|
|
)
|
|
try:
|
|
user_agent_response = UserAgentService(db)._build_expense_application_response(
|
|
request,
|
|
risk_flags=[],
|
|
)
|
|
except ValueError as error:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
|
|
|
return ExpenseApplicationPreviewActionResponse(
|
|
status="succeeded",
|
|
conversation_id=payload.conversation_id,
|
|
result=ExpenseApplicationPreviewActionResult(
|
|
message=user_agent_response.answer,
|
|
answer=user_agent_response.answer,
|
|
suggested_actions=[
|
|
action.model_dump(mode="json")
|
|
for action in user_agent_response.suggested_actions
|
|
],
|
|
risk_flags=user_agent_response.risk_flags,
|
|
requires_confirmation=user_agent_response.requires_confirmation,
|
|
draft_payload=(
|
|
user_agent_response.draft_payload.model_dump(mode="json")
|
|
if user_agent_response.draft_payload is not None
|
|
else None
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/claims",
|
|
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
|
summary="查询个人报销单列表",
|
|
description="返回当前登录用户可见的真实个人报销单据列表。",
|
|
)
|
|
def list_expense_claims(
|
|
db: DbSession,
|
|
current_user: CurrentUser,
|
|
page: PageNumber = None,
|
|
page_size: PageSize = None,
|
|
) -> list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead]:
|
|
service = ExpenseClaimService(db)
|
|
if wants_page(page, page_size):
|
|
return page_payload(service.list_claims_page(current_user, page=page, page_size=page_size))
|
|
return service.list_claims(current_user)
|
|
|
|
|
|
@router.get(
|
|
"/claims/approvals",
|
|
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
|
summary="查询当前用户审批待办报销单列表",
|
|
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
|
|
)
|
|
def list_expense_claim_approvals(
|
|
db: DbSession,
|
|
current_user: CurrentUser,
|
|
page: PageNumber = None,
|
|
page_size: PageSize = None,
|
|
) -> list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead]:
|
|
service = ExpenseClaimService(db)
|
|
if wants_page(page, page_size):
|
|
return page_payload(
|
|
service.list_approval_claims_page(current_user, page=page, page_size=page_size)
|
|
)
|
|
return service.list_approval_claims(current_user)
|
|
|
|
|
|
@router.get(
|
|
"/claims/archives",
|
|
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
|
summary="查询归档中心报销单列表",
|
|
description="返回公司已归档入账的报销单据,供财务与审计角色集中查阅。",
|
|
)
|
|
def list_archived_expense_claims(
|
|
db: DbSession,
|
|
current_user: CurrentUser,
|
|
page: PageNumber = None,
|
|
page_size: PageSize = None,
|
|
) -> list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead]:
|
|
service = ExpenseClaimService(db)
|
|
if wants_page(page, page_size):
|
|
return page_payload(
|
|
service.list_archived_claims_page(current_user, page=page, page_size=page_size)
|
|
)
|
|
return service.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.get(
|
|
"/claims/{claim_id}/budget-analysis",
|
|
response_model=BudgetClaimAnalysisRead,
|
|
summary="读取申请单预算分析",
|
|
description="根据当前预算池、申请金额和预算管控模型,返回费用申请的预算影响和评分建议。",
|
|
responses={
|
|
status.HTTP_404_NOT_FOUND: {
|
|
"model": ErrorResponse,
|
|
"description": "单据不存在。",
|
|
},
|
|
status.HTTP_403_FORBIDDEN: {
|
|
"model": ErrorResponse,
|
|
"description": "当前用户无权查看预算分析。",
|
|
},
|
|
},
|
|
)
|
|
def get_expense_claim_budget_analysis(
|
|
claim_id: str,
|
|
db: DbSession,
|
|
current_user: CurrentUser,
|
|
) -> BudgetClaimAnalysisRead:
|
|
service = ExpenseClaimService(db)
|
|
claim = service.get_claim(claim_id, current_user)
|
|
if claim is None:
|
|
if not service.can_view_budget_analysis(current_user):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
|
if not service.can_view_budget_analysis(current_user, claim):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。")
|
|
return BudgetService(db).analyze_claim_budget(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.post(
|
|
"/claims/{claim_id}/standard-adjustment",
|
|
response_model=ExpenseClaimRead,
|
|
summary="接受职级报销标准重算",
|
|
description="在草稿报销单存在中高风险但提交人不补充异常说明时,按职级可报销标准重算实际报销金额。",
|
|
responses={
|
|
status.HTTP_404_NOT_FOUND: {
|
|
"model": ErrorResponse,
|
|
"description": "报销单不存在。",
|
|
},
|
|
status.HTTP_400_BAD_REQUEST: {
|
|
"model": ErrorResponse,
|
|
"description": "报销单状态不允许重算或入参不合法。",
|
|
},
|
|
},
|
|
)
|
|
def accept_expense_claim_standard_adjustment(
|
|
claim_id: str,
|
|
payload: ExpenseClaimStandardAdjustmentPayload,
|
|
db: DbSession,
|
|
current_user: CurrentUser,
|
|
) -> ExpenseClaimRead:
|
|
service = ExpenseClaimService(db)
|
|
try:
|
|
claim = service.accept_standard_adjustment(
|
|
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,
|
|
receipt_id: Annotated[str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
|
|
) -> 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,
|
|
source_receipt_id=receipt_id or "",
|
|
)
|
|
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}/pre-review",
|
|
response_model=ExpenseClaimRead,
|
|
summary="执行报销单 AI 预审",
|
|
description="只执行 AI 预审并回写风险结果,不提交到审批流程。",
|
|
responses={
|
|
status.HTTP_404_NOT_FOUND: {
|
|
"model": ErrorResponse,
|
|
"description": "报销单不存在。",
|
|
},
|
|
status.HTTP_400_BAD_REQUEST: {
|
|
"model": ErrorResponse,
|
|
"description": "草稿信息不完整或状态不允许预审。",
|
|
},
|
|
},
|
|
)
|
|
def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
|
|
service = ExpenseClaimService(db)
|
|
try:
|
|
claim = service.pre_review_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}/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.post(
|
|
"/claims/{claim_id}/pay",
|
|
response_model=ExpenseClaimRead,
|
|
summary="确认报销单已付款",
|
|
description="财务人员或高级财务人员确认待付款报销单已完成付款。",
|
|
responses={
|
|
status.HTTP_404_NOT_FOUND: {
|
|
"model": ErrorResponse,
|
|
"description": "单据不存在。",
|
|
},
|
|
status.HTTP_400_BAD_REQUEST: {
|
|
"model": ErrorResponse,
|
|
"description": "当前用户或单据状态不允许确认付款。",
|
|
},
|
|
},
|
|
)
|
|
def pay_expense_claim(
|
|
claim_id: str,
|
|
db: DbSession,
|
|
current_user: CurrentUser,
|
|
) -> ExpenseClaimRead:
|
|
service = ExpenseClaimService(db)
|
|
try:
|
|
claim = service.mark_claim_paid(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.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")
|
|
|
|
claim_no = str(claim.claim_no or "").strip()
|
|
expense_type = str(claim.expense_type or "").strip().lower()
|
|
document_label = (
|
|
"申请单"
|
|
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
|
|
else "报销单"
|
|
)
|
|
return ExpenseClaimActionResponse(
|
|
message=f"{claim.claim_no} {document_label}已删除。",
|
|
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
|