invoice.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. from __future__ import annotations
  2. import mimetypes
  3. from decimal import Decimal
  4. from typing import List, Optional, Sequence, Tuple
  5. from fastapi import HTTPException, status
  6. from sqlalchemy import or_
  7. from sqlalchemy.orm import Session
  8. from app.core.bx_auth import BxUserContext
  9. from app.core.menu_guard import SUPER_ADMIN_ROLE
  10. from app.core.tenant_context import get_tenant_no
  11. from app.models.bx_models import BxAttachment, BxInvoice, BxInvoiceLedger, BxInvoiceWalletSummary
  12. from app.models.models import SysUser
  13. from app.services.bx.attachment import AttachmentService
  14. from app.services.bx.base import BxCrudService
  15. from app.services.bx.invoice_ledger import (
  16. ENTRY_CREDIT_UPLOAD,
  17. ENTRY_DEBIT_DELETE,
  18. InvoiceLedgerService,
  19. )
  20. from app.services.bx.storage import upload_bx_file
  21. from app.services.web_auth_roles import iter_user_menu_perm_codes
  22. INVOICE_WALLET_ADMIN_PERM = "bx:invoiceWallet:admin"
  23. class InvoiceService:
  24. @staticmethod
  25. def _tenant_no(db: Session, user: BxUserContext) -> str:
  26. return get_tenant_no(db, user.to_tenant_user_dict())
  27. @staticmethod
  28. def is_wallet_admin(db: Session, user: BxUserContext) -> bool:
  29. if user.role == SUPER_ADMIN_ROLE:
  30. return True
  31. codes = iter_user_menu_perm_codes(db, user.id, user.tenant_no)
  32. return INVOICE_WALLET_ADMIN_PERM in codes
  33. @staticmethod
  34. def _require_wallet_admin(db: Session, user: BxUserContext) -> None:
  35. if not InvoiceService.is_wallet_admin(db, user):
  36. raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要发票夹管理权限")
  37. @staticmethod
  38. def _lookup_user_name(db: Session, user_id: str) -> str:
  39. row = (
  40. db.query(SysUser)
  41. .filter(SysUser.id == user_id, SysUser.del_flag == 0)
  42. .first()
  43. )
  44. if not row:
  45. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标用户不存在")
  46. return (row.nick_name or row.user_no or "").strip()
  47. @staticmethod
  48. def _resolve_owner(
  49. db: Session,
  50. user: BxUserContext,
  51. target_user_id: Optional[str],
  52. ) -> Tuple[str, str]:
  53. target = (target_user_id or "").strip()
  54. if not target or target == user.id:
  55. return user.id, user.name
  56. InvoiceService._require_wallet_admin(db, user)
  57. return target, InvoiceService._lookup_user_name(db, target)
  58. @staticmethod
  59. def _resolve_owner_for_query(
  60. db: Session,
  61. user: BxUserContext,
  62. user_id: Optional[str],
  63. ) -> str:
  64. owner_id = (user_id or "").strip() or user.id
  65. if owner_id != user.id:
  66. InvoiceService._require_wallet_admin(db, user)
  67. return owner_id
  68. @staticmethod
  69. def _count_invoices(db: Session, tenant_no: str, owner_user_id: str) -> int:
  70. return (
  71. db.query(BxInvoice)
  72. .filter(
  73. BxInvoice.tenant_no == tenant_no,
  74. BxInvoice.owner_user_id == owner_user_id,
  75. BxInvoice.del_flag == 0,
  76. )
  77. .count()
  78. )
  79. @staticmethod
  80. def get_mine_summary(db: Session, *, user: BxUserContext) -> dict:
  81. tenant_no = InvoiceService._tenant_no(db, user)
  82. row = (
  83. db.query(BxInvoiceWalletSummary)
  84. .filter(
  85. BxInvoiceWalletSummary.tenant_no == tenant_no,
  86. BxInvoiceWalletSummary.owner_user_id == user.id,
  87. BxInvoiceWalletSummary.del_flag == 0,
  88. )
  89. .first()
  90. )
  91. if row:
  92. return InvoiceService._summary_to_dict(row)
  93. totals = InvoiceLedgerService.calc_summary(db, tenant_no, user.id)
  94. return {
  95. "id": "",
  96. "owner_user_id": user.id,
  97. "owner_user_name": user.name,
  98. "total_amount": totals["total_amount"],
  99. "used_amount": totals["used_amount"],
  100. "balance": totals["balance"],
  101. "invoice_count": InvoiceService._count_invoices(db, tenant_no, user.id),
  102. "version": 0,
  103. "create_time": None,
  104. "update_time": None,
  105. }
  106. @staticmethod
  107. def list_wallets(
  108. db: Session,
  109. *,
  110. user: BxUserContext,
  111. page: int = 1,
  112. page_size: int = 10,
  113. keyword: str = "",
  114. ) -> Tuple[Sequence[BxInvoiceWalletSummary], int]:
  115. InvoiceService._require_wallet_admin(db, user)
  116. tenant_no = InvoiceService._tenant_no(db, user)
  117. query = db.query(BxInvoiceWalletSummary).filter(
  118. BxInvoiceWalletSummary.tenant_no == tenant_no,
  119. BxInvoiceWalletSummary.del_flag == 0,
  120. )
  121. kw = (keyword or "").strip()
  122. if kw:
  123. query = query.filter(
  124. or_(
  125. BxInvoiceWalletSummary.owner_user_name.contains(kw),
  126. BxInvoiceWalletSummary.owner_user_id.contains(kw),
  127. )
  128. )
  129. total = query.count()
  130. rows = (
  131. query.order_by(BxInvoiceWalletSummary.update_time.desc(), BxInvoiceWalletSummary.create_time.desc())
  132. .offset((page - 1) * page_size)
  133. .limit(page_size)
  134. .all()
  135. )
  136. return rows, total
  137. @staticmethod
  138. def list_invoices(
  139. db: Session,
  140. *,
  141. user: BxUserContext,
  142. owner_user_id: Optional[str] = None,
  143. page: int = 1,
  144. page_size: int = 10,
  145. ) -> Tuple[Sequence[BxInvoice], int]:
  146. tenant_no = InvoiceService._tenant_no(db, user)
  147. owner_id = InvoiceService._resolve_owner_for_query(db, user, owner_user_id)
  148. filters = [BxInvoice.owner_user_id == owner_id]
  149. rows, total = BxCrudService.get_paged(
  150. BxInvoice,
  151. db,
  152. tenant_no,
  153. page,
  154. page_size,
  155. filters=filters,
  156. user=user,
  157. )
  158. return rows, total
  159. @staticmethod
  160. def list_ledgers(
  161. db: Session,
  162. *,
  163. user: BxUserContext,
  164. owner_user_id: Optional[str] = None,
  165. ) -> List[BxInvoiceLedger]:
  166. tenant_no = InvoiceService._tenant_no(db, user)
  167. owner_id = InvoiceService._resolve_owner_for_query(db, user, owner_user_id)
  168. rows = InvoiceLedgerService.list_entries(db, tenant_no, owner_id)
  169. return list(reversed(rows))
  170. @staticmethod
  171. def create(
  172. db: Session,
  173. *,
  174. user: BxUserContext,
  175. amount: Decimal,
  176. file_data: bytes,
  177. filename: str,
  178. content_type: str,
  179. remark: Optional[str] = None,
  180. target_user_id: Optional[str] = None,
  181. ) -> BxInvoice:
  182. tenant_no = InvoiceService._tenant_no(db, user)
  183. owner_id, owner_name = InvoiceService._resolve_owner(db, user, target_user_id)
  184. amt = Decimal(str(amount))
  185. if amt <= 0:
  186. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额必须大于0")
  187. if not file_data:
  188. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择文件")
  189. invoice = BxCrudService.create(
  190. BxInvoice,
  191. db,
  192. {
  193. "owner_user_id": owner_id,
  194. "owner_user_name": owner_name,
  195. "amount": amt,
  196. "upload_by": user.id,
  197. "upload_by_name": user.name,
  198. "remark": (remark or "").strip() or None,
  199. },
  200. tenant_no=tenant_no,
  201. user_id=user.id,
  202. dept_id=user.dept_id,
  203. )
  204. safe_name = filename or "unknown"
  205. mime = content_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
  206. relative_path, _full_url = upload_bx_file(file_data, safe_name, biz_type="invoice")
  207. AttachmentService.create(
  208. db,
  209. {
  210. "biz_type": "invoice",
  211. "biz_id": invoice.id,
  212. "file_name": safe_name,
  213. "file_path": relative_path,
  214. "file_type": mime,
  215. "file_size": len(file_data),
  216. "attach_type": "invoice",
  217. },
  218. user=user,
  219. )
  220. InvoiceLedgerService.add_entry(
  221. db,
  222. tenant_no=tenant_no,
  223. owner_user_id=owner_id,
  224. entry_type=ENTRY_CREDIT_UPLOAD,
  225. amount=amt,
  226. user=user,
  227. invoice_id=invoice.id,
  228. )
  229. invoice_count = InvoiceService._count_invoices(db, tenant_no, owner_id)
  230. InvoiceLedgerService.refresh_summary(
  231. db,
  232. tenant_no,
  233. owner_id,
  234. owner_name,
  235. invoice_count,
  236. user.id,
  237. user.dept_id,
  238. )
  239. return invoice
  240. @staticmethod
  241. def delete(db: Session, *, user: BxUserContext, invoice_id: str) -> bool:
  242. InvoiceService._require_wallet_admin(db, user)
  243. tenant_no = InvoiceService._tenant_no(db, user)
  244. invoice = BxCrudService.get_by_id(
  245. BxInvoice, db, invoice_id, tenant_no, user=user, skip_scope=True
  246. )
  247. if not invoice:
  248. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在")
  249. InvoiceLedgerService.assert_can_delete_invoice(
  250. db, tenant_no, invoice.owner_user_id, invoice.amount
  251. )
  252. InvoiceLedgerService.add_entry(
  253. db,
  254. tenant_no=tenant_no,
  255. owner_user_id=invoice.owner_user_id,
  256. entry_type=ENTRY_DEBIT_DELETE,
  257. amount=Decimal(str(invoice.amount)),
  258. user=user,
  259. invoice_id=invoice.id,
  260. )
  261. invoice.del_flag = 1
  262. invoice.update_by = user.id
  263. db.query(BxAttachment).filter(
  264. BxAttachment.biz_type == "invoice",
  265. BxAttachment.biz_id == invoice.id,
  266. BxAttachment.tenant_no == tenant_no,
  267. BxAttachment.del_flag == 0,
  268. ).update(
  269. {BxAttachment.del_flag: 1, BxAttachment.update_by: user.id},
  270. synchronize_session="fetch",
  271. )
  272. db.flush()
  273. invoice_count = InvoiceService._count_invoices(db, tenant_no, invoice.owner_user_id)
  274. InvoiceLedgerService.refresh_summary(
  275. db,
  276. tenant_no,
  277. invoice.owner_user_id,
  278. invoice.owner_user_name,
  279. invoice_count,
  280. user.id,
  281. user.dept_id,
  282. )
  283. return True
  284. @staticmethod
  285. def _summary_to_dict(row: BxInvoiceWalletSummary) -> dict:
  286. return {
  287. "id": row.id,
  288. "owner_user_id": row.owner_user_id,
  289. "owner_user_name": row.owner_user_name,
  290. "total_amount": row.total_amount,
  291. "used_amount": row.used_amount,
  292. "balance": row.balance,
  293. "invoice_count": row.invoice_count,
  294. "version": row.version,
  295. "create_time": row.create_time,
  296. "update_time": row.update_time,
  297. }
  298. @staticmethod
  299. def out_rows_with_attachments(
  300. db: Session, tenant_no: str, invoices: Sequence[BxInvoice]
  301. ) -> List[dict]:
  302. if not invoices:
  303. return []
  304. ids = [inv.id for inv in invoices]
  305. attachments = (
  306. db.query(BxAttachment)
  307. .filter(
  308. BxAttachment.biz_type == "invoice",
  309. BxAttachment.biz_id.in_(ids),
  310. BxAttachment.tenant_no == tenant_no,
  311. BxAttachment.del_flag == 0,
  312. )
  313. .order_by(BxAttachment.create_time.asc())
  314. .all()
  315. )
  316. att_map: dict[str, BxAttachment] = {}
  317. for att in attachments:
  318. if att.biz_id not in att_map:
  319. att_map[att.biz_id] = att
  320. out: List[dict] = []
  321. for inv in invoices:
  322. att = att_map.get(inv.id)
  323. out.append(
  324. {
  325. "id": inv.id,
  326. "owner_user_id": inv.owner_user_id,
  327. "owner_user_name": inv.owner_user_name,
  328. "amount": inv.amount,
  329. "upload_by": inv.upload_by,
  330. "upload_by_name": inv.upload_by_name,
  331. "remark": inv.remark,
  332. "create_time": inv.create_time,
  333. "update_time": inv.update_time,
  334. "attachment_id": att.id if att else None,
  335. "file_name": att.file_name if att else None,
  336. "file_path": att.file_path if att else None,
  337. "file_type": att.file_type if att else None,
  338. }
  339. )
  340. return out