reimburse.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323
  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. occur_date=d.get("occur_date"),
  819. tenant_no=tenant_no,
  820. create_by=user.id,
  821. create_dept_id=user.dept_id or "",
  822. )
  823. db.add(detail)
  824. db.flush()
  825. return apply_obj
  826. @staticmethod
  827. def update_with_details(
  828. db: Session,
  829. apply_id: str,
  830. obj_in: dict,
  831. details: Optional[List[dict]],
  832. *,
  833. user: BxUserContext,
  834. ) -> Optional[BxReimburseApply]:
  835. tenant_no = ReimburseService._tenant_no(db, user)
  836. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  837. if not apply_obj:
  838. return None
  839. reimburse_date = None
  840. date_changed = False
  841. if "reimburse_date" in obj_in:
  842. reimburse_date = parse_reimburse_date(obj_in.pop("reimburse_date"))
  843. date_changed = reimburse_date != apply_obj.reimburse_date
  844. requested_title = obj_in.pop("title", None)
  845. prev_applicant_id = apply_obj.applicant_id
  846. for key, value in obj_in.items():
  847. setattr(apply_obj, key, value)
  848. if reimburse_date is not None:
  849. apply_obj.reimburse_date = reimburse_date
  850. if requested_title is not None:
  851. apply_obj.title = str(requested_title).strip()
  852. applicant_changed = apply_obj.applicant_id != prev_applicant_id
  853. should_regen_title = (date_changed or applicant_changed) and is_auto_reimburse_title(
  854. apply_obj.title
  855. )
  856. if should_regen_title:
  857. seq = allocate_reimburse_title_seq(
  858. db, tenant_no, apply_obj.reimburse_date, exclude_apply_id=apply_id
  859. )
  860. apply_obj.title_seq = seq
  861. apply_obj.title = format_reimburse_title(
  862. apply_obj.applicant_name, apply_obj.reimburse_date, seq
  863. )
  864. elif requested_title is not None and not is_auto_reimburse_title(apply_obj.title):
  865. apply_obj.title_seq = None
  866. if details is not None:
  867. db.query(BxReimburseDetail).filter(
  868. BxReimburseDetail.reimburse_id == apply_id,
  869. BxReimburseDetail.tenant_no == tenant_no,
  870. BxReimburseDetail.del_flag == 0,
  871. ).update({"del_flag": 1}, synchronize_session=False)
  872. total_amount = Decimal("0")
  873. first_expense_name = ""
  874. first_expense_id = ""
  875. for i, d in enumerate(details):
  876. detail = BxReimburseDetail(
  877. id=generate_uuid(),
  878. reimburse_id=apply_id,
  879. expense_id=str(d["expense_id"]),
  880. expense_name=d["expense_name"],
  881. amount=d["amount"],
  882. remark=d.get("remark"),
  883. occur_date=d.get("occur_date"),
  884. tenant_no=tenant_no,
  885. create_by=user.id,
  886. create_dept_id=user.dept_id or "",
  887. )
  888. db.add(detail)
  889. total_amount += Decimal(str(d["amount"]))
  890. if i == 0:
  891. first_expense_name = d["expense_name"]
  892. first_expense_id = str(d["expense_id"])
  893. apply_obj.amount = total_amount
  894. apply_obj.expense_name = first_expense_name
  895. apply_obj.expense_id = first_expense_id
  896. apply_obj.update_by = user.id
  897. db.flush()
  898. return apply_obj
  899. @staticmethod
  900. def get_details(db: Session, reimburse_id: str, tenant_no: str) -> List[BxReimburseDetail]:
  901. return (
  902. db.query(BxReimburseDetail)
  903. .filter(
  904. BxReimburseDetail.reimburse_id == reimburse_id,
  905. BxReimburseDetail.tenant_no == tenant_no,
  906. BxReimburseDetail.del_flag == 0,
  907. )
  908. .order_by(BxReimburseDetail.create_time)
  909. .all()
  910. )
  911. @staticmethod
  912. def build_expense_name_condition(
  913. db: Session, tenant_no: str, expense_name: Optional[str]
  914. ):
  915. name_kw = str(expense_name or "").strip()
  916. if not name_kw:
  917. return None
  918. detail_reimburse_ids = (
  919. db.query(BxReimburseDetail.reimburse_id)
  920. .filter(
  921. BxReimburseDetail.tenant_no == tenant_no,
  922. BxReimburseDetail.del_flag == 0,
  923. BxReimburseDetail.expense_name.contains(name_kw),
  924. )
  925. )
  926. return or_(
  927. BxReimburseApply.expense_name.contains(name_kw),
  928. BxReimburseApply.id.in_(detail_reimburse_ids),
  929. )
  930. @staticmethod
  931. def get_details_map_by_reimburse_ids(
  932. db: Session, reimburse_ids: List[str], tenant_no: str
  933. ) -> Dict[str, List[BxReimburseDetail]]:
  934. if not reimburse_ids:
  935. return {}
  936. detail_rows = (
  937. db.query(BxReimburseDetail)
  938. .filter(
  939. BxReimburseDetail.reimburse_id.in_(reimburse_ids),
  940. BxReimburseDetail.tenant_no == tenant_no,
  941. BxReimburseDetail.del_flag == 0,
  942. )
  943. .order_by(BxReimburseDetail.create_time)
  944. .all()
  945. )
  946. grouped: Dict[str, List[BxReimburseDetail]] = {}
  947. for detail in detail_rows:
  948. grouped.setdefault(detail.reimburse_id, []).append(detail)
  949. return grouped
  950. @staticmethod
  951. def out_rows_with_attachment_counts(
  952. db: Session, tenant_no: str, rows: List[BxReimburseApply]
  953. ) -> List[ReimburseApplyOut]:
  954. if not rows:
  955. return []
  956. ids = [r.id for r in rows]
  957. counts = AttachmentService.count_invoice_attach_by_biz_ids(
  958. db, "reimburse_apply", ids, tenant_no
  959. )
  960. details_map = ReimburseService.get_details_map_by_reimburse_ids(db, ids, tenant_no)
  961. invoice_ids_map = ReimburseService.get_linked_invoice_ids_map(db, ids, tenant_no)
  962. out_list: List[ReimburseApplyOut] = []
  963. for row in rows:
  964. out = ReimburseApplyOut.model_validate(row)
  965. inv, att = counts.get(row.id, (0, 0))
  966. detail_rows = details_map.get(row.id, [])
  967. linked_ids = invoice_ids_map.get(row.id, [])
  968. out_list.append(
  969. out.model_copy(
  970. update={
  971. "invoice_count": inv,
  972. "attach_count": att,
  973. "details": [ReimburseDetailOut.model_validate(d) for d in detail_rows],
  974. "invoice_ids": linked_ids,
  975. }
  976. )
  977. )
  978. return out_list
  979. @staticmethod
  980. def out_one_with_attachment_counts(
  981. db: Session, tenant_no: str, row: BxReimburseApply
  982. ) -> ReimburseApplyOut:
  983. return ReimburseService.out_rows_with_attachment_counts(db, tenant_no, [row])[0]
  984. @staticmethod
  985. def build_apply_out_with_details(
  986. db: Session, tenant_no: str, reimburse: BxReimburseApply
  987. ) -> ReimburseApplyOut:
  988. out = ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse)
  989. detail_rows = ReimburseService.get_details(db, reimburse.id, tenant_no)
  990. out.details = [ReimburseDetailOut.model_validate(d) for d in detail_rows]
  991. out.invoice_ids = ReimburseService.get_linked_invoice_ids(db, reimburse.id, tenant_no)
  992. out.invoices = ReimburseService.get_linked_invoices_brief(db, reimburse.id, tenant_no)
  993. return out
  994. @staticmethod
  995. def global_status_counts(db: Session, tenant_no: str, *, user: BxUserContext) -> Dict[str, int]:
  996. query = db.query(BxReimburseApply.status, func.count()).filter(
  997. BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no
  998. )
  999. query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
  1000. rows = query.group_by(BxReimburseApply.status).all()
  1001. return {str(s or ""): int(c) for s, c in rows}
  1002. @staticmethod
  1003. def _my_apply_base(db: Session, user: BxUserContext, tenant_no: str):
  1004. query = db.query(BxReimburseApply).filter(
  1005. BxReimburseApply.del_flag == 0,
  1006. BxReimburseApply.tenant_no == tenant_no,
  1007. or_(
  1008. BxReimburseApply.applicant_id == user.id,
  1009. BxReimburseApply.create_by == user.id,
  1010. ),
  1011. )
  1012. return apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
  1013. @staticmethod
  1014. def my_apply_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
  1015. base_q = ReimburseService._my_apply_base(db, user, tenant_no).subquery()
  1016. base = BxReimburseApply.id.in_(db.query(base_q.c.id))
  1017. total_count = (
  1018. db.query(func.count()).select_from(BxReimburseApply).filter(base).scalar() or 0
  1019. )
  1020. pending = (
  1021. db.query(func.count())
  1022. .select_from(BxReimburseApply)
  1023. .filter(base, BxReimburseApply.status == "pending")
  1024. .scalar()
  1025. or 0
  1026. )
  1027. approved_count = (
  1028. db.query(func.count())
  1029. .select_from(BxReimburseApply)
  1030. .filter(base, BxReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
  1031. .scalar()
  1032. or 0
  1033. )
  1034. rejected_count = (
  1035. db.query(func.count())
  1036. .select_from(BxReimburseApply)
  1037. .filter(base, BxReimburseApply.status == "rejected")
  1038. .scalar()
  1039. or 0
  1040. )
  1041. approved_amount = (
  1042. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1043. .filter(base, BxReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
  1044. .scalar()
  1045. or 0
  1046. )
  1047. m0, m1 = current_month_bounds()
  1048. month_apply_amount = (
  1049. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1050. .filter(base, BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  1051. .scalar()
  1052. or 0
  1053. )
  1054. month_apply_count = (
  1055. db.query(func.count())
  1056. .select_from(BxReimburseApply)
  1057. .filter(base, BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  1058. .scalar()
  1059. or 0
  1060. )
  1061. return {
  1062. "total_count": int(total_count),
  1063. "pending_count": int(pending),
  1064. "approved_count": int(approved_count),
  1065. "rejected_count": int(rejected_count),
  1066. "approved_amount": float(approved_amount),
  1067. "month_apply_amount": float(month_apply_amount),
  1068. "month_apply_count": int(month_apply_count),
  1069. }
  1070. @staticmethod
  1071. def pending_approval_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
  1072. subquery = (
  1073. db.query(BxReimburseApply.id)
  1074. .join(
  1075. BxWorkflowNode,
  1076. and_(
  1077. BxWorkflowNode.wf_id == BxReimburseApply.wf_id,
  1078. BxWorkflowNode.node_name == BxReimburseApply.current_node,
  1079. BxWorkflowNode.tenant_no == tenant_no,
  1080. BxWorkflowNode.del_flag == 0,
  1081. ),
  1082. )
  1083. .filter(
  1084. BxReimburseApply.del_flag == 0,
  1085. BxReimburseApply.tenant_no == tenant_no,
  1086. BxReimburseApply.status == "pending",
  1087. BxWorkflowNode.approver_id == user.id,
  1088. )
  1089. ).subquery()
  1090. total = db.query(func.count()).select_from(subquery).scalar() or 0
  1091. m0, m1 = current_month_bounds()
  1092. month_sum = (
  1093. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1094. .join(subquery, BxReimburseApply.id == subquery.c.id)
  1095. .filter(BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
  1096. .scalar()
  1097. or 0
  1098. )
  1099. return {"pending_total": int(total), "month_pending_amount": float(month_sum)}
  1100. @staticmethod
  1101. def pay(
  1102. db: Session,
  1103. apply_id: str,
  1104. user: BxUserContext,
  1105. pay_remark: str = "",
  1106. actual_amount: Optional[float] = None,
  1107. ) -> Optional[BxReimburseApply]:
  1108. tenant_no = ReimburseService._tenant_no(db, user)
  1109. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  1110. if not apply_obj:
  1111. return None
  1112. apply_obj.status = "processed"
  1113. apply_obj.update_by = user.id
  1114. apply_obj.pay_status = "paid"
  1115. apply_obj.pay_time = datetime.now()
  1116. apply_obj.pay_user_id = user.id
  1117. apply_obj.pay_user_name = user.name
  1118. if actual_amount is not None:
  1119. apply_obj.actual_amount = actual_amount
  1120. db.flush()
  1121. return apply_obj
  1122. @staticmethod
  1123. def offset(
  1124. db: Session,
  1125. apply_id: str,
  1126. advance_id: str,
  1127. amount: float,
  1128. user: BxUserContext,
  1129. remark: str = "",
  1130. ) -> Optional[BxReimburseApply]:
  1131. tenant_no = ReimburseService._tenant_no(db, user)
  1132. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  1133. if not apply_obj:
  1134. return None
  1135. apply_obj.status = "offset"
  1136. apply_obj.offset_advance_id = advance_id
  1137. apply_obj.offset_amount = amount
  1138. apply_obj.update_by = user.id
  1139. offset_record = BxReimburseOffset(
  1140. id=generate_uuid(),
  1141. reimburse_id=apply_id,
  1142. advance_id=advance_id,
  1143. offset_amount=amount,
  1144. remark=remark or None,
  1145. tenant_no=tenant_no,
  1146. create_by=user.name,
  1147. create_dept_id=user.dept_id or "",
  1148. )
  1149. db.add(offset_record)
  1150. db.flush()
  1151. return apply_obj
  1152. @staticmethod
  1153. def batch_offset(
  1154. db: Session,
  1155. apply_id: str,
  1156. items: List[dict],
  1157. pay_amount: float,
  1158. user: BxUserContext,
  1159. remark: str = "",
  1160. ) -> Optional[BxReimburseApply]:
  1161. tenant_no = ReimburseService._tenant_no(db, user)
  1162. apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
  1163. if not apply_obj:
  1164. return None
  1165. has_offsets = bool(items)
  1166. total_offset = 0.0
  1167. first_advance_id = None
  1168. for item in items:
  1169. advance_id = str(item["advance_id"])
  1170. offset_amount = float(item["offset_amount"])
  1171. if first_advance_id is None:
  1172. first_advance_id = advance_id
  1173. total_offset += offset_amount
  1174. offset_record = BxReimburseOffset(
  1175. id=generate_uuid(),
  1176. reimburse_id=apply_id,
  1177. advance_id=advance_id,
  1178. offset_amount=offset_amount,
  1179. remark=remark or None,
  1180. tenant_no=tenant_no,
  1181. create_by=user.name,
  1182. create_dept_id=user.dept_id or "",
  1183. )
  1184. db.add(offset_record)
  1185. if first_advance_id is not None:
  1186. apply_obj.offset_advance_id = first_advance_id
  1187. apply_obj.offset_amount = total_offset
  1188. if pay_amount > 0:
  1189. apply_obj.pay_status = "paid"
  1190. apply_obj.pay_time = datetime.now()
  1191. apply_obj.pay_user_id = user.id
  1192. apply_obj.pay_user_name = user.name
  1193. apply_obj.actual_amount = pay_amount
  1194. apply_obj.status = "processed"
  1195. elif has_offsets:
  1196. apply_obj.actual_amount = 0
  1197. apply_obj.status = "processed"
  1198. apply_obj.update_by = user.id
  1199. db.flush()
  1200. return apply_obj
  1201. @staticmethod
  1202. def finance_payment_stats(db: Session, tenant_no: str, *, user: BxUserContext) -> dict:
  1203. base_q = (
  1204. db.query(BxReimburseApply)
  1205. .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
  1206. )
  1207. base_q = apply_bx_scope(base_q, BxReimburseApply, user, db, tenant_no).subquery()
  1208. base = BxReimburseApply.id.in_(db.query(base_q.c.id))
  1209. pending = (
  1210. db.query(func.count())
  1211. .select_from(BxReimburseApply)
  1212. .filter(base, BxReimburseApply.status == "approved")
  1213. .scalar()
  1214. or 0
  1215. )
  1216. bank_paid = BxReimburseApply.pay_time.isnot(None)
  1217. paid = (
  1218. db.query(func.count()).select_from(BxReimburseApply).filter(base, bank_paid).scalar() or 0
  1219. )
  1220. offset_settled = or_(
  1221. BxReimburseApply.status == "offset",
  1222. and_(
  1223. BxReimburseApply.status == "processed",
  1224. BxReimburseApply.pay_time.is_(None),
  1225. func.coalesce(BxReimburseApply.offset_amount, 0) > 0,
  1226. ),
  1227. )
  1228. offset = (
  1229. db.query(func.count()).select_from(BxReimburseApply).filter(base, offset_settled).scalar() or 0
  1230. )
  1231. m0, m1 = current_month_bounds()
  1232. month_paid = (
  1233. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1234. .filter(base, bank_paid, BxReimburseApply.pay_time >= m0, BxReimburseApply.pay_time < m1)
  1235. .scalar()
  1236. or 0
  1237. )
  1238. month_offset = (
  1239. db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
  1240. .filter(base, offset_settled, BxReimburseApply.update_time >= m0, BxReimburseApply.update_time < m1)
  1241. .scalar()
  1242. or 0
  1243. )
  1244. return {
  1245. "pending_count": int(pending),
  1246. "paid_count": int(paid),
  1247. "offset_count": int(offset),
  1248. "month_processed_amount": float(month_paid) + float(month_offset),
  1249. }