invoice_ledger.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. from __future__ import annotations
  2. from decimal import Decimal
  3. from typing import Any, Dict, Iterable, List, Optional
  4. from fastapi import HTTPException, status
  5. from sqlalchemy.orm import Session
  6. from app.core.bx_auth import BxUserContext
  7. from app.core.utils import generate_uuid
  8. from app.models.bx_models import BxInvoiceLedger, BxInvoiceWalletSummary
  9. ENTRY_CREDIT_UPLOAD = "credit_upload"
  10. ENTRY_DEBIT_DELETE = "debit_delete"
  11. ENTRY_DEBIT_REIMBURSE = "debit_reimburse"
  12. ENTRY_CREDIT_RELEASE = "credit_release"
  13. _CREDIT_TYPES = {ENTRY_CREDIT_UPLOAD, ENTRY_CREDIT_RELEASE}
  14. _DEBIT_TYPES = {ENTRY_DEBIT_DELETE, ENTRY_DEBIT_REIMBURSE}
  15. def summarize_entries(rows: Iterable[Dict[str, Any]]) -> Dict[str, Decimal]:
  16. credit_upload = Decimal("0")
  17. debit_delete = Decimal("0")
  18. debit_reimburse = Decimal("0")
  19. credit_release = Decimal("0")
  20. for row in rows:
  21. amt = Decimal(str(row["amount"]))
  22. entry_type = row["entry_type"]
  23. if entry_type == ENTRY_CREDIT_UPLOAD:
  24. credit_upload += amt
  25. elif entry_type == ENTRY_DEBIT_DELETE:
  26. debit_delete += amt
  27. elif entry_type == ENTRY_DEBIT_REIMBURSE:
  28. debit_reimburse += amt
  29. elif entry_type == ENTRY_CREDIT_RELEASE:
  30. credit_release += amt
  31. total = credit_upload - debit_delete
  32. used = debit_reimburse - credit_release
  33. return {
  34. "total_amount": total,
  35. "used_amount": used,
  36. "balance": total - used,
  37. }
  38. def should_skip_release(*, has_debit: bool, has_release: bool) -> bool:
  39. if not has_debit:
  40. return True
  41. return has_release
  42. class InvoiceLedgerService:
  43. @staticmethod
  44. def list_entries(
  45. db: Session, tenant_no: str, owner_user_id: str
  46. ) -> List[BxInvoiceLedger]:
  47. return (
  48. db.query(BxInvoiceLedger)
  49. .filter(
  50. BxInvoiceLedger.tenant_no == tenant_no,
  51. BxInvoiceLedger.owner_user_id == owner_user_id,
  52. BxInvoiceLedger.del_flag == 0,
  53. )
  54. .order_by(BxInvoiceLedger.create_time.asc())
  55. .all()
  56. )
  57. @staticmethod
  58. def calc_summary(db: Session, tenant_no: str, owner_user_id: str) -> Dict[str, Decimal]:
  59. rows = InvoiceLedgerService.list_entries(db, tenant_no, owner_user_id)
  60. payload = [{"entry_type": row.entry_type, "amount": row.amount} for row in rows]
  61. return summarize_entries(payload)
  62. @staticmethod
  63. def _lock_summary(
  64. db: Session,
  65. tenant_no: str,
  66. owner_user_id: str,
  67. *,
  68. owner_user_name: str = "",
  69. user_id: str = "",
  70. dept_id: str = "",
  71. ) -> BxInvoiceWalletSummary:
  72. row = (
  73. db.query(BxInvoiceWalletSummary)
  74. .filter(
  75. BxInvoiceWalletSummary.tenant_no == tenant_no,
  76. BxInvoiceWalletSummary.owner_user_id == owner_user_id,
  77. BxInvoiceWalletSummary.del_flag == 0,
  78. )
  79. .with_for_update()
  80. .first()
  81. )
  82. if row:
  83. return row
  84. row = BxInvoiceWalletSummary(
  85. id=generate_uuid(),
  86. tenant_no=tenant_no,
  87. owner_user_id=owner_user_id,
  88. owner_user_name=owner_user_name or "",
  89. total_amount=Decimal("0"),
  90. used_amount=Decimal("0"),
  91. balance=Decimal("0"),
  92. invoice_count=0,
  93. version=0,
  94. create_by=user_id or None,
  95. create_dept_id=dept_id or "",
  96. )
  97. db.add(row)
  98. db.flush()
  99. return (
  100. db.query(BxInvoiceWalletSummary)
  101. .filter(
  102. BxInvoiceWalletSummary.id == row.id,
  103. BxInvoiceWalletSummary.del_flag == 0,
  104. )
  105. .with_for_update()
  106. .one()
  107. )
  108. @staticmethod
  109. def refresh_summary(
  110. db: Session,
  111. tenant_no: str,
  112. owner_user_id: str,
  113. owner_user_name: str,
  114. invoice_count: int,
  115. user_id: str,
  116. dept_id: str,
  117. ) -> BxInvoiceWalletSummary:
  118. totals = InvoiceLedgerService.calc_summary(db, tenant_no, owner_user_id)
  119. row = (
  120. db.query(BxInvoiceWalletSummary)
  121. .filter(
  122. BxInvoiceWalletSummary.tenant_no == tenant_no,
  123. BxInvoiceWalletSummary.owner_user_id == owner_user_id,
  124. BxInvoiceWalletSummary.del_flag == 0,
  125. )
  126. .first()
  127. )
  128. if row:
  129. row.total_amount = totals["total_amount"]
  130. row.used_amount = totals["used_amount"]
  131. row.balance = totals["balance"]
  132. row.invoice_count = invoice_count
  133. if owner_user_name:
  134. row.owner_user_name = owner_user_name
  135. row.version = (row.version or 0) + 1
  136. row.update_by = user_id or None
  137. else:
  138. row = BxInvoiceWalletSummary(
  139. id=generate_uuid(),
  140. tenant_no=tenant_no,
  141. owner_user_id=owner_user_id,
  142. owner_user_name=owner_user_name or "",
  143. total_amount=totals["total_amount"],
  144. used_amount=totals["used_amount"],
  145. balance=totals["balance"],
  146. invoice_count=invoice_count,
  147. version=1,
  148. create_by=user_id or None,
  149. create_dept_id=dept_id or "",
  150. )
  151. db.add(row)
  152. db.flush()
  153. return row
  154. @staticmethod
  155. def add_entry(
  156. db: Session,
  157. *,
  158. tenant_no: str,
  159. owner_user_id: str,
  160. entry_type: str,
  161. amount: Decimal,
  162. user: BxUserContext,
  163. invoice_id: Optional[str] = None,
  164. reimburse_id: Optional[str] = None,
  165. ref_ledger_id: Optional[str] = None,
  166. remark: Optional[str] = None,
  167. ) -> BxInvoiceLedger:
  168. amt = Decimal(str(amount))
  169. if amt <= 0:
  170. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额必须大于0")
  171. if entry_type not in _CREDIT_TYPES | _DEBIT_TYPES:
  172. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的流水类型")
  173. row = BxInvoiceLedger(
  174. id=generate_uuid(),
  175. tenant_no=tenant_no,
  176. owner_user_id=owner_user_id,
  177. entry_type=entry_type,
  178. amount=amt,
  179. invoice_id=invoice_id,
  180. reimburse_id=reimburse_id,
  181. ref_ledger_id=ref_ledger_id,
  182. remark=remark,
  183. create_by=user.id,
  184. create_dept_id=user.dept_id or "",
  185. )
  186. db.add(row)
  187. db.flush()
  188. return row
  189. @staticmethod
  190. def debit_reimburse(
  191. db: Session,
  192. *,
  193. tenant_no: str,
  194. owner_user_id: str,
  195. amount: Decimal,
  196. reimburse_id: str,
  197. user: BxUserContext,
  198. ) -> BxInvoiceLedger:
  199. amt = Decimal(str(amount))
  200. if amt <= 0:
  201. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额必须大于0")
  202. summary_row = InvoiceLedgerService._lock_summary(
  203. db,
  204. tenant_no,
  205. owner_user_id,
  206. owner_user_name=user.name,
  207. user_id=user.id,
  208. dept_id=user.dept_id,
  209. )
  210. totals = InvoiceLedgerService.calc_summary(db, tenant_no, owner_user_id)
  211. balance = totals["balance"]
  212. if balance < amt:
  213. raise HTTPException(
  214. status_code=status.HTTP_400_BAD_REQUEST,
  215. detail=f"发票额度不足(余额 {balance},本次 {amt})",
  216. )
  217. entry = InvoiceLedgerService.add_entry(
  218. db,
  219. tenant_no=tenant_no,
  220. owner_user_id=owner_user_id,
  221. entry_type=ENTRY_DEBIT_REIMBURSE,
  222. amount=amt,
  223. user=user,
  224. reimburse_id=reimburse_id,
  225. )
  226. InvoiceLedgerService.refresh_summary(
  227. db,
  228. tenant_no,
  229. owner_user_id,
  230. summary_row.owner_user_name or user.name,
  231. summary_row.invoice_count,
  232. user.id,
  233. user.dept_id,
  234. )
  235. return entry
  236. @staticmethod
  237. def release_reimburse(
  238. db: Session,
  239. *,
  240. tenant_no: str,
  241. reimburse_id: str,
  242. user: BxUserContext,
  243. ) -> Optional[BxInvoiceLedger]:
  244. debit = (
  245. db.query(BxInvoiceLedger)
  246. .filter(
  247. BxInvoiceLedger.tenant_no == tenant_no,
  248. BxInvoiceLedger.reimburse_id == reimburse_id,
  249. BxInvoiceLedger.entry_type == ENTRY_DEBIT_REIMBURSE,
  250. BxInvoiceLedger.del_flag == 0,
  251. )
  252. .order_by(BxInvoiceLedger.create_time.desc())
  253. .first()
  254. )
  255. release = (
  256. db.query(BxInvoiceLedger)
  257. .filter(
  258. BxInvoiceLedger.tenant_no == tenant_no,
  259. BxInvoiceLedger.reimburse_id == reimburse_id,
  260. BxInvoiceLedger.entry_type == ENTRY_CREDIT_RELEASE,
  261. BxInvoiceLedger.del_flag == 0,
  262. )
  263. .first()
  264. )
  265. if should_skip_release(has_debit=debit is not None, has_release=release is not None):
  266. return None
  267. summary_row = InvoiceLedgerService._lock_summary(
  268. db,
  269. tenant_no,
  270. debit.owner_user_id,
  271. owner_user_name=user.name,
  272. user_id=user.id,
  273. dept_id=user.dept_id,
  274. )
  275. entry = InvoiceLedgerService.add_entry(
  276. db,
  277. tenant_no=tenant_no,
  278. owner_user_id=debit.owner_user_id,
  279. entry_type=ENTRY_CREDIT_RELEASE,
  280. amount=Decimal(str(debit.amount)),
  281. user=user,
  282. reimburse_id=reimburse_id,
  283. ref_ledger_id=debit.id,
  284. remark="报销退回释放额度",
  285. )
  286. InvoiceLedgerService.refresh_summary(
  287. db,
  288. tenant_no,
  289. debit.owner_user_id,
  290. summary_row.owner_user_name or user.name,
  291. summary_row.invoice_count,
  292. user.id,
  293. user.dept_id,
  294. )
  295. return entry
  296. @staticmethod
  297. def assert_can_delete_invoice(
  298. db: Session,
  299. tenant_no: str,
  300. owner_user_id: str,
  301. invoice_amount: Decimal,
  302. ) -> None:
  303. amt = Decimal(str(invoice_amount))
  304. totals = InvoiceLedgerService.calc_summary(db, tenant_no, owner_user_id)
  305. if totals["balance"] - amt < 0:
  306. raise HTTPException(
  307. status_code=status.HTTP_400_BAD_REQUEST,
  308. detail=f"发票金额已部分或全部被报销占用,无法删除(余额 {totals['balance']},本次 {amt})",
  309. )