reimburse.py 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453
  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. BxAttachment,
  15. BxInvoice,
  16. BxInvoiceLedger,
  17. BxReimburseApply,
  18. BxReimburseApproval,
  19. BxReimburseDetail,
  20. BxReimburseInvoice,
  21. BxReimburseOffset,
  22. BxWorkflowDef,
  23. BxWorkflowNode,
  24. )
  25. from app.models.models import SysUser
  26. from app.schemas.bx.reimburse import (
  27. ReimburseApplyOut,
  28. ReimburseDetailOut,
  29. ReimburseInvoiceBriefOut,
  30. ReimburseInvoiceLinkIn,
  31. )
  32. from app.services.bx.approval_cycle import filter_current_cycle_approvals
  33. from app.services.bx.attachment import AttachmentService
  34. from app.services.bx.base import BxCrudService
  35. from app.services.bx.invoice_ledger import ENTRY_DEBIT_REIMBURSE, InvoiceLedgerService
  36. from app.services.bx_data_scope import apply_bx_scope
  37. from app.services.web_auth_roles import iter_user_menu_perm_codes
  38. from app.utils.bx_codegen import (
  39. allocate_reimburse_title_seq,
  40. generate_apply_code,
  41. format_reimburse_title,
  42. is_auto_reimburse_title,
  43. parse_reimburse_date,
  44. resolve_reimburse_title,
  45. )
  46. from app.utils.bx_date_range import current_month_bounds, resolve_create_time_bounds
  47. REIMBURSE_CHANGE_APPLICANT_PERM = "bx:reimburseApply:changeApplicant"
  48. _REIMBURSE_MY_STATS_PASSED_STATUSES = ("approved", "processed", "paid", "offset")
  49. class ReimburseService:
  50. @staticmethod
  51. def _tenant_no(db: Session, user: BxUserContext) -> str:
  52. return get_tenant_no(db, user.to_tenant_user_dict())
  53. @staticmethod
  54. def can_change_applicant(db: Session, user: BxUserContext) -> bool:
  55. """是否可选择他人作为报销申请人(超管或具备按钮权限)。"""
  56. if user.role == SUPER_ADMIN_ROLE:
  57. return True
  58. return REIMBURSE_CHANGE_APPLICANT_PERM in set(
  59. iter_user_menu_perm_codes(db, user.id, user.tenant_no)
  60. )
  61. @staticmethod
  62. def resolve_applicant(
  63. db: Session,
  64. user: BxUserContext,
  65. applicant_id: Optional[str],
  66. *,
  67. tenant_no: str,
  68. ) -> tuple[str, str]:
  69. """
  70. 解析报销申请人。
  71. 无 changeApplicant 权限时忽略客户端指定,强制本人;
  72. 有权限时可指定同租户有效用户。
  73. 返回 (applicant_id, applicant_name)。
  74. """
  75. requested = (applicant_id or "").strip() or None
  76. if not requested or requested == user.id:
  77. return user.id, (user.name or user.id)
  78. if not ReimburseService.can_change_applicant(db, user):
  79. return user.id, (user.name or user.id)
  80. target = (
  81. db.query(SysUser)
  82. .filter(
  83. SysUser.id == requested,
  84. SysUser.tenant_no == tenant_no,
  85. SysUser.del_flag == 0,
  86. SysUser.status == "1",
  87. )
  88. .first()
  89. )
  90. if not target:
  91. raise HTTPException(
  92. status_code=status.HTTP_400_BAD_REQUEST,
  93. detail="申请人不存在或已停用",
  94. )
  95. return target.id, (target.nick_name or target.user_no or target.id)
  96. @staticmethod
  97. def get_by_id(
  98. db: Session,
  99. reimburse_id: str,
  100. tenant_no: str,
  101. *,
  102. user: Optional[BxUserContext] = None,
  103. skip_scope: bool = False,
  104. ) -> Optional[BxReimburseApply]:
  105. return BxCrudService.get_by_id(
  106. BxReimburseApply, db, reimburse_id, tenant_no, user=user, skip_scope=skip_scope
  107. )
  108. @staticmethod
  109. def get_paged(
  110. db: Session,
  111. tenant_no: str,
  112. page: int,
  113. page_size: int,
  114. filters: Optional[List[Any]] = None,
  115. *,
  116. user: Optional[BxUserContext] = None,
  117. order_by: Optional[Any] = None,
  118. ) -> Tuple[Sequence[BxReimburseApply], int]:
  119. return BxCrudService.get_paged(
  120. BxReimburseApply,
  121. db,
  122. tenant_no,
  123. page,
  124. page_size,
  125. filters,
  126. user=user,
  127. order_by=order_by,
  128. )
  129. @staticmethod
  130. def soft_delete(db: Session, reimburse_id: str, *, user: BxUserContext) -> bool:
  131. tenant_no = ReimburseService._tenant_no(db, user)
  132. return BxCrudService.soft_delete(
  133. BxReimburseApply, db, reimburse_id, tenant_no, user_id=user.id, user=user
  134. )
  135. @staticmethod
  136. def _workflow_nodes(db: Session, wf_id: str, tenant_no: str) -> List[BxWorkflowNode]:
  137. return (
  138. db.query(BxWorkflowNode)
  139. .filter(
  140. BxWorkflowNode.wf_id == wf_id,
  141. BxWorkflowNode.tenant_no == tenant_no,
  142. BxWorkflowNode.del_flag == 0,
  143. )
  144. .order_by(BxWorkflowNode.node_order)
  145. .all()
  146. )
  147. @staticmethod
  148. def list_scope_filter(db: Session, user: BxUserContext, tenant_no: str, scope: str):
  149. """列头 facet 与列表视图对齐。
  150. - pending / approved:审批待办 / 我已审批
  151. - mine:我的申请(申请人或创建人)
  152. - payment_pending / payment_processed:付款待处理 / 已处理
  153. """
  154. key = (scope or "").strip()
  155. if key == "pending":
  156. return BxReimburseApply.id.in_(
  157. db.query(BxReimburseApply.id)
  158. .join(
  159. BxWorkflowNode,
  160. and_(
  161. BxWorkflowNode.wf_id == BxReimburseApply.wf_id,
  162. BxWorkflowNode.node_name == BxReimburseApply.current_node,
  163. BxWorkflowNode.tenant_no == tenant_no,
  164. BxWorkflowNode.del_flag == 0,
  165. ),
  166. )
  167. .filter(
  168. BxReimburseApply.del_flag == 0,
  169. BxReimburseApply.tenant_no == tenant_no,
  170. BxReimburseApply.status == "pending",
  171. BxWorkflowNode.approver_id == user.id,
  172. )
  173. )
  174. if key == "approved":
  175. return BxReimburseApply.id.in_(
  176. db.query(BxReimburseApproval.apply_id)
  177. .filter(
  178. BxReimburseApproval.approver_id == user.id,
  179. BxReimburseApproval.tenant_no == tenant_no,
  180. BxReimburseApproval.del_flag == 0,
  181. )
  182. .distinct()
  183. )
  184. if key == "mine":
  185. return or_(
  186. BxReimburseApply.applicant_id == user.id,
  187. BxReimburseApply.create_by == user.id,
  188. )
  189. if key == "payment_pending":
  190. return BxReimburseApply.status == "approved"
  191. if key == "payment_processed":
  192. return BxReimburseApply.status.in_(["processed", "paid", "offset"])
  193. raise ValueError(
  194. "不支持的 scope,仅支持 pending | approved | mine | payment_pending | payment_processed"
  195. )
  196. # 兼容旧名
  197. approval_list_scope_filter = list_scope_filter
  198. @staticmethod
  199. def pending_approval(
  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. extra_filters: Optional[list] = None,
  212. order_by: Optional[Any] = None,
  213. ):
  214. subquery = (
  215. db.query(BxReimburseApply.id)
  216. .join(
  217. BxWorkflowNode,
  218. and_(
  219. BxWorkflowNode.wf_id == BxReimburseApply.wf_id,
  220. BxWorkflowNode.node_name == BxReimburseApply.current_node,
  221. BxWorkflowNode.tenant_no == tenant_no,
  222. BxWorkflowNode.del_flag == 0,
  223. ),
  224. )
  225. .filter(
  226. BxReimburseApply.del_flag == 0,
  227. BxReimburseApply.tenant_no == tenant_no,
  228. BxReimburseApply.status == "pending",
  229. BxWorkflowNode.approver_id == user.id,
  230. )
  231. )
  232. if extra_filters:
  233. # Column filters from builder already include keyword / dates / etc.
  234. subquery = subquery.filter(*extra_filters)
  235. else:
  236. if keyword:
  237. kw_conds = [
  238. BxReimburseApply.apply_code.contains(keyword),
  239. BxReimburseApply.applicant_name.contains(keyword),
  240. BxReimburseApply.title.contains(keyword),
  241. ]
  242. expense_kw = ReimburseService.build_expense_name_condition(
  243. db, tenant_no, keyword
  244. )
  245. if expense_kw is not None:
  246. kw_conds.append(expense_kw)
  247. subquery = subquery.filter(or_(*kw_conds))
  248. if applicant_id:
  249. subquery = subquery.filter(BxReimburseApply.applicant_id == applicant_id)
  250. expense_filter = ReimburseService.build_expense_name_condition(
  251. db, tenant_no, expense_name
  252. )
  253. if expense_filter is not None:
  254. subquery = subquery.filter(expense_filter)
  255. start, end = resolve_create_time_bounds(
  256. apply_date=apply_date,
  257. apply_date_from=apply_date_from,
  258. apply_date_to=apply_date_to,
  259. )
  260. if start is not None:
  261. subquery = subquery.filter(BxReimburseApply.create_time >= start)
  262. if end is not None:
  263. subquery = subquery.filter(BxReimburseApply.create_time < end)
  264. subquery = subquery.subquery()
  265. total = db.query(func.count()).select_from(subquery).scalar() or 0
  266. order_expr = order_by if order_by is not None else BxReimburseApply.create_time.desc()
  267. rows = (
  268. db.query(BxReimburseApply)
  269. .join(subquery, BxReimburseApply.id == subquery.c.id)
  270. .order_by(order_expr)
  271. .offset((page - 1) * page_size)
  272. .limit(page_size)
  273. .all()
  274. )
  275. return rows, total
  276. @staticmethod
  277. def approved_by_me(
  278. db: Session,
  279. user: BxUserContext,
  280. tenant_no: str,
  281. page: int,
  282. page_size: int,
  283. keyword: str = "",
  284. applicant_id: Optional[str] = None,
  285. expense_name: Optional[str] = None,
  286. apply_date: Optional[str] = None,
  287. apply_date_from: Optional[str] = None,
  288. apply_date_to: Optional[str] = None,
  289. extra_filters: Optional[list] = None,
  290. order_by: Optional[Any] = None,
  291. ):
  292. subquery = (
  293. db.query(BxReimburseApproval.apply_id)
  294. .filter(
  295. BxReimburseApproval.approver_id == user.id,
  296. BxReimburseApproval.tenant_no == tenant_no,
  297. BxReimburseApproval.del_flag == 0,
  298. )
  299. .distinct()
  300. .subquery()
  301. )
  302. query = (
  303. db.query(BxReimburseApply)
  304. .join(subquery, BxReimburseApply.id == subquery.c.apply_id)
  305. .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
  306. )
  307. if extra_filters:
  308. query = query.filter(*extra_filters)
  309. else:
  310. if keyword:
  311. kw_conds = [
  312. BxReimburseApply.apply_code.contains(keyword),
  313. BxReimburseApply.applicant_name.contains(keyword),
  314. BxReimburseApply.title.contains(keyword),
  315. ]
  316. expense_kw = ReimburseService.build_expense_name_condition(
  317. db, tenant_no, keyword
  318. )
  319. if expense_kw is not None:
  320. kw_conds.append(expense_kw)
  321. query = query.filter(or_(*kw_conds))
  322. if applicant_id:
  323. query = query.filter(BxReimburseApply.applicant_id == applicant_id)
  324. expense_filter = ReimburseService.build_expense_name_condition(
  325. db, tenant_no, expense_name
  326. )
  327. if expense_filter is not None:
  328. query = query.filter(expense_filter)
  329. start, end = resolve_create_time_bounds(
  330. apply_date=apply_date,
  331. apply_date_from=apply_date_from,
  332. apply_date_to=apply_date_to,
  333. )
  334. if start is not None:
  335. query = query.filter(BxReimburseApply.create_time >= start)
  336. if end is not None:
  337. query = query.filter(BxReimburseApply.create_time < end)
  338. query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
  339. total = query.count()
  340. order_expr = order_by if order_by is not None else BxReimburseApply.create_time.desc()
  341. rows = (
  342. query.order_by(order_expr)
  343. .offset((page - 1) * page_size)
  344. .limit(page_size)
  345. .all()
  346. )
  347. return rows, total
  348. @staticmethod
  349. def _has_debit_reimburse(db: Session, tenant_no: str, reimburse_id: str) -> bool:
  350. return (
  351. db.query(BxInvoiceLedger.id)
  352. .filter(
  353. BxInvoiceLedger.tenant_no == tenant_no,
  354. BxInvoiceLedger.reimburse_id == reimburse_id,
  355. BxInvoiceLedger.entry_type == ENTRY_DEBIT_REIMBURSE,
  356. BxInvoiceLedger.del_flag == 0,
  357. )
  358. .first()
  359. is not None
  360. )
  361. @staticmethod
  362. def normalize_invoice_links(
  363. invoice_links: Optional[Sequence[ReimburseInvoiceLinkIn]] = None,
  364. invoice_ids: Optional[Sequence[str]] = None,
  365. ) -> Optional[List[Tuple[str, Optional[str]]]]:
  366. """归一化勾选链接;返回 None 表示未传(不改关联),[] 表示清空。"""
  367. if invoice_links is not None:
  368. normalized: List[Tuple[str, Optional[str]]] = []
  369. seen: set[Tuple[str, str]] = set()
  370. for link in invoice_links:
  371. invoice_id = str(link.invoice_id or "").strip()
  372. if not invoice_id:
  373. continue
  374. attachment_id = str(link.attachment_id or "").strip() or None
  375. key = (invoice_id, attachment_id or "")
  376. if key in seen:
  377. continue
  378. seen.add(key)
  379. normalized.append((invoice_id, attachment_id))
  380. return normalized
  381. if invoice_ids is not None:
  382. normalized_ids: List[Tuple[str, Optional[str]]] = []
  383. seen_ids: set[str] = set()
  384. for raw_id in invoice_ids:
  385. invoice_id = str(raw_id or "").strip()
  386. if not invoice_id or invoice_id in seen_ids:
  387. continue
  388. seen_ids.add(invoice_id)
  389. normalized_ids.append((invoice_id, None))
  390. return normalized_ids
  391. return None
  392. @staticmethod
  393. def sync_reimburse_invoices(
  394. db: Session,
  395. reimburse_id: str,
  396. invoice_ids: Optional[List[str]],
  397. tenant_no: str,
  398. user: BxUserContext,
  399. *,
  400. applicant_id: str,
  401. invoice_links: Optional[Sequence[ReimburseInvoiceLinkIn]] = None,
  402. ) -> None:
  403. links = ReimburseService.normalize_invoice_links(invoice_links, invoice_ids)
  404. if links is None:
  405. return
  406. invoice_id_list = list({invoice_id for invoice_id, _ in links})
  407. if invoice_id_list:
  408. invoices = (
  409. db.query(BxInvoice)
  410. .filter(
  411. BxInvoice.tenant_no == tenant_no,
  412. BxInvoice.id.in_(invoice_id_list),
  413. BxInvoice.del_flag == 0,
  414. )
  415. .all()
  416. )
  417. invoice_map = {inv.id: inv for inv in invoices}
  418. for invoice_id in invoice_id_list:
  419. invoice = invoice_map.get(invoice_id)
  420. if not invoice:
  421. raise HTTPException(
  422. status_code=status.HTTP_400_BAD_REQUEST,
  423. detail=f"发票 {invoice_id} 不存在",
  424. )
  425. if invoice.owner_user_id != applicant_id:
  426. raise HTTPException(
  427. status_code=status.HTTP_400_BAD_REQUEST,
  428. detail=f"发票 {invoice_id} 不属于申请人",
  429. )
  430. attachment_ids = [aid for _, aid in links if aid]
  431. attachment_map: Dict[str, BxAttachment] = {}
  432. if attachment_ids:
  433. attachments = (
  434. db.query(BxAttachment)
  435. .filter(
  436. BxAttachment.id.in_(attachment_ids),
  437. BxAttachment.biz_type == "invoice",
  438. BxAttachment.tenant_no == tenant_no,
  439. BxAttachment.del_flag == 0,
  440. )
  441. .all()
  442. )
  443. attachment_map = {att.id: att for att in attachments}
  444. for invoice_id, attachment_id in links:
  445. if not attachment_id:
  446. continue
  447. att = attachment_map.get(attachment_id)
  448. if not att:
  449. raise HTTPException(
  450. status_code=status.HTTP_400_BAD_REQUEST,
  451. detail=f"发票附件 {attachment_id} 不存在",
  452. )
  453. if att.biz_id != invoice_id:
  454. raise HTTPException(
  455. status_code=status.HTTP_400_BAD_REQUEST,
  456. detail=f"附件 {attachment_id} 不属于发票 {invoice_id}",
  457. )
  458. db.query(BxReimburseInvoice).filter(
  459. BxReimburseInvoice.reimburse_id == reimburse_id,
  460. BxReimburseInvoice.tenant_no == tenant_no,
  461. BxReimburseInvoice.del_flag == 0,
  462. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  463. for invoice_id, attachment_id in links:
  464. db.add(
  465. BxReimburseInvoice(
  466. id=generate_uuid(),
  467. reimburse_id=reimburse_id,
  468. invoice_id=invoice_id,
  469. attachment_id=attachment_id,
  470. tenant_no=tenant_no,
  471. create_by=user.id,
  472. create_dept_id=user.dept_id or "",
  473. )
  474. )
  475. db.flush()
  476. @staticmethod
  477. def get_linked_invoice_ids(db: Session, reimburse_id: str, tenant_no: str) -> List[str]:
  478. rows = (
  479. db.query(BxReimburseInvoice.invoice_id)
  480. .filter(
  481. BxReimburseInvoice.reimburse_id == reimburse_id,
  482. BxReimburseInvoice.tenant_no == tenant_no,
  483. BxReimburseInvoice.del_flag == 0,
  484. )
  485. .order_by(BxReimburseInvoice.create_time.asc())
  486. .all()
  487. )
  488. # 同一发票多附件时去重,保持首次出现顺序
  489. seen: set[str] = set()
  490. ordered: List[str] = []
  491. for (invoice_id,) in rows:
  492. if invoice_id in seen:
  493. continue
  494. seen.add(invoice_id)
  495. ordered.append(invoice_id)
  496. return ordered
  497. @staticmethod
  498. def get_linked_invoices_brief(
  499. db: Session, reimburse_id: str, tenant_no: str
  500. ) -> List[ReimburseInvoiceBriefOut]:
  501. rows = (
  502. db.query(BxReimburseInvoice, BxInvoice)
  503. .join(
  504. BxInvoice,
  505. and_(
  506. BxInvoice.id == BxReimburseInvoice.invoice_id,
  507. BxInvoice.tenant_no == tenant_no,
  508. BxInvoice.del_flag == 0,
  509. ),
  510. )
  511. .filter(
  512. BxReimburseInvoice.reimburse_id == reimburse_id,
  513. BxReimburseInvoice.tenant_no == tenant_no,
  514. BxReimburseInvoice.del_flag == 0,
  515. )
  516. .order_by(BxReimburseInvoice.create_time.asc())
  517. .all()
  518. )
  519. if not rows:
  520. return []
  521. explicit_att_ids = [
  522. link.attachment_id for link, _ in rows if (link.attachment_id or "").strip()
  523. ]
  524. invoice_ids_need_fallback = [
  525. invoice.id
  526. for link, invoice in rows
  527. if not (link.attachment_id or "").strip()
  528. ]
  529. att_by_id: Dict[str, BxAttachment] = {}
  530. if explicit_att_ids:
  531. for att in (
  532. db.query(BxAttachment)
  533. .filter(
  534. BxAttachment.id.in_(explicit_att_ids),
  535. BxAttachment.tenant_no == tenant_no,
  536. BxAttachment.del_flag == 0,
  537. )
  538. .all()
  539. ):
  540. att_by_id[att.id] = att
  541. first_att_by_invoice: Dict[str, BxAttachment] = {}
  542. if invoice_ids_need_fallback:
  543. fallback_atts = (
  544. db.query(BxAttachment)
  545. .filter(
  546. BxAttachment.biz_type == "invoice",
  547. BxAttachment.biz_id.in_(invoice_ids_need_fallback),
  548. BxAttachment.tenant_no == tenant_no,
  549. BxAttachment.del_flag == 0,
  550. )
  551. .order_by(BxAttachment.sort_order.asc(), BxAttachment.create_time.asc())
  552. .all()
  553. )
  554. for att in fallback_atts:
  555. first_att_by_invoice.setdefault(att.biz_id, att)
  556. result: List[ReimburseInvoiceBriefOut] = []
  557. sort_keys: List[Tuple[datetime, str]] = []
  558. for link, invoice in rows:
  559. att: Optional[BxAttachment] = None
  560. aid = (link.attachment_id or "").strip()
  561. if aid:
  562. att = att_by_id.get(aid)
  563. if att is None:
  564. att = first_att_by_invoice.get(invoice.id)
  565. # 固定顺序:附件上传时间优先,否则发票创建时间;同时间按附件/发票 ID
  566. ts = att.create_time if att and att.create_time else invoice.create_time
  567. sort_keys.append((ts or datetime.min, str(att.id if att else invoice.id)))
  568. result.append(
  569. ReimburseInvoiceBriefOut(
  570. id=invoice.id,
  571. amount=float(invoice.amount),
  572. owner_user_name=invoice.owner_user_name,
  573. remark=invoice.remark,
  574. attachment_id=att.id if att else (aid or None),
  575. file_name=(att.file_name if att else None),
  576. file_type=(att.file_type if att else None),
  577. create_time=ts,
  578. )
  579. )
  580. # 与发票夹选择弹窗一致:按时间从新到旧
  581. ordered = sorted(
  582. range(len(result)),
  583. key=lambda i: (sort_keys[i][0], sort_keys[i][1]),
  584. reverse=True,
  585. )
  586. return [result[i] for i in ordered]
  587. @staticmethod
  588. def get_linked_invoice_ids_map(
  589. db: Session, reimburse_ids: List[str], tenant_no: str
  590. ) -> Dict[str, List[str]]:
  591. if not reimburse_ids:
  592. return {}
  593. rows = (
  594. db.query(BxReimburseInvoice.reimburse_id, BxReimburseInvoice.invoice_id)
  595. .filter(
  596. BxReimburseInvoice.reimburse_id.in_(reimburse_ids),
  597. BxReimburseInvoice.tenant_no == tenant_no,
  598. BxReimburseInvoice.del_flag == 0,
  599. )
  600. .order_by(BxReimburseInvoice.create_time.asc())
  601. .all()
  602. )
  603. grouped: Dict[str, List[str]] = {}
  604. for reimburse_id, invoice_id in rows:
  605. bucket = grouped.setdefault(reimburse_id, [])
  606. if invoice_id not in bucket:
  607. bucket.append(invoice_id)
  608. return grouped
  609. @staticmethod
  610. def submit(
  611. db: Session,
  612. apply_id: str,
  613. user: BxUserContext,
  614. *,
  615. invoice_ids: Optional[List[str]] = None,
  616. invoice_links: Optional[Sequence[ReimburseInvoiceLinkIn]] = None,
  617. ) -> Optional[BxReimburseApply]:
  618. tenant_no = ReimburseService._tenant_no(db, user)
  619. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  620. if not apply_obj:
  621. return None
  622. if invoice_links is not None or invoice_ids is not None:
  623. ReimburseService.sync_reimburse_invoices(
  624. db,
  625. apply_id,
  626. invoice_ids,
  627. tenant_no,
  628. user,
  629. applicant_id=apply_obj.applicant_id,
  630. invoice_links=invoice_links,
  631. )
  632. if not ReimburseService._has_debit_reimburse(db, tenant_no, apply_id):
  633. amount = Decimal(str(apply_obj.amount or 0))
  634. InvoiceLedgerService.debit_reimburse(
  635. db,
  636. tenant_no=tenant_no,
  637. owner_user_id=apply_obj.applicant_id,
  638. amount=amount,
  639. reimburse_id=apply_id,
  640. user=user,
  641. )
  642. apply_obj.status = "pending"
  643. if not apply_obj.apply_code:
  644. apply_obj.apply_code = generate_apply_code(db, "BX", tenant_no)
  645. apply_obj.update_by = user.id
  646. if apply_obj.wf_id:
  647. nodes = ReimburseService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
  648. if nodes:
  649. apply_obj.current_node = nodes[0].node_name
  650. db.flush()
  651. return apply_obj
  652. @staticmethod
  653. def approve(
  654. db: Session, apply_id: str, approver: BxUserContext, opinion: str
  655. ) -> Optional[BxReimburseApply]:
  656. tenant_no = ReimburseService._tenant_no(db, approver)
  657. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
  658. if not apply_obj or not apply_obj.wf_id:
  659. return None
  660. nodes = ReimburseService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
  661. if not nodes:
  662. return None
  663. current_idx = None
  664. for i, node in enumerate(nodes):
  665. if node.node_name == apply_obj.current_node:
  666. current_idx = i
  667. break
  668. if current_idx is None:
  669. return None
  670. current_node = nodes[current_idx]
  671. if current_node.approver_id != approver.id:
  672. return None
  673. approval = BxReimburseApproval(
  674. id=generate_uuid(),
  675. apply_id=apply_id,
  676. approver_id=approver.id,
  677. approver_name=approver.name,
  678. approval_opinion=opinion,
  679. node_name=current_node.node_name,
  680. node_order=current_node.node_order,
  681. approval_status="approved",
  682. approval_time=datetime.now(),
  683. tenant_no=tenant_no,
  684. create_by=approver.id,
  685. create_dept_id=approver.dept_id or "",
  686. )
  687. db.add(approval)
  688. if current_idx == len(nodes) - 1:
  689. apply_obj.status = "approved"
  690. apply_obj.current_node = None
  691. else:
  692. apply_obj.current_node = nodes[current_idx + 1].node_name
  693. apply_obj.update_by = approver.id
  694. db.flush()
  695. return apply_obj
  696. @staticmethod
  697. def reject(
  698. db: Session, apply_id: str, approver: BxUserContext, opinion: str
  699. ) -> Optional[BxReimburseApply]:
  700. tenant_no = ReimburseService._tenant_no(db, approver)
  701. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
  702. if not apply_obj or not apply_obj.wf_id:
  703. return None
  704. nodes = ReimburseService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
  705. current_node = None
  706. for node in nodes:
  707. if node.node_name == apply_obj.current_node:
  708. current_node = node
  709. break
  710. if current_node is None or current_node.approver_id != approver.id:
  711. return None
  712. approval = BxReimburseApproval(
  713. id=generate_uuid(),
  714. apply_id=apply_id,
  715. approver_id=approver.id,
  716. approver_name=approver.name,
  717. approval_opinion=opinion,
  718. node_name=current_node.node_name,
  719. node_order=current_node.node_order,
  720. approval_status="rejected",
  721. approval_time=datetime.now(),
  722. tenant_no=tenant_no,
  723. create_by=approver.id,
  724. create_dept_id=approver.dept_id or "",
  725. )
  726. db.add(approval)
  727. apply_obj.status = "rejected"
  728. apply_obj.current_node = None
  729. apply_obj.update_by = approver.id
  730. InvoiceLedgerService.release_reimburse(
  731. db, tenant_no=tenant_no, reimburse_id=apply_id, user=approver
  732. )
  733. db.flush()
  734. return apply_obj
  735. @staticmethod
  736. def withdraw(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxReimburseApply]:
  737. tenant_no = ReimburseService._tenant_no(db, user)
  738. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  739. if not apply_obj:
  740. return None
  741. if apply_obj.applicant_id != user.id:
  742. return None
  743. if apply_obj.status != "pending":
  744. return None
  745. apply_obj.status = "draft"
  746. apply_obj.current_node = None
  747. apply_obj.update_by = user.id
  748. InvoiceLedgerService.release_reimburse(
  749. db, tenant_no=tenant_no, reimburse_id=apply_id, user=user
  750. )
  751. db.flush()
  752. return apply_obj
  753. @staticmethod
  754. def _approval_record_dict(approval: BxReimburseApproval, apply_id: str) -> Dict[str, Any]:
  755. return {
  756. "id": approval.id,
  757. "reimburse_id": apply_id,
  758. "node_name": approval.node_name,
  759. "node_order": approval.node_order,
  760. "approval_status": approval.approval_status,
  761. "approval_opinion": approval.approval_opinion,
  762. "approver_name": approval.approver_name,
  763. "approval_time": approval.approval_time,
  764. }
  765. @staticmethod
  766. def _latest_approval_per_node(
  767. approvals: Sequence[BxReimburseApproval],
  768. ) -> Dict[int, BxReimburseApproval]:
  769. """同一节点多次审批时,取 approval_time 最新的一条作为当前节点状态。"""
  770. approval_map: Dict[int, BxReimburseApproval] = {}
  771. for approval in approvals:
  772. existing = approval_map.get(approval.node_order)
  773. if existing is None:
  774. approval_map[approval.node_order] = approval
  775. continue
  776. approval_time = approval.approval_time or datetime.min
  777. existing_time = existing.approval_time or datetime.min
  778. if approval_time >= existing_time:
  779. approval_map[approval.node_order] = approval
  780. return approval_map
  781. @staticmethod
  782. def _query_approvals(db: Session, apply_id: str, tenant_no: str) -> List[BxReimburseApproval]:
  783. return (
  784. db.query(BxReimburseApproval)
  785. .filter(
  786. BxReimburseApproval.apply_id == apply_id,
  787. BxReimburseApproval.tenant_no == tenant_no,
  788. BxReimburseApproval.del_flag == 0,
  789. )
  790. .all()
  791. )
  792. @staticmethod
  793. def get_approvals(
  794. db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
  795. ) -> List[Dict[str, Any]]:
  796. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  797. if not apply_obj or not apply_obj.wf_id:
  798. return []
  799. wf = BxCrudService.get_by_id(BxWorkflowDef, db, apply_obj.wf_id, tenant_no)
  800. if not wf:
  801. return []
  802. nodes = ReimburseService._workflow_nodes(db, wf.id, tenant_no)
  803. if not nodes:
  804. return []
  805. approvals = ReimburseService._query_approvals(db, apply_id, tenant_no)
  806. cycle_approvals = filter_current_cycle_approvals(approvals, apply_obj.status)
  807. approval_map = ReimburseService._latest_approval_per_node(cycle_approvals)
  808. result: List[Dict[str, Any]] = []
  809. for node in nodes:
  810. approval = approval_map.get(node.node_order)
  811. if approval:
  812. result.append(ReimburseService._approval_record_dict(approval, apply_id))
  813. else:
  814. result.append(
  815. {
  816. "id": "0",
  817. "reimburse_id": apply_id,
  818. "node_name": node.node_name,
  819. "node_order": node.node_order,
  820. "approval_status": "pending",
  821. "approval_opinion": None,
  822. "approver_name": node.approver_name or "",
  823. "approval_time": None,
  824. }
  825. )
  826. return result
  827. @staticmethod
  828. def get_approval_history(
  829. db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
  830. ) -> List[Dict[str, Any]]:
  831. """返回全部审批动作,按时间正序排列,用于展示完整审批历史。"""
  832. if not ReimburseService.get_by_id(db, apply_id, tenant_no, user=user):
  833. return []
  834. approvals = (
  835. db.query(BxReimburseApproval)
  836. .filter(
  837. BxReimburseApproval.apply_id == apply_id,
  838. BxReimburseApproval.tenant_no == tenant_no,
  839. BxReimburseApproval.del_flag == 0,
  840. )
  841. .order_by(BxReimburseApproval.approval_time.asc(), BxReimburseApproval.create_time.asc())
  842. .all()
  843. )
  844. return [ReimburseService._approval_record_dict(a, apply_id) for a in approvals]
  845. @staticmethod
  846. def create_with_details(
  847. db: Session,
  848. obj_in: dict,
  849. details: List[dict],
  850. *,
  851. user: BxUserContext,
  852. applicant_id: Optional[str] = None,
  853. applicant_name: Optional[str] = None,
  854. ) -> BxReimburseApply:
  855. tenant_no = ReimburseService._tenant_no(db, user)
  856. data = dict(obj_in)
  857. total_amount = sum(float(d.get("amount", 0) or 0) for d in details)
  858. reimburse_date = parse_reimburse_date(data.pop("reimburse_date", None))
  859. requested_title = data.pop("title", None)
  860. first_detail = details[0] if details else {}
  861. resolved_id = applicant_id or user.id
  862. resolved_name = applicant_name or user.name
  863. apply_obj: BxReimburseApply | None = None
  864. # 去掉调用方预生成的单号,在重试循环内重新取号(避免撞 uk_apply_code 后仍用旧号)
  865. data.pop("apply_code", None)
  866. for attempt in range(5):
  867. title, title_seq = resolve_reimburse_title(
  868. db, resolved_name, tenant_no, reimburse_date, requested_title
  869. )
  870. apply_obj = BxReimburseApply(
  871. id=generate_uuid(),
  872. apply_code=generate_apply_code(db, "BX", tenant_no),
  873. title=title,
  874. reimburse_date=reimburse_date,
  875. title_seq=title_seq,
  876. amount=total_amount,
  877. expense_id=str(first_detail.get("expense_id") or ""),
  878. expense_name=first_detail.get("expense_name") or "",
  879. applicant_id=resolved_id,
  880. applicant_name=resolved_name,
  881. tenant_no=tenant_no,
  882. create_by=user.id,
  883. create_dept_id=user.dept_id or "",
  884. **data,
  885. )
  886. db.add(apply_obj)
  887. try:
  888. db.flush()
  889. break
  890. except IntegrityError:
  891. db.rollback()
  892. if attempt >= 4:
  893. raise
  894. continue
  895. if apply_obj is None:
  896. raise RuntimeError("报销申请创建失败")
  897. for d in details:
  898. detail = BxReimburseDetail(
  899. id=generate_uuid(),
  900. reimburse_id=apply_obj.id,
  901. expense_id=str(d["expense_id"]),
  902. expense_name=d["expense_name"],
  903. amount=d["amount"],
  904. remark=d.get("remark"),
  905. occur_date=d.get("occur_date"),
  906. tenant_no=tenant_no,
  907. create_by=user.id,
  908. create_dept_id=user.dept_id or "",
  909. )
  910. db.add(detail)
  911. db.flush()
  912. return apply_obj
  913. @staticmethod
  914. def update_with_details(
  915. db: Session,
  916. apply_id: str,
  917. obj_in: dict,
  918. details: Optional[List[dict]],
  919. *,
  920. user: BxUserContext,
  921. ) -> Optional[BxReimburseApply]:
  922. tenant_no = ReimburseService._tenant_no(db, user)
  923. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  924. if not apply_obj:
  925. return None
  926. reimburse_date = None
  927. date_changed = False
  928. if "reimburse_date" in obj_in:
  929. reimburse_date = parse_reimburse_date(obj_in.pop("reimburse_date"))
  930. date_changed = reimburse_date != apply_obj.reimburse_date
  931. requested_title = obj_in.pop("title", None)
  932. prev_applicant_id = apply_obj.applicant_id
  933. for key, value in obj_in.items():
  934. setattr(apply_obj, key, value)
  935. if reimburse_date is not None:
  936. apply_obj.reimburse_date = reimburse_date
  937. if requested_title is not None:
  938. apply_obj.title = str(requested_title).strip()
  939. applicant_changed = apply_obj.applicant_id != prev_applicant_id
  940. should_regen_title = (date_changed or applicant_changed) and is_auto_reimburse_title(
  941. apply_obj.title
  942. )
  943. if should_regen_title:
  944. seq = allocate_reimburse_title_seq(
  945. db, tenant_no, apply_obj.reimburse_date, exclude_apply_id=apply_id
  946. )
  947. apply_obj.title_seq = seq
  948. apply_obj.title = format_reimburse_title(
  949. apply_obj.applicant_name, apply_obj.reimburse_date, seq
  950. )
  951. elif requested_title is not None and not is_auto_reimburse_title(apply_obj.title):
  952. apply_obj.title_seq = None
  953. if details is not None:
  954. db.query(BxReimburseDetail).filter(
  955. BxReimburseDetail.reimburse_id == apply_id,
  956. BxReimburseDetail.tenant_no == tenant_no,
  957. BxReimburseDetail.del_flag == 0,
  958. ).update({"del_flag": 1}, synchronize_session=False)
  959. total_amount = Decimal("0")
  960. first_expense_name = ""
  961. first_expense_id = ""
  962. for i, d in enumerate(details):
  963. detail = BxReimburseDetail(
  964. id=generate_uuid(),
  965. reimburse_id=apply_id,
  966. expense_id=str(d["expense_id"]),
  967. expense_name=d["expense_name"],
  968. amount=d["amount"],
  969. remark=d.get("remark"),
  970. occur_date=d.get("occur_date"),
  971. tenant_no=tenant_no,
  972. create_by=user.id,
  973. create_dept_id=user.dept_id or "",
  974. )
  975. db.add(detail)
  976. total_amount += Decimal(str(d["amount"]))
  977. if i == 0:
  978. first_expense_name = d["expense_name"]
  979. first_expense_id = str(d["expense_id"])
  980. apply_obj.amount = total_amount
  981. apply_obj.expense_name = first_expense_name
  982. apply_obj.expense_id = first_expense_id
  983. apply_obj.update_by = user.id
  984. db.flush()
  985. return apply_obj
  986. @staticmethod
  987. def get_details(db: Session, reimburse_id: str, tenant_no: str) -> List[BxReimburseDetail]:
  988. return (
  989. db.query(BxReimburseDetail)
  990. .filter(
  991. BxReimburseDetail.reimburse_id == reimburse_id,
  992. BxReimburseDetail.tenant_no == tenant_no,
  993. BxReimburseDetail.del_flag == 0,
  994. )
  995. .order_by(BxReimburseDetail.create_time)
  996. .all()
  997. )
  998. @staticmethod
  999. def detail_expense_keyword_match(name_kw: str):
  1000. """明细行:费用项目名称或发生日期(YYYY-MM-DD)模糊包含。"""
  1001. return or_(
  1002. BxReimburseDetail.expense_name.contains(name_kw),
  1003. and_(
  1004. BxReimburseDetail.occur_date.isnot(None),
  1005. func.date_format(BxReimburseDetail.occur_date, "%Y-%m-%d").contains(name_kw),
  1006. ),
  1007. )
  1008. @staticmethod
  1009. def build_expense_name_condition(
  1010. db: Session, tenant_no: str, expense_name: Optional[str]
  1011. ):
  1012. name_kw = str(expense_name or "").strip()
  1013. if not name_kw:
  1014. return None
  1015. detail_reimburse_ids = (
  1016. db.query(BxReimburseDetail.reimburse_id)
  1017. .filter(
  1018. BxReimburseDetail.tenant_no == tenant_no,
  1019. BxReimburseDetail.del_flag == 0,
  1020. ReimburseService.detail_expense_keyword_match(name_kw),
  1021. )
  1022. )
  1023. return or_(
  1024. BxReimburseApply.expense_name.contains(name_kw),
  1025. BxReimburseApply.id.in_(detail_reimburse_ids),
  1026. )
  1027. @staticmethod
  1028. def build_occur_date_range_condition(
  1029. db: Session,
  1030. tenant_no: str,
  1031. occur_date_from: Optional[str] = None,
  1032. occur_date_to: Optional[str] = None,
  1033. ):
  1034. """明细发生日期区间:任一明细落在 [from, to](含端点,按日)即命中单据。"""
  1035. from app.utils.bx_date_range import resolve_create_time_bounds
  1036. start, end_exclusive = resolve_create_time_bounds(
  1037. apply_date_from=occur_date_from,
  1038. apply_date_to=occur_date_to,
  1039. )
  1040. if start is None and end_exclusive is None:
  1041. return None
  1042. conds = [
  1043. BxReimburseDetail.tenant_no == tenant_no,
  1044. BxReimburseDetail.del_flag == 0,
  1045. BxReimburseDetail.occur_date.isnot(None),
  1046. ]
  1047. if start is not None:
  1048. conds.append(BxReimburseDetail.occur_date >= start.date())
  1049. if end_exclusive is not None:
  1050. # resolve 返回不含止;Date 列用 < next_day 等价于 <= to 日
  1051. conds.append(BxReimburseDetail.occur_date < end_exclusive.date())
  1052. detail_reimburse_ids = db.query(BxReimburseDetail.reimburse_id).filter(*conds)
  1053. return BxReimburseApply.id.in_(detail_reimburse_ids)
  1054. @staticmethod
  1055. def get_details_map_by_reimburse_ids(
  1056. db: Session, reimburse_ids: List[str], tenant_no: str
  1057. ) -> Dict[str, List[BxReimburseDetail]]:
  1058. if not reimburse_ids:
  1059. return {}
  1060. detail_rows = (
  1061. db.query(BxReimburseDetail)
  1062. .filter(
  1063. BxReimburseDetail.reimburse_id.in_(reimburse_ids),
  1064. BxReimburseDetail.tenant_no == tenant_no,
  1065. BxReimburseDetail.del_flag == 0,
  1066. )
  1067. .order_by(BxReimburseDetail.create_time)
  1068. .all()
  1069. )
  1070. grouped: Dict[str, List[BxReimburseDetail]] = {}
  1071. for detail in detail_rows:
  1072. grouped.setdefault(detail.reimburse_id, []).append(detail)
  1073. return grouped
  1074. @staticmethod
  1075. def out_rows_with_attachment_counts(
  1076. db: Session, tenant_no: str, rows: List[BxReimburseApply]
  1077. ) -> List[ReimburseApplyOut]:
  1078. if not rows:
  1079. return []
  1080. ids = [r.id for r in rows]
  1081. counts = AttachmentService.count_invoice_attach_by_biz_ids(
  1082. db, "reimburse_apply", ids, tenant_no
  1083. )
  1084. details_map = ReimburseService.get_details_map_by_reimburse_ids(db, ids, tenant_no)
  1085. invoice_ids_map = ReimburseService.get_linked_invoice_ids_map(db, ids, tenant_no)
  1086. out_list: List[ReimburseApplyOut] = []
  1087. for row in rows:
  1088. out = ReimburseApplyOut.model_validate(row)
  1089. inv, att = counts.get(row.id, (0, 0))
  1090. detail_rows = details_map.get(row.id, [])
  1091. linked_ids = invoice_ids_map.get(row.id, [])
  1092. out_list.append(
  1093. out.model_copy(
  1094. update={
  1095. "invoice_count": inv,
  1096. "attach_count": att,
  1097. "details": [ReimburseDetailOut.model_validate(d) for d in detail_rows],
  1098. "invoice_ids": linked_ids,
  1099. }
  1100. )
  1101. )
  1102. return out_list
  1103. @staticmethod
  1104. def out_one_with_attachment_counts(
  1105. db: Session, tenant_no: str, row: BxReimburseApply
  1106. ) -> ReimburseApplyOut:
  1107. return ReimburseService.out_rows_with_attachment_counts(db, tenant_no, [row])[0]
  1108. @staticmethod
  1109. def build_apply_out_with_details(
  1110. db: Session, tenant_no: str, reimburse: BxReimburseApply
  1111. ) -> ReimburseApplyOut:
  1112. out = ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse)
  1113. detail_rows = ReimburseService.get_details(db, reimburse.id, tenant_no)
  1114. out.details = [ReimburseDetailOut.model_validate(d) for d in detail_rows]
  1115. out.invoice_ids = ReimburseService.get_linked_invoice_ids(db, reimburse.id, tenant_no)
  1116. out.invoices = ReimburseService.get_linked_invoices_brief(db, reimburse.id, tenant_no)
  1117. return out
  1118. @staticmethod
  1119. def global_status_counts(db: Session, tenant_no: str, *, user: BxUserContext) -> Dict[str, int]:
  1120. query = db.query(BxReimburseApply.status, func.count()).filter(
  1121. BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no
  1122. )
  1123. query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
  1124. rows = query.group_by(BxReimburseApply.status).all()
  1125. return {str(s or ""): int(c) for s, c in rows}
  1126. @staticmethod
  1127. def _my_apply_base(db: Session, user: BxUserContext, tenant_no: str):
  1128. query = db.query(BxReimburseApply).filter(
  1129. BxReimburseApply.del_flag == 0,
  1130. BxReimburseApply.tenant_no == tenant_no,
  1131. or_(
  1132. BxReimburseApply.applicant_id == user.id,
  1133. BxReimburseApply.create_by == user.id,
  1134. ),
  1135. )
  1136. return apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
  1137. @staticmethod
  1138. def my_apply_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
  1139. base_q = ReimburseService._my_apply_base(db, user, tenant_no).subquery()
  1140. base = BxReimburseApply.id.in_(db.query(base_q.c.id))
  1141. total_count = (
  1142. db.query(func.count()).select_from(BxReimburseApply).filter(base).scalar() or 0
  1143. )
  1144. pending = (
  1145. db.query(func.count())
  1146. .select_from(BxReimburseApply)
  1147. .filter(base, BxReimburseApply.status == "pending")
  1148. .scalar()
  1149. or 0
  1150. )
  1151. approved_count = (
  1152. db.query(func.count())
  1153. .select_from(BxReimburseApply)
  1154. .filter(base, BxReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
  1155. .scalar()
  1156. or 0
  1157. )
  1158. rejected_count = (
  1159. db.query(func.count())
  1160. .select_from(BxReimburseApply)
  1161. .filter(base, BxReimburseApply.status == "rejected")
  1162. .scalar()
  1163. or 0
  1164. )
  1165. approved_amount = (
  1166. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1167. .filter(base, BxReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
  1168. .scalar()
  1169. or 0
  1170. )
  1171. m0, m1 = current_month_bounds()
  1172. month_apply_amount = (
  1173. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1174. .filter(base, BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  1175. .scalar()
  1176. or 0
  1177. )
  1178. month_apply_count = (
  1179. db.query(func.count())
  1180. .select_from(BxReimburseApply)
  1181. .filter(base, BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  1182. .scalar()
  1183. or 0
  1184. )
  1185. return {
  1186. "total_count": int(total_count),
  1187. "pending_count": int(pending),
  1188. "approved_count": int(approved_count),
  1189. "rejected_count": int(rejected_count),
  1190. "approved_amount": float(approved_amount),
  1191. "month_apply_amount": float(month_apply_amount),
  1192. "month_apply_count": int(month_apply_count),
  1193. }
  1194. @staticmethod
  1195. def pending_approval_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
  1196. subquery = (
  1197. db.query(BxReimburseApply.id)
  1198. .join(
  1199. BxWorkflowNode,
  1200. and_(
  1201. BxWorkflowNode.wf_id == BxReimburseApply.wf_id,
  1202. BxWorkflowNode.node_name == BxReimburseApply.current_node,
  1203. BxWorkflowNode.tenant_no == tenant_no,
  1204. BxWorkflowNode.del_flag == 0,
  1205. ),
  1206. )
  1207. .filter(
  1208. BxReimburseApply.del_flag == 0,
  1209. BxReimburseApply.tenant_no == tenant_no,
  1210. BxReimburseApply.status == "pending",
  1211. BxWorkflowNode.approver_id == user.id,
  1212. )
  1213. ).subquery()
  1214. total = db.query(func.count()).select_from(subquery).scalar() or 0
  1215. m0, m1 = current_month_bounds()
  1216. month_sum = (
  1217. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1218. .join(subquery, BxReimburseApply.id == subquery.c.id)
  1219. .filter(BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  1220. .scalar()
  1221. or 0
  1222. )
  1223. return {"pending_total": int(total), "month_pending_amount": float(month_sum)}
  1224. @staticmethod
  1225. def pay(
  1226. db: Session,
  1227. apply_id: str,
  1228. user: BxUserContext,
  1229. pay_remark: str = "",
  1230. actual_amount: Optional[float] = None,
  1231. ) -> Optional[BxReimburseApply]:
  1232. tenant_no = ReimburseService._tenant_no(db, user)
  1233. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  1234. if not apply_obj:
  1235. return None
  1236. apply_obj.status = "processed"
  1237. apply_obj.update_by = user.id
  1238. apply_obj.pay_status = "paid"
  1239. apply_obj.pay_time = datetime.now()
  1240. apply_obj.pay_user_id = user.id
  1241. apply_obj.pay_user_name = user.name
  1242. if actual_amount is not None:
  1243. apply_obj.actual_amount = actual_amount
  1244. db.flush()
  1245. return apply_obj
  1246. @staticmethod
  1247. def offset(
  1248. db: Session,
  1249. apply_id: str,
  1250. advance_id: str,
  1251. amount: float,
  1252. user: BxUserContext,
  1253. remark: str = "",
  1254. ) -> Optional[BxReimburseApply]:
  1255. tenant_no = ReimburseService._tenant_no(db, user)
  1256. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  1257. if not apply_obj:
  1258. return None
  1259. apply_obj.status = "offset"
  1260. apply_obj.offset_advance_id = advance_id
  1261. apply_obj.offset_amount = amount
  1262. apply_obj.update_by = user.id
  1263. offset_record = BxReimburseOffset(
  1264. id=generate_uuid(),
  1265. reimburse_id=apply_id,
  1266. advance_id=advance_id,
  1267. offset_amount=amount,
  1268. remark=remark or None,
  1269. tenant_no=tenant_no,
  1270. create_by=user.name,
  1271. create_dept_id=user.dept_id or "",
  1272. )
  1273. db.add(offset_record)
  1274. db.flush()
  1275. return apply_obj
  1276. @staticmethod
  1277. def batch_offset(
  1278. db: Session,
  1279. apply_id: str,
  1280. items: List[dict],
  1281. pay_amount: float,
  1282. user: BxUserContext,
  1283. remark: str = "",
  1284. ) -> Optional[BxReimburseApply]:
  1285. tenant_no = ReimburseService._tenant_no(db, user)
  1286. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  1287. if not apply_obj:
  1288. return None
  1289. has_offsets = bool(items)
  1290. total_offset = 0.0
  1291. first_advance_id = None
  1292. for item in items:
  1293. advance_id = str(item["advance_id"])
  1294. offset_amount = float(item["offset_amount"])
  1295. if first_advance_id is None:
  1296. first_advance_id = advance_id
  1297. total_offset += offset_amount
  1298. offset_record = BxReimburseOffset(
  1299. id=generate_uuid(),
  1300. reimburse_id=apply_id,
  1301. advance_id=advance_id,
  1302. offset_amount=offset_amount,
  1303. remark=remark or None,
  1304. tenant_no=tenant_no,
  1305. create_by=user.name,
  1306. create_dept_id=user.dept_id or "",
  1307. )
  1308. db.add(offset_record)
  1309. if first_advance_id is not None:
  1310. apply_obj.offset_advance_id = first_advance_id
  1311. apply_obj.offset_amount = total_offset
  1312. if pay_amount > 0:
  1313. apply_obj.pay_status = "paid"
  1314. apply_obj.pay_time = datetime.now()
  1315. apply_obj.pay_user_id = user.id
  1316. apply_obj.pay_user_name = user.name
  1317. apply_obj.actual_amount = pay_amount
  1318. apply_obj.status = "processed"
  1319. elif has_offsets:
  1320. apply_obj.actual_amount = 0
  1321. apply_obj.status = "processed"
  1322. apply_obj.update_by = user.id
  1323. db.flush()
  1324. return apply_obj
  1325. @staticmethod
  1326. def finance_payment_stats(db: Session, tenant_no: str, *, user: BxUserContext) -> dict:
  1327. base_q = (
  1328. db.query(BxReimburseApply)
  1329. .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
  1330. )
  1331. base_q = apply_bx_scope(base_q, BxReimburseApply, user, db, tenant_no).subquery()
  1332. base = BxReimburseApply.id.in_(db.query(base_q.c.id))
  1333. pending = (
  1334. db.query(func.count())
  1335. .select_from(BxReimburseApply)
  1336. .filter(base, BxReimburseApply.status == "approved")
  1337. .scalar()
  1338. or 0
  1339. )
  1340. bank_paid = BxReimburseApply.pay_time.isnot(None)
  1341. paid = (
  1342. db.query(func.count()).select_from(BxReimburseApply).filter(base, bank_paid).scalar() or 0
  1343. )
  1344. offset_settled = or_(
  1345. BxReimburseApply.status == "offset",
  1346. and_(
  1347. BxReimburseApply.status == "processed",
  1348. BxReimburseApply.pay_time.is_(None),
  1349. func.coalesce(BxReimburseApply.offset_amount, 0) > 0,
  1350. ),
  1351. )
  1352. offset = (
  1353. db.query(func.count()).select_from(BxReimburseApply).filter(base, offset_settled).scalar() or 0
  1354. )
  1355. m0, m1 = current_month_bounds()
  1356. month_paid = (
  1357. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1358. .filter(base, bank_paid, BxReimburseApply.pay_time >= m0, BxReimburseApply.pay_time < m1)
  1359. .scalar()
  1360. or 0
  1361. )
  1362. month_offset = (
  1363. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1364. .filter(base, offset_settled, BxReimburseApply.update_time >= m0, BxReimburseApply.update_time < m1)
  1365. .scalar()
  1366. or 0
  1367. )
  1368. return {
  1369. "pending_count": int(pending),
  1370. "paid_count": int(paid),
  1371. "offset_count": int(offset),
  1372. "month_processed_amount": float(month_paid) + float(month_offset),
  1373. }