| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- from __future__ import annotations
- from decimal import Decimal
- from typing import Any, Dict, Iterable, List, Optional
- from fastapi import HTTPException, status
- from sqlalchemy.orm import Session
- from app.core.bx_auth import BxUserContext
- from app.core.utils import generate_uuid
- from app.models.bx_models import BxInvoiceLedger, BxInvoiceWalletSummary
- ENTRY_CREDIT_UPLOAD = "credit_upload"
- ENTRY_DEBIT_DELETE = "debit_delete"
- ENTRY_DEBIT_REIMBURSE = "debit_reimburse"
- ENTRY_CREDIT_RELEASE = "credit_release"
- _CREDIT_TYPES = {ENTRY_CREDIT_UPLOAD, ENTRY_CREDIT_RELEASE}
- _DEBIT_TYPES = {ENTRY_DEBIT_DELETE, ENTRY_DEBIT_REIMBURSE}
- def summarize_entries(rows: Iterable[Dict[str, Any]]) -> Dict[str, Decimal]:
- credit_upload = Decimal("0")
- debit_delete = Decimal("0")
- debit_reimburse = Decimal("0")
- credit_release = Decimal("0")
- for row in rows:
- amt = Decimal(str(row["amount"]))
- entry_type = row["entry_type"]
- if entry_type == ENTRY_CREDIT_UPLOAD:
- credit_upload += amt
- elif entry_type == ENTRY_DEBIT_DELETE:
- debit_delete += amt
- elif entry_type == ENTRY_DEBIT_REIMBURSE:
- debit_reimburse += amt
- elif entry_type == ENTRY_CREDIT_RELEASE:
- credit_release += amt
- total = credit_upload - debit_delete
- used = debit_reimburse - credit_release
- return {
- "total_amount": total,
- "used_amount": used,
- "balance": total - used,
- }
- def should_skip_release(*, has_debit: bool, has_release: bool) -> bool:
- if not has_debit:
- return True
- return has_release
- class InvoiceLedgerService:
- @staticmethod
- def list_entries(
- db: Session, tenant_no: str, owner_user_id: str
- ) -> List[BxInvoiceLedger]:
- return (
- db.query(BxInvoiceLedger)
- .filter(
- BxInvoiceLedger.tenant_no == tenant_no,
- BxInvoiceLedger.owner_user_id == owner_user_id,
- BxInvoiceLedger.del_flag == 0,
- )
- .order_by(BxInvoiceLedger.create_time.asc())
- .all()
- )
- @staticmethod
- def calc_summary(db: Session, tenant_no: str, owner_user_id: str) -> Dict[str, Decimal]:
- rows = InvoiceLedgerService.list_entries(db, tenant_no, owner_user_id)
- payload = [{"entry_type": row.entry_type, "amount": row.amount} for row in rows]
- return summarize_entries(payload)
- @staticmethod
- def _lock_summary(
- db: Session,
- tenant_no: str,
- owner_user_id: str,
- *,
- owner_user_name: str = "",
- user_id: str = "",
- dept_id: str = "",
- ) -> BxInvoiceWalletSummary:
- row = (
- db.query(BxInvoiceWalletSummary)
- .filter(
- BxInvoiceWalletSummary.tenant_no == tenant_no,
- BxInvoiceWalletSummary.owner_user_id == owner_user_id,
- BxInvoiceWalletSummary.del_flag == 0,
- )
- .with_for_update()
- .first()
- )
- if row:
- return row
- row = BxInvoiceWalletSummary(
- id=generate_uuid(),
- tenant_no=tenant_no,
- owner_user_id=owner_user_id,
- owner_user_name=owner_user_name or "",
- total_amount=Decimal("0"),
- used_amount=Decimal("0"),
- balance=Decimal("0"),
- invoice_count=0,
- version=0,
- create_by=user_id or None,
- create_dept_id=dept_id or "",
- )
- db.add(row)
- db.flush()
- return (
- db.query(BxInvoiceWalletSummary)
- .filter(
- BxInvoiceWalletSummary.id == row.id,
- BxInvoiceWalletSummary.del_flag == 0,
- )
- .with_for_update()
- .one()
- )
- @staticmethod
- def refresh_summary(
- db: Session,
- tenant_no: str,
- owner_user_id: str,
- owner_user_name: str,
- invoice_count: int,
- user_id: str,
- dept_id: str,
- ) -> BxInvoiceWalletSummary:
- totals = InvoiceLedgerService.calc_summary(db, tenant_no, owner_user_id)
- row = (
- db.query(BxInvoiceWalletSummary)
- .filter(
- BxInvoiceWalletSummary.tenant_no == tenant_no,
- BxInvoiceWalletSummary.owner_user_id == owner_user_id,
- BxInvoiceWalletSummary.del_flag == 0,
- )
- .first()
- )
- if row:
- row.total_amount = totals["total_amount"]
- row.used_amount = totals["used_amount"]
- row.balance = totals["balance"]
- row.invoice_count = invoice_count
- if owner_user_name:
- row.owner_user_name = owner_user_name
- row.version = (row.version or 0) + 1
- row.update_by = user_id or None
- else:
- row = BxInvoiceWalletSummary(
- id=generate_uuid(),
- tenant_no=tenant_no,
- owner_user_id=owner_user_id,
- owner_user_name=owner_user_name or "",
- total_amount=totals["total_amount"],
- used_amount=totals["used_amount"],
- balance=totals["balance"],
- invoice_count=invoice_count,
- version=1,
- create_by=user_id or None,
- create_dept_id=dept_id or "",
- )
- db.add(row)
- db.flush()
- return row
- @staticmethod
- def add_entry(
- db: Session,
- *,
- tenant_no: str,
- owner_user_id: str,
- entry_type: str,
- amount: Decimal,
- user: BxUserContext,
- invoice_id: Optional[str] = None,
- reimburse_id: Optional[str] = None,
- ref_ledger_id: Optional[str] = None,
- remark: Optional[str] = None,
- ) -> BxInvoiceLedger:
- amt = Decimal(str(amount))
- if amt <= 0:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额必须大于0")
- if entry_type not in _CREDIT_TYPES | _DEBIT_TYPES:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的流水类型")
- row = BxInvoiceLedger(
- id=generate_uuid(),
- tenant_no=tenant_no,
- owner_user_id=owner_user_id,
- entry_type=entry_type,
- amount=amt,
- invoice_id=invoice_id,
- reimburse_id=reimburse_id,
- ref_ledger_id=ref_ledger_id,
- remark=remark,
- create_by=user.id,
- create_dept_id=user.dept_id or "",
- )
- db.add(row)
- db.flush()
- return row
- @staticmethod
- def debit_reimburse(
- db: Session,
- *,
- tenant_no: str,
- owner_user_id: str,
- amount: Decimal,
- reimburse_id: str,
- user: BxUserContext,
- ) -> BxInvoiceLedger:
- amt = Decimal(str(amount))
- if amt <= 0:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额必须大于0")
- summary_row = InvoiceLedgerService._lock_summary(
- db,
- tenant_no,
- owner_user_id,
- owner_user_name=user.name,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- totals = InvoiceLedgerService.calc_summary(db, tenant_no, owner_user_id)
- balance = totals["balance"]
- if balance < amt:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"发票额度不足(余额 {balance},本次 {amt})",
- )
- entry = InvoiceLedgerService.add_entry(
- db,
- tenant_no=tenant_no,
- owner_user_id=owner_user_id,
- entry_type=ENTRY_DEBIT_REIMBURSE,
- amount=amt,
- user=user,
- reimburse_id=reimburse_id,
- )
- InvoiceLedgerService.refresh_summary(
- db,
- tenant_no,
- owner_user_id,
- summary_row.owner_user_name or user.name,
- summary_row.invoice_count,
- user.id,
- user.dept_id,
- )
- return entry
- @staticmethod
- def release_reimburse(
- db: Session,
- *,
- tenant_no: str,
- reimburse_id: str,
- user: BxUserContext,
- ) -> Optional[BxInvoiceLedger]:
- debit = (
- db.query(BxInvoiceLedger)
- .filter(
- BxInvoiceLedger.tenant_no == tenant_no,
- BxInvoiceLedger.reimburse_id == reimburse_id,
- BxInvoiceLedger.entry_type == ENTRY_DEBIT_REIMBURSE,
- BxInvoiceLedger.del_flag == 0,
- )
- .order_by(BxInvoiceLedger.create_time.desc())
- .first()
- )
- release = (
- db.query(BxInvoiceLedger)
- .filter(
- BxInvoiceLedger.tenant_no == tenant_no,
- BxInvoiceLedger.reimburse_id == reimburse_id,
- BxInvoiceLedger.entry_type == ENTRY_CREDIT_RELEASE,
- BxInvoiceLedger.del_flag == 0,
- )
- .first()
- )
- if should_skip_release(has_debit=debit is not None, has_release=release is not None):
- return None
- summary_row = InvoiceLedgerService._lock_summary(
- db,
- tenant_no,
- debit.owner_user_id,
- owner_user_name=user.name,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- entry = InvoiceLedgerService.add_entry(
- db,
- tenant_no=tenant_no,
- owner_user_id=debit.owner_user_id,
- entry_type=ENTRY_CREDIT_RELEASE,
- amount=Decimal(str(debit.amount)),
- user=user,
- reimburse_id=reimburse_id,
- ref_ledger_id=debit.id,
- remark="报销退回释放额度",
- )
- InvoiceLedgerService.refresh_summary(
- db,
- tenant_no,
- debit.owner_user_id,
- summary_row.owner_user_name or user.name,
- summary_row.invoice_count,
- user.id,
- user.dept_id,
- )
- return entry
- @staticmethod
- def assert_can_delete_invoice(
- db: Session,
- tenant_no: str,
- owner_user_id: str,
- invoice_amount: Decimal,
- ) -> None:
- amt = Decimal(str(invoice_amount))
- totals = InvoiceLedgerService.calc_summary(db, tenant_no, owner_user_id)
- if totals["balance"] - amt < 0:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"发票金额已部分或全部被报销占用,无法删除(余额 {totals['balance']},本次 {amt})",
- )
|