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, 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/{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}/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