Просмотр исходного кода

feat(bx): debit invoice balance on submit; release on reject/withdraw

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
6092b685de

+ 42 - 2
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,

+ 18 - 0
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):

+ 203 - 2
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