feat(bx): debit invoice balance on submit; release on reject/withdraw
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
7a26e5cf58
commit
6092b685de
3 files changed
+263
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user