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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-25 19:32:55 +08:00
1 parent 7a26e5cf58
commit 6092b685de
3 files changed
+263 -4

No files matched your search

+42 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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