reimburse.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182
  1. from __future__ import annotations
  2. from datetime import datetime
  3. from decimal import Decimal
  4. from typing import Any, Dict, List, Optional, Sequence, Tuple
  5. from fastapi import HTTPException, status
  6. from sqlalchemy import and_, func, or_
  7. from sqlalchemy.exc import IntegrityError
  8. from sqlalchemy.orm import Session
  9. from app.core.bx_auth import BxUserContext
  10. from app.core.menu_guard import SUPER_ADMIN_ROLE
  11. from app.core.tenant_context import get_tenant_no
  12. from app.core.utils import generate_uuid
  13. from app.models.bx_models import (
  14. BxInvoice,
  15. BxInvoiceLedger,
  16. BxReimburseApply,
  17. BxReimburseApproval,
  18. BxReimburseDetail,
  19. BxReimburseInvoice,
  20. BxReimburseOffset,
  21. BxWorkflowDef,
  22. BxWorkflowNode,
  23. )
  24. from app.models.models import SysUser
  25. from app.schemas.bx.reimburse import ReimburseApplyOut, ReimburseDetailOut, ReimburseInvoiceBriefOut
  26. from app.services.bx.approval_cycle import filter_current_cycle_approvals
  27. from app.services.bx.attachment import AttachmentService
  28. from app.services.bx.base import BxCrudService
  29. from app.services.bx.invoice_ledger import ENTRY_DEBIT_REIMBURSE, InvoiceLedgerService
  30. from app.services.bx_data_scope import apply_bx_scope
  31. from app.services.web_auth_roles import iter_user_menu_perm_codes
  32. from app.utils.bx_codegen import (
  33. allocate_reimburse_title_seq,
  34. generate_apply_code,
  35. format_reimburse_title,
  36. is_auto_reimburse_title,
  37. parse_reimburse_date,
  38. resolve_reimburse_title,
  39. )
  40. from app.utils.bx_date_range import current_month_bounds, resolve_create_time_bounds
  41. REIMBURSE_CHANGE_APPLICANT_PERM = "bx:reimburseApply:changeApplicant"
  42. _REIMBURSE_MY_STATS_PASSED_STATUSES = ("approved", "processed", "paid", "offset")
  43. class ReimburseService:
  44. @staticmethod
  45. def _tenant_no(db: Session, user: BxUserContext) -> str:
  46. return get_tenant_no(db, user.to_tenant_user_dict())
  47. @staticmethod
  48. def can_change_applicant(db: Session, user: BxUserContext) -> bool:
  49. """是否可选择他人作为报销申请人(超管或具备按钮权限)。"""
  50. if user.role == SUPER_ADMIN_ROLE:
  51. return True
  52. return REIMBURSE_CHANGE_APPLICANT_PERM in set(
  53. iter_user_menu_perm_codes(db, user.id, user.tenant_no)
  54. )
  55. @staticmethod
  56. def resolve_applicant(
  57. db: Session,
  58. user: BxUserContext,
  59. applicant_id: Optional[str],
  60. *,
  61. tenant_no: str,
  62. ) -> tuple[str, str]:
  63. """
  64. 解析报销申请人。
  65. 无 changeApplicant 权限时忽略客户端指定,强制本人;
  66. 有权限时可指定同租户有效用户。
  67. 返回 (applicant_id, applicant_name)。
  68. """
  69. requested = (applicant_id or "").strip() or None
  70. if not requested or requested == user.id:
  71. return user.id, (user.name or user.id)
  72. if not ReimburseService.can_change_applicant(db, user):
  73. return user.id, (user.name or user.id)
  74. target = (
  75. db.query(SysUser)
  76. .filter(
  77. SysUser.id == requested,
  78. SysUser.tenant_no == tenant_no,
  79. SysUser.del_flag == 0,
  80. SysUser.status == "1",
  81. )
  82. .first()
  83. )
  84. if not target:
  85. raise HTTPException(
  86. status_code=status.HTTP_400_BAD_REQUEST,
  87. detail="申请人不存在或已停用",
  88. )
  89. return target.id, (target.nick_name or target.user_no or target.id)
  90. @staticmethod
  91. def get_by_id(
  92. db: Session,
  93. reimburse_id: str,
  94. tenant_no: str,
  95. *,
  96. user: Optional[BxUserContext] = None,
  97. skip_scope: bool = False,
  98. ) -> Optional[BxReimburseApply]:
  99. return BxCrudService.get_by_id(
  100. BxReimburseApply, db, reimburse_id, tenant_no, user=user, skip_scope=skip_scope
  101. )
  102. @staticmethod
  103. def get_paged(
  104. db: Session,
  105. tenant_no: str,
  106. page: int,
  107. page_size: int,
  108. filters: Optional[List[Any]] = None,
  109. *,
  110. user: Optional[BxUserContext] = None,
  111. ) -> Tuple[Sequence[BxReimburseApply], int]:
  112. return BxCrudService.get_paged(
  113. BxReimburseApply, db, tenant_no, page, page_size, filters, user=user
  114. )
  115. @staticmethod
  116. def soft_delete(db: Session, reimburse_id: str, *, user: BxUserContext) -> bool:
  117. tenant_no = ReimburseService._tenant_no(db, user)
  118. return BxCrudService.soft_delete(
  119. BxReimburseApply, db, reimburse_id, tenant_no, user_id=user.id, user=user
  120. )
  121. @staticmethod
  122. def _workflow_nodes(db: Session, wf_id: str, tenant_no: str) -> List[BxWorkflowNode]:
  123. return (
  124. db.query(BxWorkflowNode)
  125. .filter(
  126. BxWorkflowNode.wf_id == wf_id,
  127. BxWorkflowNode.tenant_no == tenant_no,
  128. BxWorkflowNode.del_flag == 0,
  129. )
  130. .order_by(BxWorkflowNode.node_order)
  131. .all()
  132. )
  133. @staticmethod
  134. def pending_approval(
  135. db: Session,
  136. user: BxUserContext,
  137. tenant_no: str,
  138. page: int,
  139. page_size: int,
  140. keyword: str = "",
  141. applicant_id: Optional[str] = None,
  142. expense_name: Optional[str] = None,
  143. apply_date: Optional[str] = None,
  144. apply_date_from: Optional[str] = None,
  145. apply_date_to: Optional[str] = None,
  146. ):
  147. subquery = (
  148. db.query(BxReimburseApply.id)
  149. .join(
  150. BxWorkflowNode,
  151. and_(
  152. BxWorkflowNode.wf_id == BxReimburseApply.wf_id,
  153. BxWorkflowNode.node_name == BxReimburseApply.current_node,
  154. BxWorkflowNode.tenant_no == tenant_no,
  155. BxWorkflowNode.del_flag == 0,
  156. ),
  157. )
  158. .filter(
  159. BxReimburseApply.del_flag == 0,
  160. BxReimburseApply.tenant_no == tenant_no,
  161. BxReimburseApply.status == "pending",
  162. BxWorkflowNode.approver_id == user.id,
  163. )
  164. )
  165. if keyword:
  166. subquery = subquery.filter(
  167. or_(
  168. BxReimburseApply.apply_code.contains(keyword),
  169. BxReimburseApply.applicant_name.contains(keyword),
  170. BxReimburseApply.title.contains(keyword),
  171. )
  172. )
  173. if applicant_id:
  174. subquery = subquery.filter(BxReimburseApply.applicant_id == applicant_id)
  175. expense_filter = ReimburseService.build_expense_name_condition(db, tenant_no, expense_name)
  176. if expense_filter is not None:
  177. subquery = subquery.filter(expense_filter)
  178. start, end = resolve_create_time_bounds(
  179. apply_date=apply_date,
  180. apply_date_from=apply_date_from,
  181. apply_date_to=apply_date_to,
  182. )
  183. if start is not None:
  184. subquery = subquery.filter(BxReimburseApply.create_time >= start)
  185. if end is not None:
  186. subquery = subquery.filter(BxReimburseApply.create_time < end)
  187. subquery = subquery.subquery()
  188. total = db.query(func.count()).select_from(subquery).scalar() or 0
  189. rows = (
  190. db.query(BxReimburseApply)
  191. .join(subquery, BxReimburseApply.id == subquery.c.id)
  192. .order_by(BxReimburseApply.create_time.desc())
  193. .offset((page - 1) * page_size)
  194. .limit(page_size)
  195. .all()
  196. )
  197. return rows, total
  198. @staticmethod
  199. def approved_by_me(
  200. db: Session,
  201. user: BxUserContext,
  202. tenant_no: str,
  203. page: int,
  204. page_size: int,
  205. keyword: str = "",
  206. applicant_id: Optional[str] = None,
  207. expense_name: Optional[str] = None,
  208. apply_date: Optional[str] = None,
  209. apply_date_from: Optional[str] = None,
  210. apply_date_to: Optional[str] = None,
  211. ):
  212. subquery = (
  213. db.query(BxReimburseApproval.apply_id)
  214. .filter(
  215. BxReimburseApproval.approver_id == user.id,
  216. BxReimburseApproval.tenant_no == tenant_no,
  217. BxReimburseApproval.del_flag == 0,
  218. )
  219. .distinct()
  220. .subquery()
  221. )
  222. query = (
  223. db.query(BxReimburseApply)
  224. .join(subquery, BxReimburseApply.id == subquery.c.apply_id)
  225. .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
  226. )
  227. if keyword:
  228. query = query.filter(
  229. or_(
  230. BxReimburseApply.apply_code.contains(keyword),
  231. BxReimburseApply.applicant_name.contains(keyword),
  232. BxReimburseApply.title.contains(keyword),
  233. )
  234. )
  235. if applicant_id:
  236. query = query.filter(BxReimburseApply.applicant_id == applicant_id)
  237. expense_filter = ReimburseService.build_expense_name_condition(db, tenant_no, expense_name)
  238. if expense_filter is not None:
  239. query = query.filter(expense_filter)
  240. start, end = resolve_create_time_bounds(
  241. apply_date=apply_date,
  242. apply_date_from=apply_date_from,
  243. apply_date_to=apply_date_to,
  244. )
  245. if start is not None:
  246. query = query.filter(BxReimburseApply.create_time >= start)
  247. if end is not None:
  248. query = query.filter(BxReimburseApply.create_time < end)
  249. query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
  250. total = query.count()
  251. rows = (
  252. query.order_by(BxReimburseApply.create_time.desc())
  253. .offset((page - 1) * page_size)
  254. .limit(page_size)
  255. .all()
  256. )
  257. return rows, total
  258. @staticmethod
  259. def _has_debit_reimburse(db: Session, tenant_no: str, reimburse_id: str) -> bool:
  260. return (
  261. db.query(BxInvoiceLedger.id)
  262. .filter(
  263. BxInvoiceLedger.tenant_no == tenant_no,
  264. BxInvoiceLedger.reimburse_id == reimburse_id,
  265. BxInvoiceLedger.entry_type == ENTRY_DEBIT_REIMBURSE,
  266. BxInvoiceLedger.del_flag == 0,
  267. )
  268. .first()
  269. is not None
  270. )
  271. @staticmethod
  272. def sync_reimburse_invoices(
  273. db: Session,
  274. reimburse_id: str,
  275. invoice_ids: Optional[List[str]],
  276. tenant_no: str,
  277. user: BxUserContext,
  278. *,
  279. applicant_id: str,
  280. ) -> None:
  281. if invoice_ids is None:
  282. return
  283. normalized_ids: List[str] = []
  284. seen: set[str] = set()
  285. for raw_id in invoice_ids:
  286. invoice_id = str(raw_id or "").strip()
  287. if not invoice_id or invoice_id in seen:
  288. continue
  289. seen.add(invoice_id)
  290. normalized_ids.append(invoice_id)
  291. if normalized_ids:
  292. invoices = (
  293. db.query(BxInvoice)
  294. .filter(
  295. BxInvoice.tenant_no == tenant_no,
  296. BxInvoice.id.in_(normalized_ids),
  297. BxInvoice.del_flag == 0,
  298. )
  299. .all()
  300. )
  301. invoice_map = {inv.id: inv for inv in invoices}
  302. for invoice_id in normalized_ids:
  303. invoice = invoice_map.get(invoice_id)
  304. if not invoice:
  305. raise HTTPException(
  306. status_code=status.HTTP_400_BAD_REQUEST,
  307. detail=f"发票 {invoice_id} 不存在",
  308. )
  309. if invoice.owner_user_id != applicant_id:
  310. raise HTTPException(
  311. status_code=status.HTTP_400_BAD_REQUEST,
  312. detail=f"发票 {invoice_id} 不属于申请人",
  313. )
  314. db.query(BxReimburseInvoice).filter(
  315. BxReimburseInvoice.reimburse_id == reimburse_id,
  316. BxReimburseInvoice.tenant_no == tenant_no,
  317. BxReimburseInvoice.del_flag == 0,
  318. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  319. for invoice_id in normalized_ids:
  320. db.add(
  321. BxReimburseInvoice(
  322. id=generate_uuid(),
  323. reimburse_id=reimburse_id,
  324. invoice_id=invoice_id,
  325. tenant_no=tenant_no,
  326. create_by=user.id,
  327. create_dept_id=user.dept_id or "",
  328. )
  329. )
  330. db.flush()
  331. @staticmethod
  332. def get_linked_invoice_ids(db: Session, reimburse_id: str, tenant_no: str) -> List[str]:
  333. rows = (
  334. db.query(BxReimburseInvoice.invoice_id)
  335. .filter(
  336. BxReimburseInvoice.reimburse_id == reimburse_id,
  337. BxReimburseInvoice.tenant_no == tenant_no,
  338. BxReimburseInvoice.del_flag == 0,
  339. )
  340. .order_by(BxReimburseInvoice.create_time.asc())
  341. .all()
  342. )
  343. return [row[0] for row in rows]
  344. @staticmethod
  345. def get_linked_invoices_brief(
  346. db: Session, reimburse_id: str, tenant_no: str
  347. ) -> List[ReimburseInvoiceBriefOut]:
  348. rows = (
  349. db.query(BxReimburseInvoice, BxInvoice)
  350. .join(
  351. BxInvoice,
  352. and_(
  353. BxInvoice.id == BxReimburseInvoice.invoice_id,
  354. BxInvoice.tenant_no == tenant_no,
  355. BxInvoice.del_flag == 0,
  356. ),
  357. )
  358. .filter(
  359. BxReimburseInvoice.reimburse_id == reimburse_id,
  360. BxReimburseInvoice.tenant_no == tenant_no,
  361. BxReimburseInvoice.del_flag == 0,
  362. )
  363. .order_by(BxReimburseInvoice.create_time.asc())
  364. .all()
  365. )
  366. return [
  367. ReimburseInvoiceBriefOut(
  368. id=invoice.id,
  369. amount=float(invoice.amount),
  370. owner_user_name=invoice.owner_user_name,
  371. )
  372. for _, invoice in rows
  373. ]
  374. @staticmethod
  375. def get_linked_invoice_ids_map(
  376. db: Session, reimburse_ids: List[str], tenant_no: str
  377. ) -> Dict[str, List[str]]:
  378. if not reimburse_ids:
  379. return {}
  380. rows = (
  381. db.query(BxReimburseInvoice.reimburse_id, BxReimburseInvoice.invoice_id)
  382. .filter(
  383. BxReimburseInvoice.reimburse_id.in_(reimburse_ids),
  384. BxReimburseInvoice.tenant_no == tenant_no,
  385. BxReimburseInvoice.del_flag == 0,
  386. )
  387. .order_by(BxReimburseInvoice.create_time.asc())
  388. .all()
  389. )
  390. grouped: Dict[str, List[str]] = {}
  391. for reimburse_id, invoice_id in rows:
  392. grouped.setdefault(reimburse_id, []).append(invoice_id)
  393. return grouped
  394. @staticmethod
  395. def submit(
  396. db: Session,
  397. apply_id: str,
  398. user: BxUserContext,
  399. *,
  400. invoice_ids: Optional[List[str]] = None,
  401. ) -> Optional[BxReimburseApply]:
  402. tenant_no = ReimburseService._tenant_no(db, user)
  403. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  404. if not apply_obj:
  405. return None
  406. if invoice_ids is not None:
  407. ReimburseService.sync_reimburse_invoices(
  408. db,
  409. apply_id,
  410. invoice_ids,
  411. tenant_no,
  412. user,
  413. applicant_id=apply_obj.applicant_id,
  414. )
  415. if not ReimburseService._has_debit_reimburse(db, tenant_no, apply_id):
  416. amount = Decimal(str(apply_obj.amount or 0))
  417. InvoiceLedgerService.debit_reimburse(
  418. db,
  419. tenant_no=tenant_no,
  420. owner_user_id=apply_obj.applicant_id,
  421. amount=amount,
  422. reimburse_id=apply_id,
  423. user=user,
  424. )
  425. apply_obj.status = "pending"
  426. if not apply_obj.apply_code:
  427. apply_obj.apply_code = generate_apply_code(db, "BX", tenant_no)
  428. apply_obj.update_by = user.id
  429. if apply_obj.wf_id:
  430. nodes = ReimburseService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
  431. if nodes:
  432. apply_obj.current_node = nodes[0].node_name
  433. db.flush()
  434. return apply_obj
  435. @staticmethod
  436. def approve(
  437. db: Session, apply_id: str, approver: BxUserContext, opinion: str
  438. ) -> Optional[BxReimburseApply]:
  439. tenant_no = ReimburseService._tenant_no(db, approver)
  440. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
  441. if not apply_obj or not apply_obj.wf_id:
  442. return None
  443. nodes = ReimburseService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
  444. if not nodes:
  445. return None
  446. current_idx = None
  447. for i, node in enumerate(nodes):
  448. if node.node_name == apply_obj.current_node:
  449. current_idx = i
  450. break
  451. if current_idx is None:
  452. return None
  453. current_node = nodes[current_idx]
  454. if current_node.approver_id != approver.id:
  455. return None
  456. approval = BxReimburseApproval(
  457. id=generate_uuid(),
  458. apply_id=apply_id,
  459. approver_id=approver.id,
  460. approver_name=approver.name,
  461. approval_opinion=opinion,
  462. node_name=current_node.node_name,
  463. node_order=current_node.node_order,
  464. approval_status="approved",
  465. approval_time=datetime.now(),
  466. tenant_no=tenant_no,
  467. create_by=approver.id,
  468. create_dept_id=approver.dept_id or "",
  469. )
  470. db.add(approval)
  471. if current_idx == len(nodes) - 1:
  472. apply_obj.status = "approved"
  473. apply_obj.current_node = None
  474. else:
  475. apply_obj.current_node = nodes[current_idx + 1].node_name
  476. apply_obj.update_by = approver.id
  477. db.flush()
  478. return apply_obj
  479. @staticmethod
  480. def reject(
  481. db: Session, apply_id: str, approver: BxUserContext, opinion: str
  482. ) -> Optional[BxReimburseApply]:
  483. tenant_no = ReimburseService._tenant_no(db, approver)
  484. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
  485. if not apply_obj or not apply_obj.wf_id:
  486. return None
  487. nodes = ReimburseService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
  488. current_node = None
  489. for node in nodes:
  490. if node.node_name == apply_obj.current_node:
  491. current_node = node
  492. break
  493. if current_node is None or current_node.approver_id != approver.id:
  494. return None
  495. approval = BxReimburseApproval(
  496. id=generate_uuid(),
  497. apply_id=apply_id,
  498. approver_id=approver.id,
  499. approver_name=approver.name,
  500. approval_opinion=opinion,
  501. node_name=current_node.node_name,
  502. node_order=current_node.node_order,
  503. approval_status="rejected",
  504. approval_time=datetime.now(),
  505. tenant_no=tenant_no,
  506. create_by=approver.id,
  507. create_dept_id=approver.dept_id or "",
  508. )
  509. db.add(approval)
  510. apply_obj.status = "rejected"
  511. apply_obj.current_node = None
  512. apply_obj.update_by = approver.id
  513. InvoiceLedgerService.release_reimburse(
  514. db, tenant_no=tenant_no, reimburse_id=apply_id, user=approver
  515. )
  516. db.flush()
  517. return apply_obj
  518. @staticmethod
  519. def withdraw(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxReimburseApply]:
  520. tenant_no = ReimburseService._tenant_no(db, user)
  521. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  522. if not apply_obj:
  523. return None
  524. if apply_obj.applicant_id != user.id:
  525. return None
  526. if apply_obj.status != "pending":
  527. return None
  528. apply_obj.status = "draft"
  529. apply_obj.current_node = None
  530. apply_obj.update_by = user.id
  531. InvoiceLedgerService.release_reimburse(
  532. db, tenant_no=tenant_no, reimburse_id=apply_id, user=user
  533. )
  534. db.flush()
  535. return apply_obj
  536. @staticmethod
  537. def _approval_record_dict(approval: BxReimburseApproval, apply_id: str) -> Dict[str, Any]:
  538. return {
  539. "id": approval.id,
  540. "reimburse_id": apply_id,
  541. "node_name": approval.node_name,
  542. "node_order": approval.node_order,
  543. "approval_status": approval.approval_status,
  544. "approval_opinion": approval.approval_opinion,
  545. "approver_name": approval.approver_name,
  546. "approval_time": approval.approval_time,
  547. }
  548. @staticmethod
  549. def _latest_approval_per_node(
  550. approvals: Sequence[BxReimburseApproval],
  551. ) -> Dict[int, BxReimburseApproval]:
  552. """同一节点多次审批时,取 approval_time 最新的一条作为当前节点状态。"""
  553. approval_map: Dict[int, BxReimburseApproval] = {}
  554. for approval in approvals:
  555. existing = approval_map.get(approval.node_order)
  556. if existing is None:
  557. approval_map[approval.node_order] = approval
  558. continue
  559. approval_time = approval.approval_time or datetime.min
  560. existing_time = existing.approval_time or datetime.min
  561. if approval_time >= existing_time:
  562. approval_map[approval.node_order] = approval
  563. return approval_map
  564. @staticmethod
  565. def _query_approvals(db: Session, apply_id: str, tenant_no: str) -> List[BxReimburseApproval]:
  566. return (
  567. db.query(BxReimburseApproval)
  568. .filter(
  569. BxReimburseApproval.apply_id == apply_id,
  570. BxReimburseApproval.tenant_no == tenant_no,
  571. BxReimburseApproval.del_flag == 0,
  572. )
  573. .all()
  574. )
  575. @staticmethod
  576. def get_approvals(
  577. db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
  578. ) -> List[Dict[str, Any]]:
  579. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  580. if not apply_obj or not apply_obj.wf_id:
  581. return []
  582. wf = BxCrudService.get_by_id(BxWorkflowDef, db, apply_obj.wf_id, tenant_no)
  583. if not wf:
  584. return []
  585. nodes = ReimburseService._workflow_nodes(db, wf.id, tenant_no)
  586. if not nodes:
  587. return []
  588. approvals = ReimburseService._query_approvals(db, apply_id, tenant_no)
  589. cycle_approvals = filter_current_cycle_approvals(approvals, apply_obj.status)
  590. approval_map = ReimburseService._latest_approval_per_node(cycle_approvals)
  591. result: List[Dict[str, Any]] = []
  592. for node in nodes:
  593. approval = approval_map.get(node.node_order)
  594. if approval:
  595. result.append(ReimburseService._approval_record_dict(approval, apply_id))
  596. else:
  597. result.append(
  598. {
  599. "id": "0",
  600. "reimburse_id": apply_id,
  601. "node_name": node.node_name,
  602. "node_order": node.node_order,
  603. "approval_status": "pending",
  604. "approval_opinion": None,
  605. "approver_name": node.approver_name or "",
  606. "approval_time": None,
  607. }
  608. )
  609. return result
  610. @staticmethod
  611. def get_approval_history(
  612. db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
  613. ) -> List[Dict[str, Any]]:
  614. """返回全部审批动作,按时间正序排列,用于展示完整审批历史。"""
  615. if not ReimburseService.get_by_id(db, apply_id, tenant_no, user=user):
  616. return []
  617. approvals = (
  618. db.query(BxReimburseApproval)
  619. .filter(
  620. BxReimburseApproval.apply_id == apply_id,
  621. BxReimburseApproval.tenant_no == tenant_no,
  622. BxReimburseApproval.del_flag == 0,
  623. )
  624. .order_by(BxReimburseApproval.approval_time.asc(), BxReimburseApproval.create_time.asc())
  625. .all()
  626. )
  627. return [ReimburseService._approval_record_dict(a, apply_id) for a in approvals]
  628. @staticmethod
  629. def create_with_details(
  630. db: Session,
  631. obj_in: dict,
  632. details: List[dict],
  633. *,
  634. user: BxUserContext,
  635. applicant_id: Optional[str] = None,
  636. applicant_name: Optional[str] = None,
  637. ) -> BxReimburseApply:
  638. tenant_no = ReimburseService._tenant_no(db, user)
  639. data = dict(obj_in)
  640. total_amount = sum(float(d.get("amount", 0) or 0) for d in details)
  641. reimburse_date = parse_reimburse_date(data.pop("reimburse_date", None))
  642. requested_title = data.pop("title", None)
  643. first_detail = details[0] if details else {}
  644. resolved_id = applicant_id or user.id
  645. resolved_name = applicant_name or user.name
  646. apply_obj: BxReimburseApply | None = None
  647. for attempt in range(5):
  648. title, title_seq = resolve_reimburse_title(
  649. db, resolved_name, tenant_no, reimburse_date, requested_title
  650. )
  651. apply_obj = BxReimburseApply(
  652. id=generate_uuid(),
  653. title=title,
  654. reimburse_date=reimburse_date,
  655. title_seq=title_seq,
  656. amount=total_amount,
  657. expense_id=str(first_detail.get("expense_id") or ""),
  658. expense_name=first_detail.get("expense_name") or "",
  659. applicant_id=resolved_id,
  660. applicant_name=resolved_name,
  661. tenant_no=tenant_no,
  662. create_by=user.id,
  663. create_dept_id=user.dept_id or "",
  664. **data,
  665. )
  666. db.add(apply_obj)
  667. try:
  668. db.flush()
  669. break
  670. except IntegrityError:
  671. db.rollback()
  672. if attempt >= 4:
  673. raise
  674. continue
  675. if apply_obj is None:
  676. raise RuntimeError("报销申请创建失败")
  677. for d in details:
  678. detail = BxReimburseDetail(
  679. id=generate_uuid(),
  680. reimburse_id=apply_obj.id,
  681. expense_id=str(d["expense_id"]),
  682. expense_name=d["expense_name"],
  683. amount=d["amount"],
  684. remark=d.get("remark"),
  685. tenant_no=tenant_no,
  686. create_by=user.id,
  687. create_dept_id=user.dept_id or "",
  688. )
  689. db.add(detail)
  690. db.flush()
  691. return apply_obj
  692. @staticmethod
  693. def update_with_details(
  694. db: Session,
  695. apply_id: str,
  696. obj_in: dict,
  697. details: Optional[List[dict]],
  698. *,
  699. user: BxUserContext,
  700. ) -> Optional[BxReimburseApply]:
  701. tenant_no = ReimburseService._tenant_no(db, user)
  702. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  703. if not apply_obj:
  704. return None
  705. reimburse_date = None
  706. date_changed = False
  707. if "reimburse_date" in obj_in:
  708. reimburse_date = parse_reimburse_date(obj_in.pop("reimburse_date"))
  709. date_changed = reimburse_date != apply_obj.reimburse_date
  710. requested_title = obj_in.pop("title", None)
  711. prev_applicant_id = apply_obj.applicant_id
  712. for key, value in obj_in.items():
  713. setattr(apply_obj, key, value)
  714. if reimburse_date is not None:
  715. apply_obj.reimburse_date = reimburse_date
  716. if requested_title is not None:
  717. apply_obj.title = str(requested_title).strip()
  718. applicant_changed = apply_obj.applicant_id != prev_applicant_id
  719. should_regen_title = (date_changed or applicant_changed) and is_auto_reimburse_title(
  720. apply_obj.title
  721. )
  722. if should_regen_title:
  723. seq = allocate_reimburse_title_seq(
  724. db, tenant_no, apply_obj.reimburse_date, exclude_apply_id=apply_id
  725. )
  726. apply_obj.title_seq = seq
  727. apply_obj.title = format_reimburse_title(
  728. apply_obj.applicant_name, apply_obj.reimburse_date, seq
  729. )
  730. elif requested_title is not None and not is_auto_reimburse_title(apply_obj.title):
  731. apply_obj.title_seq = None
  732. if details is not None:
  733. db.query(BxReimburseDetail).filter(
  734. BxReimburseDetail.reimburse_id == apply_id,
  735. BxReimburseDetail.tenant_no == tenant_no,
  736. BxReimburseDetail.del_flag == 0,
  737. ).update({"del_flag": 1}, synchronize_session=False)
  738. total_amount = Decimal("0")
  739. first_expense_name = ""
  740. first_expense_id = ""
  741. for i, d in enumerate(details):
  742. detail = BxReimburseDetail(
  743. id=generate_uuid(),
  744. reimburse_id=apply_id,
  745. expense_id=str(d["expense_id"]),
  746. expense_name=d["expense_name"],
  747. amount=d["amount"],
  748. remark=d.get("remark"),
  749. tenant_no=tenant_no,
  750. create_by=user.id,
  751. create_dept_id=user.dept_id or "",
  752. )
  753. db.add(detail)
  754. total_amount += Decimal(str(d["amount"]))
  755. if i == 0:
  756. first_expense_name = d["expense_name"]
  757. first_expense_id = str(d["expense_id"])
  758. apply_obj.amount = total_amount
  759. apply_obj.expense_name = first_expense_name
  760. apply_obj.expense_id = first_expense_id
  761. apply_obj.update_by = user.id
  762. db.flush()
  763. return apply_obj
  764. @staticmethod
  765. def get_details(db: Session, reimburse_id: str, tenant_no: str) -> List[BxReimburseDetail]:
  766. return (
  767. db.query(BxReimburseDetail)
  768. .filter(
  769. BxReimburseDetail.reimburse_id == reimburse_id,
  770. BxReimburseDetail.tenant_no == tenant_no,
  771. BxReimburseDetail.del_flag == 0,
  772. )
  773. .order_by(BxReimburseDetail.create_time)
  774. .all()
  775. )
  776. @staticmethod
  777. def build_expense_name_condition(
  778. db: Session, tenant_no: str, expense_name: Optional[str]
  779. ):
  780. name_kw = str(expense_name or "").strip()
  781. if not name_kw:
  782. return None
  783. detail_reimburse_ids = (
  784. db.query(BxReimburseDetail.reimburse_id)
  785. .filter(
  786. BxReimburseDetail.tenant_no == tenant_no,
  787. BxReimburseDetail.del_flag == 0,
  788. BxReimburseDetail.expense_name.contains(name_kw),
  789. )
  790. )
  791. return or_(
  792. BxReimburseApply.expense_name.contains(name_kw),
  793. BxReimburseApply.id.in_(detail_reimburse_ids),
  794. )
  795. @staticmethod
  796. def get_details_map_by_reimburse_ids(
  797. db: Session, reimburse_ids: List[str], tenant_no: str
  798. ) -> Dict[str, List[BxReimburseDetail]]:
  799. if not reimburse_ids:
  800. return {}
  801. detail_rows = (
  802. db.query(BxReimburseDetail)
  803. .filter(
  804. BxReimburseDetail.reimburse_id.in_(reimburse_ids),
  805. BxReimburseDetail.tenant_no == tenant_no,
  806. BxReimburseDetail.del_flag == 0,
  807. )
  808. .order_by(BxReimburseDetail.create_time)
  809. .all()
  810. )
  811. grouped: Dict[str, List[BxReimburseDetail]] = {}
  812. for detail in detail_rows:
  813. grouped.setdefault(detail.reimburse_id, []).append(detail)
  814. return grouped
  815. @staticmethod
  816. def out_rows_with_attachment_counts(
  817. db: Session, tenant_no: str, rows: List[BxReimburseApply]
  818. ) -> List[ReimburseApplyOut]:
  819. if not rows:
  820. return []
  821. ids = [r.id for r in rows]
  822. counts = AttachmentService.count_invoice_attach_by_biz_ids(
  823. db, "reimburse_apply", ids, tenant_no
  824. )
  825. details_map = ReimburseService.get_details_map_by_reimburse_ids(db, ids, tenant_no)
  826. invoice_ids_map = ReimburseService.get_linked_invoice_ids_map(db, ids, tenant_no)
  827. out_list: List[ReimburseApplyOut] = []
  828. for row in rows:
  829. out = ReimburseApplyOut.model_validate(row)
  830. inv, att = counts.get(row.id, (0, 0))
  831. detail_rows = details_map.get(row.id, [])
  832. linked_ids = invoice_ids_map.get(row.id, [])
  833. out_list.append(
  834. out.model_copy(
  835. update={
  836. "invoice_count": inv,
  837. "attach_count": att,
  838. "details": [ReimburseDetailOut.model_validate(d) for d in detail_rows],
  839. "invoice_ids": linked_ids,
  840. }
  841. )
  842. )
  843. return out_list
  844. @staticmethod
  845. def out_one_with_attachment_counts(
  846. db: Session, tenant_no: str, row: BxReimburseApply
  847. ) -> ReimburseApplyOut:
  848. return ReimburseService.out_rows_with_attachment_counts(db, tenant_no, [row])[0]
  849. @staticmethod
  850. def build_apply_out_with_details(
  851. db: Session, tenant_no: str, reimburse: BxReimburseApply
  852. ) -> ReimburseApplyOut:
  853. out = ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse)
  854. detail_rows = ReimburseService.get_details(db, reimburse.id, tenant_no)
  855. out.details = [ReimburseDetailOut.model_validate(d) for d in detail_rows]
  856. out.invoice_ids = ReimburseService.get_linked_invoice_ids(db, reimburse.id, tenant_no)
  857. out.invoices = ReimburseService.get_linked_invoices_brief(db, reimburse.id, tenant_no)
  858. return out
  859. @staticmethod
  860. def global_status_counts(db: Session, tenant_no: str, *, user: BxUserContext) -> Dict[str, int]:
  861. query = db.query(BxReimburseApply.status, func.count()).filter(
  862. BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no
  863. )
  864. query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
  865. rows = query.group_by(BxReimburseApply.status).all()
  866. return {str(s or ""): int(c) for s, c in rows}
  867. @staticmethod
  868. def _my_apply_base(db: Session, user: BxUserContext, tenant_no: str):
  869. query = db.query(BxReimburseApply).filter(
  870. BxReimburseApply.del_flag == 0,
  871. BxReimburseApply.tenant_no == tenant_no,
  872. or_(
  873. BxReimburseApply.applicant_id == user.id,
  874. BxReimburseApply.create_by == user.id,
  875. ),
  876. )
  877. return apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
  878. @staticmethod
  879. def my_apply_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
  880. base_q = ReimburseService._my_apply_base(db, user, tenant_no).subquery()
  881. base = BxReimburseApply.id.in_(db.query(base_q.c.id))
  882. total_count = (
  883. db.query(func.count()).select_from(BxReimburseApply).filter(base).scalar() or 0
  884. )
  885. pending = (
  886. db.query(func.count())
  887. .select_from(BxReimburseApply)
  888. .filter(base, BxReimburseApply.status == "pending")
  889. .scalar()
  890. or 0
  891. )
  892. approved_count = (
  893. db.query(func.count())
  894. .select_from(BxReimburseApply)
  895. .filter(base, BxReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
  896. .scalar()
  897. or 0
  898. )
  899. rejected_count = (
  900. db.query(func.count())
  901. .select_from(BxReimburseApply)
  902. .filter(base, BxReimburseApply.status == "rejected")
  903. .scalar()
  904. or 0
  905. )
  906. approved_amount = (
  907. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  908. .filter(base, BxReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
  909. .scalar()
  910. or 0
  911. )
  912. m0, m1 = current_month_bounds()
  913. month_apply_amount = (
  914. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  915. .filter(base, BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  916. .scalar()
  917. or 0
  918. )
  919. month_apply_count = (
  920. db.query(func.count())
  921. .select_from(BxReimburseApply)
  922. .filter(base, BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  923. .scalar()
  924. or 0
  925. )
  926. return {
  927. "total_count": int(total_count),
  928. "pending_count": int(pending),
  929. "approved_count": int(approved_count),
  930. "rejected_count": int(rejected_count),
  931. "approved_amount": float(approved_amount),
  932. "month_apply_amount": float(month_apply_amount),
  933. "month_apply_count": int(month_apply_count),
  934. }
  935. @staticmethod
  936. def pending_approval_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
  937. subquery = (
  938. db.query(BxReimburseApply.id)
  939. .join(
  940. BxWorkflowNode,
  941. and_(
  942. BxWorkflowNode.wf_id == BxReimburseApply.wf_id,
  943. BxWorkflowNode.node_name == BxReimburseApply.current_node,
  944. BxWorkflowNode.tenant_no == tenant_no,
  945. BxWorkflowNode.del_flag == 0,
  946. ),
  947. )
  948. .filter(
  949. BxReimburseApply.del_flag == 0,
  950. BxReimburseApply.tenant_no == tenant_no,
  951. BxReimburseApply.status == "pending",
  952. BxWorkflowNode.approver_id == user.id,
  953. )
  954. ).subquery()
  955. total = db.query(func.count()).select_from(subquery).scalar() or 0
  956. m0, m1 = current_month_bounds()
  957. month_sum = (
  958. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  959. .join(subquery, BxReimburseApply.id == subquery.c.id)
  960. .filter(BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  961. .scalar()
  962. or 0
  963. )
  964. return {"pending_total": int(total), "month_pending_amount": float(month_sum)}
  965. @staticmethod
  966. def pay(
  967. db: Session,
  968. apply_id: str,
  969. user: BxUserContext,
  970. pay_remark: str = "",
  971. actual_amount: Optional[float] = None,
  972. ) -> Optional[BxReimburseApply]:
  973. tenant_no = ReimburseService._tenant_no(db, user)
  974. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  975. if not apply_obj:
  976. return None
  977. apply_obj.status = "processed"
  978. apply_obj.update_by = user.id
  979. apply_obj.pay_status = "paid"
  980. apply_obj.pay_time = datetime.now()
  981. apply_obj.pay_user_id = user.id
  982. apply_obj.pay_user_name = user.name
  983. if actual_amount is not None:
  984. apply_obj.actual_amount = actual_amount
  985. db.flush()
  986. return apply_obj
  987. @staticmethod
  988. def offset(
  989. db: Session,
  990. apply_id: str,
  991. advance_id: str,
  992. amount: float,
  993. user: BxUserContext,
  994. remark: str = "",
  995. ) -> Optional[BxReimburseApply]:
  996. tenant_no = ReimburseService._tenant_no(db, user)
  997. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  998. if not apply_obj:
  999. return None
  1000. apply_obj.status = "offset"
  1001. apply_obj.offset_advance_id = advance_id
  1002. apply_obj.offset_amount = amount
  1003. apply_obj.update_by = user.id
  1004. offset_record = BxReimburseOffset(
  1005. id=generate_uuid(),
  1006. reimburse_id=apply_id,
  1007. advance_id=advance_id,
  1008. offset_amount=amount,
  1009. remark=remark or None,
  1010. tenant_no=tenant_no,
  1011. create_by=user.name,
  1012. create_dept_id=user.dept_id or "",
  1013. )
  1014. db.add(offset_record)
  1015. db.flush()
  1016. return apply_obj
  1017. @staticmethod
  1018. def batch_offset(
  1019. db: Session,
  1020. apply_id: str,
  1021. items: List[dict],
  1022. pay_amount: float,
  1023. user: BxUserContext,
  1024. remark: str = "",
  1025. ) -> Optional[BxReimburseApply]:
  1026. tenant_no = ReimburseService._tenant_no(db, user)
  1027. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  1028. if not apply_obj:
  1029. return None
  1030. has_offsets = bool(items)
  1031. total_offset = 0.0
  1032. first_advance_id = None
  1033. for item in items:
  1034. advance_id = str(item["advance_id"])
  1035. offset_amount = float(item["offset_amount"])
  1036. if first_advance_id is None:
  1037. first_advance_id = advance_id
  1038. total_offset += offset_amount
  1039. offset_record = BxReimburseOffset(
  1040. id=generate_uuid(),
  1041. reimburse_id=apply_id,
  1042. advance_id=advance_id,
  1043. offset_amount=offset_amount,
  1044. remark=remark or None,
  1045. tenant_no=tenant_no,
  1046. create_by=user.name,
  1047. create_dept_id=user.dept_id or "",
  1048. )
  1049. db.add(offset_record)
  1050. if first_advance_id is not None:
  1051. apply_obj.offset_advance_id = first_advance_id
  1052. apply_obj.offset_amount = total_offset
  1053. if pay_amount > 0:
  1054. apply_obj.pay_status = "paid"
  1055. apply_obj.pay_time = datetime.now()
  1056. apply_obj.pay_user_id = user.id
  1057. apply_obj.pay_user_name = user.name
  1058. apply_obj.actual_amount = pay_amount
  1059. apply_obj.status = "processed"
  1060. elif has_offsets:
  1061. apply_obj.actual_amount = 0
  1062. apply_obj.status = "processed"
  1063. apply_obj.update_by = user.id
  1064. db.flush()
  1065. return apply_obj
  1066. @staticmethod
  1067. def finance_payment_stats(db: Session, tenant_no: str, *, user: BxUserContext) -> dict:
  1068. base_q = (
  1069. db.query(BxReimburseApply)
  1070. .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
  1071. )
  1072. base_q = apply_bx_scope(base_q, BxReimburseApply, user, db, tenant_no).subquery()
  1073. base = BxReimburseApply.id.in_(db.query(base_q.c.id))
  1074. pending = (
  1075. db.query(func.count())
  1076. .select_from(BxReimburseApply)
  1077. .filter(base, BxReimburseApply.status == "approved")
  1078. .scalar()
  1079. or 0
  1080. )
  1081. bank_paid = BxReimburseApply.pay_time.isnot(None)
  1082. paid = (
  1083. db.query(func.count()).select_from(BxReimburseApply).filter(base, bank_paid).scalar() or 0
  1084. )
  1085. offset_settled = or_(
  1086. BxReimburseApply.status == "offset",
  1087. and_(
  1088. BxReimburseApply.status == "processed",
  1089. BxReimburseApply.pay_time.is_(None),
  1090. func.coalesce(BxReimburseApply.offset_amount, 0) > 0,
  1091. ),
  1092. )
  1093. offset = (
  1094. db.query(func.count()).select_from(BxReimburseApply).filter(base, offset_settled).scalar() or 0
  1095. )
  1096. m0, m1 = current_month_bounds()
  1097. month_paid = (
  1098. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1099. .filter(base, bank_paid, BxReimburseApply.pay_time >= m0, BxReimburseApply.pay_time < m1)
  1100. .scalar()
  1101. or 0
  1102. )
  1103. month_offset = (
  1104. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1105. .filter(base, offset_settled, BxReimburseApply.update_time >= m0, BxReimburseApply.update_time < m1)
  1106. .scalar()
  1107. or 0
  1108. )
  1109. return {
  1110. "pending_count": int(pending),
  1111. "paid_count": int(paid),
  1112. "offset_count": int(offset),
  1113. "month_processed_amount": float(month_paid) + float(month_offset),
  1114. }