Drop the hard balance check in debit_reimburse so occupancy can exceed uploaded invoices; balance may go negative until later top-ups. Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
10 KiB
Python
321 lines
10 KiB
Python
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,
|
||
)
|
||
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})",
|
||
)
|