diff --git a/backend/app/api/bx/reimburse.py b/backend/app/api/bx/reimburse.py index 9fc27c498..4dc59a13e 100644 --- a/backend/app/api/bx/reimburse.py +++ b/backend/app/api/bx/reimburse.py @@ -17,6 +17,7 @@ from app.schemas.bx.reimburse import ( ReimburseApprovalOut, ReimburseApproverPendingStatsOut, ReimburseMyStatsOut, + ReimburseSubmitBody, ) from app.services.bx.expense_item import ExpenseItemService from app.services.bx.reimburse import ReimburseService @@ -315,6 +316,15 @@ def create_reimburse( if wf: data["wf_id"] = wf.id reimburse = ReimburseService.create_with_details(db, data, details_data, user=user) + if body.invoice_ids is not None: + ReimburseService.sync_reimburse_invoices( + db, + reimburse.id, + body.invoice_ids, + tenant_no, + user, + applicant_id=user.id, + ) db.commit() db.refresh(reimburse) out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse) @@ -344,6 +354,15 @@ def update_reimburse( ) if not reimburse: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在") + if body.invoice_ids is not None: + ReimburseService.sync_reimburse_invoices( + db, + reimburse_id, + body.invoice_ids, + tenant_no, + user, + applicant_id=reimburse.applicant_id, + ) db.commit() db.refresh(reimburse) out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse) @@ -353,16 +372,19 @@ def update_reimburse( @router.post("/{reimburse_id}/submit", response_model=ApiResponse[ReimburseApplyOut]) def submit_reimburse( reimburse_id: str, + body: ReimburseSubmitBody = ReimburseSubmitBody(), db: Session = Depends(get_db), user: BxUserContext = Depends(get_bx_current_user), ): tenant_no = _tenant(db, user) - reimburse = ReimburseService.submit(db, reimburse_id, user) + reimburse = ReimburseService.submit( + db, reimburse_id, user, invoice_ids=body.invoice_ids + ) if not reimburse: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在") db.commit() db.refresh(reimburse) - return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse)) + return ApiResponse(data=ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)) @router.post("/{reimburse_id}/approve", response_model=ApiResponse[ReimburseApplyOut]) @@ -397,6 +419,24 @@ def reject_reimburse( return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse)) +@router.post("/{reimburse_id}/withdraw", response_model=ApiResponse[ReimburseApplyOut]) +def withdraw_reimburse( + reimburse_id: str, + db: Session = Depends(get_db), + user: BxUserContext = Depends(get_bx_current_user), +): + tenant_no = _tenant(db, user) + reimburse = ReimburseService.withdraw(db, reimburse_id, user) + if not reimburse: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="报销单不存在、非本人申请或当前状态不可撤回", + ) + db.commit() + db.refresh(reimburse) + return ApiResponse(data=ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)) + + @router.delete("/{reimburse_id}", response_model=ApiResponse[None]) def delete_reimburse( reimburse_id: str, diff --git a/backend/app/schemas/bx/reimburse.py b/backend/app/schemas/bx/reimburse.py index 1ff0434ba..bf48cbc72 100644 --- a/backend/app/schemas/bx/reimburse.py +++ b/backend/app/schemas/bx/reimburse.py @@ -38,6 +38,14 @@ class ReimburseDetailOut(BaseModel): remark: Optional[str] = None +class ReimburseInvoiceBriefOut(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel) + + id: str + amount: float + owner_user_name: Optional[str] = None + + class ReimburseApplyCreate(BaseModel): model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) @@ -45,6 +53,7 @@ class ReimburseApplyCreate(BaseModel): reimburse_date: Optional[str] = None details: List[ReimburseDetailCreate] = Field(..., min_length=1) remark: Optional[str] = None + invoice_ids: Optional[List[str]] = None @field_validator("details") @classmethod @@ -61,6 +70,13 @@ class ReimburseApplyUpdate(BaseModel): title: Optional[str] = None reimburse_date: Optional[str] = None remark: Optional[str] = None + invoice_ids: Optional[List[str]] = None + + +class ReimburseSubmitBody(BaseModel): + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + invoice_ids: Optional[List[str]] = None class ReimburseApplyOut(BaseModel): @@ -89,6 +105,8 @@ class ReimburseApplyOut(BaseModel): offset_advance_id: Optional[str] = None offset_amount: Optional[float] = None details: Optional[List[ReimburseDetailOut]] = None + invoice_ids: Optional[List[str]] = None + invoices: Optional[List[ReimburseInvoiceBriefOut]] = None class ReimburseApprovalCreate(BaseModel): diff --git a/backend/app/services/bx/reimburse.py b/backend/app/services/bx/reimburse.py index befcaee3c..7d869ed55 100644 --- a/backend/app/services/bx/reimburse.py +++ b/backend/app/services/bx/reimburse.py @@ -4,6 +4,7 @@ from datetime import datetime from decimal import Decimal from typing import Any, Dict, List, Optional, Sequence, Tuple +from fastapi import HTTPException, status from sqlalchemy import and_, func, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -12,17 +13,21 @@ from app.core.bx_auth import BxUserContext from app.core.tenant_context import get_tenant_no from app.core.utils import generate_uuid from app.models.bx_models import ( + BxInvoice, + BxInvoiceLedger, BxReimburseApply, BxReimburseApproval, BxReimburseDetail, + BxReimburseInvoice, BxReimburseOffset, BxWorkflowDef, BxWorkflowNode, ) -from app.schemas.bx.reimburse import ReimburseApplyOut, ReimburseDetailOut +from app.schemas.bx.reimburse import ReimburseApplyOut, ReimburseDetailOut, ReimburseInvoiceBriefOut from app.services.bx.approval_cycle import filter_current_cycle_approvals from app.services.bx.attachment import AttachmentService from app.services.bx.base import BxCrudService +from app.services.bx.invoice_ledger import ENTRY_DEBIT_REIMBURSE, InvoiceLedgerService from app.services.bx_data_scope import apply_bx_scope from app.utils.bx_codegen import ( allocate_reimburse_title_seq, @@ -217,11 +222,180 @@ class ReimburseService: return rows, total @staticmethod - def submit(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxReimburseApply]: + def _has_debit_reimburse(db: Session, tenant_no: str, reimburse_id: str) -> bool: + return ( + db.query(BxInvoiceLedger.id) + .filter( + BxInvoiceLedger.tenant_no == tenant_no, + BxInvoiceLedger.reimburse_id == reimburse_id, + BxInvoiceLedger.entry_type == ENTRY_DEBIT_REIMBURSE, + BxInvoiceLedger.del_flag == 0, + ) + .first() + is not None + ) + + @staticmethod + def sync_reimburse_invoices( + db: Session, + reimburse_id: str, + invoice_ids: Optional[List[str]], + tenant_no: str, + user: BxUserContext, + *, + applicant_id: str, + ) -> None: + if invoice_ids is None: + return + normalized_ids: List[str] = [] + seen: set[str] = set() + for raw_id in invoice_ids: + invoice_id = str(raw_id or "").strip() + if not invoice_id or invoice_id in seen: + continue + seen.add(invoice_id) + normalized_ids.append(invoice_id) + + if normalized_ids: + invoices = ( + db.query(BxInvoice) + .filter( + BxInvoice.tenant_no == tenant_no, + BxInvoice.id.in_(normalized_ids), + BxInvoice.del_flag == 0, + ) + .all() + ) + invoice_map = {inv.id: inv for inv in invoices} + for invoice_id in normalized_ids: + invoice = invoice_map.get(invoice_id) + if not invoice: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"发票 {invoice_id} 不存在", + ) + if invoice.owner_user_id != applicant_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"发票 {invoice_id} 不属于申请人", + ) + + db.query(BxReimburseInvoice).filter( + BxReimburseInvoice.reimburse_id == reimburse_id, + BxReimburseInvoice.tenant_no == tenant_no, + BxReimburseInvoice.del_flag == 0, + ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False) + + for invoice_id in normalized_ids: + db.add( + BxReimburseInvoice( + id=generate_uuid(), + reimburse_id=reimburse_id, + invoice_id=invoice_id, + tenant_no=tenant_no, + create_by=user.id, + create_dept_id=user.dept_id or "", + ) + ) + db.flush() + + @staticmethod + def get_linked_invoice_ids(db: Session, reimburse_id: str, tenant_no: str) -> List[str]: + rows = ( + db.query(BxReimburseInvoice.invoice_id) + .filter( + BxReimburseInvoice.reimburse_id == reimburse_id, + BxReimburseInvoice.tenant_no == tenant_no, + BxReimburseInvoice.del_flag == 0, + ) + .order_by(BxReimburseInvoice.create_time.asc()) + .all() + ) + return [row[0] for row in rows] + + @staticmethod + def get_linked_invoices_brief( + db: Session, reimburse_id: str, tenant_no: str + ) -> List[ReimburseInvoiceBriefOut]: + rows = ( + db.query(BxReimburseInvoice, BxInvoice) + .join( + BxInvoice, + and_( + BxInvoice.id == BxReimburseInvoice.invoice_id, + BxInvoice.tenant_no == tenant_no, + BxInvoice.del_flag == 0, + ), + ) + .filter( + BxReimburseInvoice.reimburse_id == reimburse_id, + BxReimburseInvoice.tenant_no == tenant_no, + BxReimburseInvoice.del_flag == 0, + ) + .order_by(BxReimburseInvoice.create_time.asc()) + .all() + ) + return [ + ReimburseInvoiceBriefOut( + id=invoice.id, + amount=float(invoice.amount), + owner_user_name=invoice.owner_user_name, + ) + for _, invoice in rows + ] + + @staticmethod + def get_linked_invoice_ids_map( + db: Session, reimburse_ids: List[str], tenant_no: str + ) -> Dict[str, List[str]]: + if not reimburse_ids: + return {} + rows = ( + db.query(BxReimburseInvoice.reimburse_id, BxReimburseInvoice.invoice_id) + .filter( + BxReimburseInvoice.reimburse_id.in_(reimburse_ids), + BxReimburseInvoice.tenant_no == tenant_no, + BxReimburseInvoice.del_flag == 0, + ) + .order_by(BxReimburseInvoice.create_time.asc()) + .all() + ) + grouped: Dict[str, List[str]] = {} + for reimburse_id, invoice_id in rows: + grouped.setdefault(reimburse_id, []).append(invoice_id) + return grouped + + @staticmethod + def submit( + db: Session, + apply_id: str, + user: BxUserContext, + *, + invoice_ids: Optional[List[str]] = None, + ) -> Optional[BxReimburseApply]: tenant_no = ReimburseService._tenant_no(db, user) apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user) if not apply_obj: return None + if invoice_ids is not None: + ReimburseService.sync_reimburse_invoices( + db, + apply_id, + invoice_ids, + tenant_no, + user, + applicant_id=apply_obj.applicant_id, + ) + if not ReimburseService._has_debit_reimburse(db, tenant_no, apply_id): + amount = Decimal(str(apply_obj.amount or 0)) + InvoiceLedgerService.debit_reimburse( + db, + tenant_no=tenant_no, + owner_user_id=apply_obj.applicant_id, + amount=amount, + reimburse_id=apply_id, + user=user, + ) apply_obj.status = "pending" if not apply_obj.apply_code: apply_obj.apply_code = generate_apply_code(db, "BX", tenant_no) @@ -312,6 +486,28 @@ class ReimburseService: apply_obj.status = "rejected" apply_obj.current_node = None apply_obj.update_by = approver.id + InvoiceLedgerService.release_reimburse( + db, tenant_no=tenant_no, reimburse_id=apply_id, user=approver + ) + db.flush() + return apply_obj + + @staticmethod + def withdraw(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxReimburseApply]: + tenant_no = ReimburseService._tenant_no(db, user) + apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user) + if not apply_obj: + return None + if apply_obj.applicant_id != user.id: + return None + if apply_obj.status != "pending": + return None + apply_obj.status = "draft" + apply_obj.current_node = None + apply_obj.update_by = user.id + InvoiceLedgerService.release_reimburse( + db, tenant_no=tenant_no, reimburse_id=apply_id, user=user + ) db.flush() return apply_obj @@ -620,17 +816,20 @@ class ReimburseService: db, "reimburse_apply", ids, tenant_no ) details_map = ReimburseService.get_details_map_by_reimburse_ids(db, ids, tenant_no) + invoice_ids_map = ReimburseService.get_linked_invoice_ids_map(db, ids, tenant_no) out_list: List[ReimburseApplyOut] = [] for row in rows: out = ReimburseApplyOut.model_validate(row) inv, att = counts.get(row.id, (0, 0)) detail_rows = details_map.get(row.id, []) + linked_ids = invoice_ids_map.get(row.id, []) out_list.append( out.model_copy( update={ "invoice_count": inv, "attach_count": att, "details": [ReimburseDetailOut.model_validate(d) for d in detail_rows], + "invoice_ids": linked_ids, } ) ) @@ -649,6 +848,8 @@ class ReimburseService: out = ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse) detail_rows = ReimburseService.get_details(db, reimburse.id, tenant_no) out.details = [ReimburseDetailOut.model_validate(d) for d in detail_rows] + out.invoice_ids = ReimburseService.get_linked_invoice_ids(db, reimburse.id, tenant_no) + out.invoices = ReimburseService.get_linked_invoices_brief(db, reimburse.id, tenant_no) return out @staticmethod