reimburse.py 49 KB

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