invoice.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. from __future__ import annotations
  2. import mimetypes
  3. from decimal import Decimal
  4. from typing import Any, List, Optional, Sequence, Tuple
  5. from fastapi import HTTPException, status
  6. from sqlalchemy.orm import Session
  7. from app.core.bx_auth import BxUserContext
  8. from app.core.menu_guard import SUPER_ADMIN_ROLE
  9. from app.core.tenant_context import get_tenant_no
  10. from app.models.bx_models import BxAttachment, BxInvoice, BxInvoiceLedger, BxInvoiceWalletSummary
  11. from app.models.models import SysUser
  12. from app.services.bx.attachment import AttachmentService
  13. from app.services.bx.base import BxCrudService
  14. from app.services.bx.invoice_ledger import (
  15. ENTRY_CREDIT_UPLOAD,
  16. ENTRY_DEBIT_DELETE,
  17. InvoiceLedgerService,
  18. )
  19. from app.services.bx.storage import upload_bx_file
  20. from app.services.web_auth_roles import iter_user_menu_perm_codes
  21. INVOICE_WALLET_ADMIN_PERM = "bx:invoiceWallet:admin"
  22. REIMBURSE_CHANGE_APPLICANT_PERM = "bx:reimburseApply:changeApplicant"
  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 can_query_others_wallet(db: Session, user: BxUserContext) -> bool:
  35. """发票夹管理 或 报销改选申请人 权限可查询他人发票/余额。"""
  36. if InvoiceService.is_wallet_admin(db, user):
  37. return True
  38. codes = iter_user_menu_perm_codes(db, user.id, user.tenant_no)
  39. return REIMBURSE_CHANGE_APPLICANT_PERM in set(codes)
  40. @staticmethod
  41. def _require_wallet_admin(db: Session, user: BxUserContext) -> None:
  42. if not InvoiceService.is_wallet_admin(db, user):
  43. raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要发票夹管理权限")
  44. @staticmethod
  45. def _lookup_user_name(db: Session, user_id: str) -> str:
  46. row = (
  47. db.query(SysUser)
  48. .filter(SysUser.id == user_id, SysUser.del_flag == 0)
  49. .first()
  50. )
  51. if not row:
  52. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标用户不存在")
  53. return (row.nick_name or row.user_no or "").strip()
  54. @staticmethod
  55. def _resolve_owner(
  56. db: Session,
  57. user: BxUserContext,
  58. target_user_id: Optional[str],
  59. ) -> Tuple[str, str]:
  60. target = (target_user_id or "").strip()
  61. if not target or target == user.id:
  62. return user.id, user.name
  63. InvoiceService._require_wallet_admin(db, user)
  64. return target, InvoiceService._lookup_user_name(db, target)
  65. @staticmethod
  66. def _resolve_owner_for_query(
  67. db: Session,
  68. user: BxUserContext,
  69. user_id: Optional[str],
  70. ) -> str:
  71. owner_id = (user_id or "").strip() or user.id
  72. if owner_id != user.id and not InvoiceService.can_query_others_wallet(db, user):
  73. raise HTTPException(
  74. status_code=status.HTTP_403_FORBIDDEN,
  75. detail="无权查看他人发票夹",
  76. )
  77. return owner_id
  78. @staticmethod
  79. def _count_invoices(db: Session, tenant_no: str, owner_user_id: str) -> int:
  80. return (
  81. db.query(BxInvoice)
  82. .filter(
  83. BxInvoice.tenant_no == tenant_no,
  84. BxInvoice.owner_user_id == owner_user_id,
  85. BxInvoice.del_flag == 0,
  86. )
  87. .count()
  88. )
  89. @staticmethod
  90. def get_mine_summary(
  91. db: Session,
  92. *,
  93. user: BxUserContext,
  94. owner_user_id: Optional[str] = None,
  95. ) -> dict:
  96. tenant_no = InvoiceService._tenant_no(db, user)
  97. owner_id = InvoiceService._resolve_owner_for_query(db, user, owner_user_id)
  98. owner_name = (
  99. user.name
  100. if owner_id == user.id
  101. else InvoiceService._lookup_user_name(db, owner_id)
  102. )
  103. row = (
  104. db.query(BxInvoiceWalletSummary)
  105. .filter(
  106. BxInvoiceWalletSummary.tenant_no == tenant_no,
  107. BxInvoiceWalletSummary.owner_user_id == owner_id,
  108. BxInvoiceWalletSummary.del_flag == 0,
  109. )
  110. .first()
  111. )
  112. if row:
  113. return InvoiceService._summary_to_dict(row)
  114. totals = InvoiceLedgerService.calc_summary(db, tenant_no, owner_id)
  115. return {
  116. "id": "",
  117. "owner_user_id": owner_id,
  118. "owner_user_name": owner_name,
  119. "total_amount": totals["total_amount"],
  120. "used_amount": totals["used_amount"],
  121. "balance": totals["balance"],
  122. "invoice_count": InvoiceService._count_invoices(db, tenant_no, owner_id),
  123. "version": 0,
  124. "create_time": None,
  125. "update_time": None,
  126. }
  127. @staticmethod
  128. def list_wallets(
  129. db: Session,
  130. *,
  131. user: BxUserContext,
  132. page: int = 1,
  133. page_size: int = 10,
  134. keyword: str = "",
  135. extra_filters: Optional[List[Any]] = None,
  136. ) -> Tuple[Sequence[BxInvoiceWalletSummary], int]:
  137. InvoiceService._require_wallet_admin(db, user)
  138. tenant_no = InvoiceService._tenant_no(db, user)
  139. query = db.query(BxInvoiceWalletSummary).filter(
  140. BxInvoiceWalletSummary.tenant_no == tenant_no,
  141. BxInvoiceWalletSummary.del_flag == 0,
  142. )
  143. # 兼容旧调用:仅 keyword、未传 extra_filters 时仍按姓名/工号模糊
  144. if extra_filters:
  145. query = query.filter(*extra_filters)
  146. else:
  147. kw = (keyword or "").strip()
  148. if kw:
  149. from sqlalchemy import or_
  150. query = query.filter(
  151. or_(
  152. BxInvoiceWalletSummary.owner_user_name.contains(kw),
  153. BxInvoiceWalletSummary.owner_user_id.contains(kw),
  154. )
  155. )
  156. total = query.count()
  157. rows = (
  158. query.order_by(BxInvoiceWalletSummary.update_time.desc(), BxInvoiceWalletSummary.create_time.desc())
  159. .offset((page - 1) * page_size)
  160. .limit(page_size)
  161. .all()
  162. )
  163. return rows, total
  164. @staticmethod
  165. def list_invoices(
  166. db: Session,
  167. *,
  168. user: BxUserContext,
  169. owner_user_id: Optional[str] = None,
  170. page: int = 1,
  171. page_size: int = 10,
  172. extra_filters: Optional[List[Any]] = None,
  173. order_by: Optional[Any] = None,
  174. ) -> Tuple[Sequence[BxInvoice], int]:
  175. tenant_no = InvoiceService._tenant_no(db, user)
  176. owner_id = InvoiceService._resolve_owner_for_query(db, user, owner_user_id)
  177. filters: List[Any] = [BxInvoice.owner_user_id == owner_id]
  178. if extra_filters:
  179. filters.extend(extra_filters)
  180. rows, total = BxCrudService.get_paged(
  181. BxInvoice,
  182. db,
  183. tenant_no,
  184. page,
  185. page_size,
  186. filters=filters,
  187. user=user,
  188. skip_scope=True, # 发票钱包按已校验的 owner_user_id 隔离,不叠加创建人权限。
  189. order_by=order_by,
  190. )
  191. return rows, total
  192. @staticmethod
  193. def list_ledgers(
  194. db: Session,
  195. *,
  196. user: BxUserContext,
  197. owner_user_id: Optional[str] = None,
  198. extra_filters: Optional[List[Any]] = None,
  199. order_by: Optional[Any] = None,
  200. ) -> List[dict]:
  201. """返回流水字典列表;流水备注为空时回填关联发票备注(含已软删发票)。"""
  202. tenant_no = InvoiceService._tenant_no(db, user)
  203. owner_id = InvoiceService._resolve_owner_for_query(db, user, owner_user_id)
  204. query = db.query(BxInvoiceLedger).filter(
  205. BxInvoiceLedger.tenant_no == tenant_no,
  206. BxInvoiceLedger.owner_user_id == owner_id,
  207. BxInvoiceLedger.del_flag == 0,
  208. )
  209. if extra_filters:
  210. query = query.filter(*extra_filters)
  211. order_expr = order_by if order_by is not None else BxInvoiceLedger.create_time.desc()
  212. rows = query.order_by(order_expr).all()
  213. invoice_ids = {r.invoice_id for r in rows if r.invoice_id}
  214. inv_remark_map: dict[str, str] = {}
  215. if invoice_ids:
  216. inv_rows = (
  217. db.query(BxInvoice.id, BxInvoice.remark)
  218. .filter(BxInvoice.id.in_(list(invoice_ids)), BxInvoice.tenant_no == tenant_no)
  219. .all()
  220. )
  221. for inv_id, inv_remark in inv_rows:
  222. text = (inv_remark or "").strip()
  223. if text:
  224. inv_remark_map[inv_id] = text
  225. out: List[dict] = []
  226. for r in rows:
  227. remark = (r.remark or "").strip()
  228. if not remark and r.invoice_id:
  229. remark = inv_remark_map.get(r.invoice_id, "")
  230. out.append(
  231. {
  232. "id": r.id,
  233. "owner_user_id": r.owner_user_id,
  234. "entry_type": r.entry_type,
  235. "amount": r.amount,
  236. "invoice_id": r.invoice_id,
  237. "reimburse_id": r.reimburse_id,
  238. "ref_ledger_id": r.ref_ledger_id,
  239. "remark": remark or None,
  240. "create_time": r.create_time,
  241. }
  242. )
  243. return out
  244. @staticmethod
  245. def create(
  246. db: Session,
  247. *,
  248. user: BxUserContext,
  249. amount: Decimal,
  250. files: Sequence[Tuple[bytes, str, str]],
  251. remark: Optional[str] = None,
  252. target_user_id: Optional[str] = None,
  253. ) -> BxInvoice:
  254. """一次上传创建一条发票档案:金额为总额,files 全部挂到同一 biz_id,只写一条 credit_upload。"""
  255. tenant_no = InvoiceService._tenant_no(db, user)
  256. owner_id, owner_name = InvoiceService._resolve_owner(db, user, target_user_id)
  257. amt = Decimal(str(amount))
  258. if amt <= 0:
  259. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额必须大于0")
  260. if not files:
  261. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择文件")
  262. invoice = BxCrudService.create(
  263. BxInvoice,
  264. db,
  265. {
  266. "owner_user_id": owner_id,
  267. "owner_user_name": owner_name,
  268. "amount": amt,
  269. "upload_by": user.id,
  270. "upload_by_name": user.name,
  271. "remark": (remark or "").strip() or None,
  272. },
  273. tenant_no=tenant_no,
  274. user_id=user.id,
  275. dept_id=user.dept_id,
  276. )
  277. for file_data, filename, content_type in files:
  278. if not file_data:
  279. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件内容为空")
  280. safe_name = filename or "unknown"
  281. mime = content_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
  282. relative_path, _full_url = upload_bx_file(file_data, safe_name, biz_type="invoice")
  283. AttachmentService.create(
  284. db,
  285. {
  286. "biz_type": "invoice",
  287. "biz_id": invoice.id,
  288. "file_name": safe_name,
  289. "file_path": relative_path,
  290. "file_type": mime,
  291. "file_size": len(file_data),
  292. "attach_type": "invoice",
  293. },
  294. user=user,
  295. )
  296. InvoiceLedgerService.add_entry(
  297. db,
  298. tenant_no=tenant_no,
  299. owner_user_id=owner_id,
  300. entry_type=ENTRY_CREDIT_UPLOAD,
  301. amount=amt,
  302. user=user,
  303. invoice_id=invoice.id,
  304. remark=(remark or "").strip() or None,
  305. )
  306. invoice_count = InvoiceService._count_invoices(db, tenant_no, owner_id)
  307. InvoiceLedgerService.refresh_summary(
  308. db,
  309. tenant_no,
  310. owner_id,
  311. owner_name,
  312. invoice_count,
  313. user.id,
  314. user.dept_id,
  315. )
  316. return invoice
  317. @staticmethod
  318. def delete(db: Session, *, user: BxUserContext, invoice_id: str) -> bool:
  319. InvoiceService._require_wallet_admin(db, user)
  320. tenant_no = InvoiceService._tenant_no(db, user)
  321. invoice = BxCrudService.get_by_id(
  322. BxInvoice, db, invoice_id, tenant_no, user=user, skip_scope=True
  323. )
  324. if not invoice:
  325. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在")
  326. InvoiceLedgerService.assert_can_delete_invoice(
  327. db, tenant_no, invoice.owner_user_id, invoice.amount
  328. )
  329. InvoiceLedgerService.add_entry(
  330. db,
  331. tenant_no=tenant_no,
  332. owner_user_id=invoice.owner_user_id,
  333. entry_type=ENTRY_DEBIT_DELETE,
  334. amount=Decimal(str(invoice.amount)),
  335. user=user,
  336. invoice_id=invoice.id,
  337. remark=(invoice.remark or "").strip() or "删除发票冲减",
  338. )
  339. invoice.del_flag = 1
  340. invoice.update_by = user.id
  341. db.query(BxAttachment).filter(
  342. BxAttachment.biz_type == "invoice",
  343. BxAttachment.biz_id == invoice.id,
  344. BxAttachment.tenant_no == tenant_no,
  345. BxAttachment.del_flag == 0,
  346. ).update(
  347. {BxAttachment.del_flag: 1, BxAttachment.update_by: user.id},
  348. synchronize_session="fetch",
  349. )
  350. db.flush()
  351. invoice_count = InvoiceService._count_invoices(db, tenant_no, invoice.owner_user_id)
  352. InvoiceLedgerService.refresh_summary(
  353. db,
  354. tenant_no,
  355. invoice.owner_user_id,
  356. invoice.owner_user_name,
  357. invoice_count,
  358. user.id,
  359. user.dept_id,
  360. )
  361. return True
  362. @staticmethod
  363. def _summary_to_dict(row: BxInvoiceWalletSummary) -> dict:
  364. return {
  365. "id": row.id,
  366. "owner_user_id": row.owner_user_id,
  367. "owner_user_name": row.owner_user_name,
  368. "total_amount": row.total_amount,
  369. "used_amount": row.used_amount,
  370. "balance": row.balance,
  371. "invoice_count": row.invoice_count,
  372. "version": row.version,
  373. "create_time": row.create_time,
  374. "update_time": row.update_time,
  375. }
  376. @staticmethod
  377. def out_rows_with_attachments(
  378. db: Session, tenant_no: str, invoices: Sequence[BxInvoice]
  379. ) -> List[dict]:
  380. if not invoices:
  381. return []
  382. ids = [inv.id for inv in invoices]
  383. attachments = (
  384. db.query(BxAttachment)
  385. .filter(
  386. BxAttachment.biz_type == "invoice",
  387. BxAttachment.biz_id.in_(ids),
  388. BxAttachment.tenant_no == tenant_no,
  389. BxAttachment.del_flag == 0,
  390. )
  391. .order_by(BxAttachment.create_time.asc())
  392. .all()
  393. )
  394. att_map: dict[str, List[BxAttachment]] = {}
  395. for att in attachments:
  396. att_map.setdefault(att.biz_id, []).append(att)
  397. out: List[dict] = []
  398. for inv in invoices:
  399. atts = att_map.get(inv.id, [])
  400. first = atts[0] if atts else None
  401. out.append(
  402. {
  403. "id": inv.id,
  404. "owner_user_id": inv.owner_user_id,
  405. "owner_user_name": inv.owner_user_name,
  406. "amount": inv.amount,
  407. "upload_by": inv.upload_by,
  408. "upload_by_name": inv.upload_by_name,
  409. "remark": inv.remark,
  410. "create_time": inv.create_time,
  411. "update_time": inv.update_time,
  412. "attachment_id": first.id if first else None,
  413. "file_name": first.file_name if first else None,
  414. "file_path": first.file_path if first else None,
  415. "file_type": first.file_type if first else None,
  416. "attachments": [
  417. {
  418. "id": a.id,
  419. "file_name": a.file_name,
  420. "file_path": a.file_path,
  421. "file_type": a.file_type,
  422. }
  423. for a in atts
  424. ],
  425. }
  426. )
  427. return out