reimburse.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. from datetime import datetime
  2. from sqlalchemy import and_, func, or_
  3. from app.utils.date_range import current_month_bounds, parse_create_time_day_bounds
  4. from sqlalchemy.orm import Session
  5. from app.models.reimburse import ReimburseApply, ReimburseApproval, ReimburseOffset
  6. from app.models.workflow import WorkflowDef, WorkflowNode
  7. from app.schemas.reimburse import ReimburseApplyOut, ReimburseDetailOut
  8. from app.services.attachment import AttachmentService
  9. from app.services.base import CrudService
  10. from app.utils.codegen import generate_apply_code, generate_title
  11. # 我的报销统计「已通过」:含待财务处理的 approved,以及已发放/冲抵后的终态(与列表筛选项「审批通过」语义区分)
  12. _REIMBURSE_MY_STATS_PASSED_STATUSES = ("approved", "processed", "paid", "offset")
  13. class ReimburseService:
  14. @staticmethod
  15. def get_by_id(db: Session, id: int) -> ReimburseApply | None:
  16. return CrudService.get_by_id(ReimburseApply, db, id)
  17. @staticmethod
  18. def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
  19. return CrudService.get_paged(ReimburseApply, db, page, page_size, filters)
  20. @staticmethod
  21. def create(db: Session, obj_in: dict) -> ReimburseApply:
  22. return CrudService.create(ReimburseApply, db, obj_in)
  23. @staticmethod
  24. def update(db: Session, id: int, obj_in: dict) -> ReimburseApply | None:
  25. return CrudService.update(ReimburseApply, db, id, obj_in)
  26. @staticmethod
  27. def soft_delete(db: Session, id: int) -> bool:
  28. return CrudService.soft_delete(ReimburseApply, db, id)
  29. @staticmethod
  30. def pending_approval(
  31. db: Session,
  32. user,
  33. page: int,
  34. page_size: int,
  35. keyword: str = "",
  36. applicant_id: int | None = None,
  37. apply_date: str | None = None,
  38. ):
  39. """查询待当前用户审批的报销申请(current_node 对应的 WorkflowNode.approver_id == user.id)"""
  40. subquery = (
  41. db.query(ReimburseApply.id)
  42. .join(WorkflowNode, and_(
  43. WorkflowNode.wf_id == ReimburseApply.wf_id,
  44. WorkflowNode.node_name == ReimburseApply.current_node,
  45. ))
  46. .filter(
  47. ReimburseApply.del_flag == 0,
  48. ReimburseApply.status == "pending",
  49. WorkflowNode.approver_id == user.id,
  50. )
  51. )
  52. if keyword:
  53. subquery = subquery.filter(
  54. ReimburseApply.apply_code.contains(keyword) | ReimburseApply.applicant_name.contains(keyword)
  55. )
  56. if applicant_id is not None:
  57. subquery = subquery.filter(ReimburseApply.applicant_id == applicant_id)
  58. bounds = parse_create_time_day_bounds(apply_date)
  59. if bounds:
  60. d0, d1 = bounds
  61. subquery = subquery.filter(ReimburseApply.create_time >= d0, ReimburseApply.create_time < d1)
  62. subquery = subquery.subquery()
  63. total = db.query(func.count()).select_from(subquery).scalar()
  64. rows = (
  65. db.query(ReimburseApply)
  66. .join(subquery, ReimburseApply.id == subquery.c.id)
  67. .order_by(ReimburseApply.id.desc())
  68. .offset((page - 1) * page_size)
  69. .limit(page_size)
  70. .all()
  71. )
  72. return rows, total or 0
  73. @staticmethod
  74. def approved_by_me(
  75. db: Session,
  76. user,
  77. page: int,
  78. page_size: int,
  79. keyword: str = "",
  80. applicant_id: int | None = None,
  81. apply_date: str | None = None,
  82. ):
  83. """查询当前用户已审批过的报销申请"""
  84. subquery = (
  85. db.query(ReimburseApproval.apply_id)
  86. .filter(ReimburseApproval.approver_id == user.id)
  87. .distinct()
  88. .subquery()
  89. )
  90. query = (
  91. db.query(ReimburseApply)
  92. .join(subquery, ReimburseApply.id == subquery.c.apply_id)
  93. .filter(ReimburseApply.del_flag == 0)
  94. )
  95. if keyword:
  96. query = query.filter(
  97. ReimburseApply.apply_code.contains(keyword) | ReimburseApply.applicant_name.contains(keyword)
  98. )
  99. if applicant_id is not None:
  100. query = query.filter(ReimburseApply.applicant_id == applicant_id)
  101. bounds = parse_create_time_day_bounds(apply_date)
  102. if bounds:
  103. d0, d1 = bounds
  104. query = query.filter(ReimburseApply.create_time >= d0, ReimburseApply.create_time < d1)
  105. total = query.count()
  106. rows = (
  107. query.order_by(ReimburseApply.id.desc())
  108. .offset((page - 1) * page_size)
  109. .limit(page_size)
  110. .all()
  111. )
  112. return rows, total
  113. @staticmethod
  114. def submit(db: Session, apply_id: int, user) -> ReimburseApply | None:
  115. apply_obj = (
  116. db.query(ReimburseApply)
  117. .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
  118. .first()
  119. )
  120. if not apply_obj:
  121. return None
  122. apply_obj.status = "pending"
  123. if not apply_obj.apply_code:
  124. apply_obj.apply_code = generate_apply_code(db, "BX")
  125. apply_obj.update_by = str(user.id)
  126. wf = (
  127. db.query(WorkflowDef)
  128. .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
  129. .first()
  130. )
  131. if wf:
  132. nodes = (
  133. db.query(WorkflowNode)
  134. .filter(WorkflowNode.wf_id == wf.id)
  135. .order_by(WorkflowNode.node_order)
  136. .all()
  137. )
  138. if nodes:
  139. apply_obj.current_node = nodes[0].node_name
  140. db.flush()
  141. return apply_obj
  142. @staticmethod
  143. def approve(db: Session, apply_id: int, approver, opinion: str) -> ReimburseApply | None:
  144. apply_obj = (
  145. db.query(ReimburseApply)
  146. .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
  147. .first()
  148. )
  149. if not apply_obj:
  150. return None
  151. wf = (
  152. db.query(WorkflowDef)
  153. .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
  154. .first()
  155. )
  156. if not wf:
  157. return None
  158. nodes = (
  159. db.query(WorkflowNode)
  160. .filter(WorkflowNode.wf_id == wf.id)
  161. .order_by(WorkflowNode.node_order)
  162. .all()
  163. )
  164. current_idx = None
  165. for i, node in enumerate(nodes):
  166. if node.node_name == apply_obj.current_node:
  167. current_idx = i
  168. break
  169. if current_idx is None:
  170. return None
  171. current_node = nodes[current_idx]
  172. if current_node.approver_id != approver.id:
  173. return None
  174. approval = ReimburseApproval(
  175. apply_id=apply_id,
  176. approver_id=approver.id,
  177. approver_name=approver.username,
  178. approval_opinion=opinion,
  179. node_name=current_node.node_name,
  180. node_order=current_node.node_order,
  181. approval_status="approved",
  182. approval_time=datetime.now(),
  183. )
  184. db.add(approval)
  185. if current_idx == len(nodes) - 1:
  186. apply_obj.status = "approved"
  187. apply_obj.current_node = None
  188. else:
  189. apply_obj.current_node = nodes[current_idx + 1].node_name
  190. apply_obj.update_by = str(approver.id)
  191. db.flush()
  192. return apply_obj
  193. @staticmethod
  194. def reject(db: Session, apply_id: int, approver, opinion: str) -> ReimburseApply | None:
  195. apply_obj = (
  196. db.query(ReimburseApply)
  197. .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
  198. .first()
  199. )
  200. if not apply_obj:
  201. return None
  202. wf = (
  203. db.query(WorkflowDef)
  204. .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
  205. .first()
  206. )
  207. if not wf:
  208. return None
  209. nodes = (
  210. db.query(WorkflowNode)
  211. .filter(WorkflowNode.wf_id == wf.id)
  212. .order_by(WorkflowNode.node_order)
  213. .all()
  214. )
  215. current_node = None
  216. for node in nodes:
  217. if node.node_name == apply_obj.current_node:
  218. current_node = node
  219. break
  220. if current_node is None:
  221. return None
  222. if current_node.approver_id != approver.id:
  223. return None
  224. approval = ReimburseApproval(
  225. apply_id=apply_id,
  226. approver_id=approver.id,
  227. approver_name=approver.username,
  228. approval_opinion=opinion,
  229. node_name=current_node.node_name,
  230. node_order=current_node.node_order,
  231. approval_status="rejected",
  232. approval_time=datetime.now(),
  233. )
  234. db.add(approval)
  235. apply_obj.status = "rejected"
  236. apply_obj.current_node = None
  237. apply_obj.update_by = str(approver.id)
  238. db.flush()
  239. return apply_obj
  240. @staticmethod
  241. def pay(
  242. db: Session,
  243. apply_id: int,
  244. user,
  245. pay_remark: str = "",
  246. actual_amount: float | None = None,
  247. ) -> ReimburseApply | None:
  248. apply_obj = (
  249. db.query(ReimburseApply)
  250. .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
  251. .first()
  252. )
  253. if not apply_obj:
  254. return None
  255. apply_obj.status = "processed"
  256. apply_obj.update_by = str(user.id)
  257. apply_obj.pay_status = "paid"
  258. apply_obj.pay_time = datetime.now()
  259. apply_obj.pay_user_id = user.id
  260. apply_obj.pay_user_name = user.real_name or user.username
  261. if actual_amount is not None:
  262. apply_obj.actual_amount = actual_amount
  263. db.flush()
  264. return apply_obj
  265. @staticmethod
  266. def offset(
  267. db: Session, apply_id: int, advance_id: int, amount: float, user, remark: str = ""
  268. ) -> ReimburseApply | None:
  269. apply_obj = (
  270. db.query(ReimburseApply)
  271. .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
  272. .first()
  273. )
  274. if not apply_obj:
  275. return None
  276. apply_obj.status = "offset"
  277. apply_obj.offset_advance_id = advance_id
  278. apply_obj.offset_amount = amount
  279. apply_obj.update_by = str(user.id)
  280. offset_record = ReimburseOffset(
  281. reimburse_id=apply_id,
  282. advance_id=advance_id,
  283. offset_amount=amount,
  284. remark=remark or None,
  285. create_by=(user.real_name or user.username),
  286. )
  287. db.add(offset_record)
  288. db.flush()
  289. return apply_obj
  290. @staticmethod
  291. def batch_offset(
  292. db: Session, apply_id: int, items: list[dict], pay_amount: float, user, remark: str = ""
  293. ) -> ReimburseApply | None:
  294. apply_obj = (
  295. db.query(ReimburseApply)
  296. .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
  297. .first()
  298. )
  299. if not apply_obj:
  300. return None
  301. has_offsets = bool(items)
  302. total_offset = 0.0
  303. first_advance_id = None
  304. for item in items:
  305. advance_id = item["advance_id"]
  306. offset_amount = float(item["offset_amount"])
  307. if first_advance_id is None:
  308. first_advance_id = advance_id
  309. total_offset += offset_amount
  310. offset_record = ReimburseOffset(
  311. reimburse_id=apply_id,
  312. advance_id=advance_id,
  313. offset_amount=offset_amount,
  314. remark=remark or None,
  315. create_by=(user.real_name or user.username),
  316. )
  317. db.add(offset_record)
  318. if first_advance_id is not None:
  319. apply_obj.offset_advance_id = first_advance_id
  320. apply_obj.offset_amount = total_offset
  321. if pay_amount > 0:
  322. apply_obj.pay_status = "paid"
  323. apply_obj.pay_time = datetime.now()
  324. apply_obj.pay_user_id = user.id
  325. apply_obj.pay_user_name = user.real_name or user.username
  326. apply_obj.actual_amount = pay_amount
  327. apply_obj.status = "processed"
  328. elif has_offsets:
  329. apply_obj.actual_amount = 0
  330. apply_obj.status = "processed"
  331. apply_obj.update_by = str(user.id)
  332. db.flush()
  333. return apply_obj
  334. @staticmethod
  335. def get_approvals(db: Session, apply_id: int) -> list[dict]:
  336. apply_obj = (
  337. db.query(ReimburseApply)
  338. .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
  339. .first()
  340. )
  341. if not apply_obj or not apply_obj.wf_id:
  342. return []
  343. wf = (
  344. db.query(WorkflowDef)
  345. .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
  346. .first()
  347. )
  348. if not wf:
  349. return []
  350. nodes = (
  351. db.query(WorkflowNode)
  352. .filter(WorkflowNode.wf_id == wf.id)
  353. .order_by(WorkflowNode.node_order)
  354. .all()
  355. )
  356. if not nodes:
  357. return []
  358. approvals = (
  359. db.query(ReimburseApproval)
  360. .filter(ReimburseApproval.apply_id == apply_id)
  361. .all()
  362. )
  363. approval_map = {a.node_order: a for a in approvals}
  364. result = []
  365. for node in nodes:
  366. approval = approval_map.get(node.node_order)
  367. if approval:
  368. result.append({
  369. "id": approval.id,
  370. "reimburse_id": apply_id,
  371. "node_name": approval.node_name,
  372. "node_order": approval.node_order,
  373. "approval_status": approval.approval_status,
  374. "approval_opinion": approval.approval_opinion,
  375. "approver_name": approval.approver_name,
  376. "approval_time": approval.approval_time,
  377. })
  378. else:
  379. result.append({
  380. "id": 0,
  381. "reimburse_id": apply_id,
  382. "node_name": node.node_name,
  383. "node_order": node.node_order,
  384. "approval_status": "pending",
  385. "approval_opinion": None,
  386. "approver_name": node.approver_name or "",
  387. "approval_time": None,
  388. })
  389. return result
  390. @staticmethod
  391. def create_with_details(db: Session, obj_in: dict, details: list[dict], user) -> ReimburseApply:
  392. from app.models.reimburse import ReimburseDetail
  393. total_amount = sum(d.get("amount", 0) for d in details)
  394. apply_date = obj_in.pop("apply_date", None)
  395. title = obj_in.pop("title", None) or generate_title(
  396. db, user.real_name or user.username, apply_date
  397. )
  398. first_detail = details[0] if details else {}
  399. apply_data = {
  400. **obj_in,
  401. "title": title,
  402. "amount": total_amount,
  403. "expense_id": first_detail.get("expense_id", 0),
  404. "expense_name": first_detail.get("expense_name", ""),
  405. "applicant_id": user.id,
  406. "applicant_name": user.real_name or user.username,
  407. }
  408. apply_obj = ReimburseApply(**apply_data)
  409. db.add(apply_obj)
  410. db.flush()
  411. for d in details:
  412. detail = ReimburseDetail(
  413. reimburse_id=apply_obj.id,
  414. expense_id=d["expense_id"],
  415. expense_name=d["expense_name"],
  416. amount=d["amount"],
  417. remark=d.get("remark"),
  418. create_by=str(user.id),
  419. )
  420. db.add(detail)
  421. db.flush()
  422. return apply_obj
  423. @staticmethod
  424. def update_with_details(db: Session, apply_id: int, obj_in: dict, details: list[dict] | None, user) -> ReimburseApply | None:
  425. from app.models.reimburse import ReimburseDetail
  426. apply_obj = (
  427. db.query(ReimburseApply)
  428. .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
  429. .first()
  430. )
  431. if not apply_obj:
  432. return None
  433. for k, v in obj_in.items():
  434. setattr(apply_obj, k, v)
  435. if details is not None:
  436. db.query(ReimburseDetail).filter(
  437. ReimburseDetail.reimburse_id == apply_id
  438. ).update({"del_flag": 1})
  439. total_amount = 0
  440. first_expense_name = ""
  441. first_expense_id = 0
  442. for i, d in enumerate(details):
  443. detail = ReimburseDetail(
  444. reimburse_id=apply_id,
  445. expense_id=d["expense_id"],
  446. expense_name=d["expense_name"],
  447. amount=d["amount"],
  448. remark=d.get("remark"),
  449. create_by=str(user.id),
  450. )
  451. db.add(detail)
  452. total_amount += d["amount"]
  453. if i == 0:
  454. first_expense_name = d["expense_name"]
  455. first_expense_id = d["expense_id"]
  456. apply_obj.amount = total_amount
  457. apply_obj.expense_name = first_expense_name
  458. apply_obj.expense_id = first_expense_id
  459. apply_obj.update_by = str(user.id)
  460. db.flush()
  461. return apply_obj
  462. @staticmethod
  463. def get_details(db: Session, reimburse_id: int) -> list:
  464. from app.models.reimburse import ReimburseDetail
  465. return db.query(ReimburseDetail).filter(
  466. ReimburseDetail.reimburse_id == reimburse_id,
  467. ReimburseDetail.del_flag == 0
  468. ).order_by(ReimburseDetail.id).all()
  469. @staticmethod
  470. def out_rows_with_attachment_counts(db: Session, rows: list[ReimburseApply]) -> list[ReimburseApplyOut]:
  471. """列表/详情输出:从 bx_attachment 聚合 invoice_count / attach_count(库内冗余字段可能未维护)。"""
  472. if not rows:
  473. return []
  474. ids = [r.id for r in rows]
  475. counts = AttachmentService.count_invoice_attach_by_biz_ids(db, "reimburse_apply", ids)
  476. out_list: list[ReimburseApplyOut] = []
  477. for r in rows:
  478. o = ReimburseApplyOut.model_validate(r)
  479. inv, att = counts.get(r.id, (0, 0))
  480. out_list.append(o.model_copy(update={"invoice_count": inv, "attach_count": att}))
  481. return out_list
  482. @staticmethod
  483. def out_one_with_attachment_counts(db: Session, row: ReimburseApply) -> ReimburseApplyOut:
  484. return ReimburseService.out_rows_with_attachment_counts(db, [row])[0]
  485. @staticmethod
  486. def build_apply_out_with_details(db: Session, reimburse: ReimburseApply) -> ReimburseApplyOut:
  487. out = ReimburseService.out_one_with_attachment_counts(db, reimburse)
  488. detail_rows = ReimburseService.get_details(db, reimburse.id)
  489. out.details = [ReimburseDetailOut.model_validate(d) for d in detail_rows]
  490. return out
  491. @staticmethod
  492. def global_status_counts(db: Session) -> dict[str, int]:
  493. """全库报销单按 status 计数(del_flag=0),用于管理端汇总卡片。"""
  494. rows = (
  495. db.query(ReimburseApply.status, func.count())
  496. .filter(ReimburseApply.del_flag == 0)
  497. .group_by(ReimburseApply.status)
  498. .all()
  499. )
  500. return {str(s or ""): int(c) for s, c in rows}
  501. @staticmethod
  502. def my_apply_stats(db: Session, user_id: int) -> dict:
  503. """我的报销:全量聚合(与列表分页无关);本月按 create_time 自然月。
  504. approved_count / approved_amount:审批流程已通过的单据(含待支付 approved 与已处理 processed/paid/offset),
  505. 与前端「已通过申请」「已审批金额」一致;不等同于 status 仅为 approved 的待支付队列。
  506. """
  507. base = and_(ReimburseApply.del_flag == 0, ReimburseApply.applicant_id == user_id)
  508. total_count = db.query(func.count()).select_from(ReimburseApply).filter(base).scalar() or 0
  509. pending = db.query(func.count()).select_from(ReimburseApply).filter(base, ReimburseApply.status == "pending").scalar() or 0
  510. approved_count = (
  511. db.query(func.count())
  512. .select_from(ReimburseApply)
  513. .filter(base, ReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
  514. .scalar()
  515. or 0
  516. )
  517. rejected_count = (
  518. db.query(func.count()).select_from(ReimburseApply).filter(base, ReimburseApply.status == "rejected").scalar() or 0
  519. )
  520. approved_amount = (
  521. db.query(func.coalesce(func.sum(ReimburseApply.amount), 0))
  522. .filter(base, ReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
  523. .scalar()
  524. or 0
  525. )
  526. m0, m1 = current_month_bounds()
  527. month_apply_amount = (
  528. db.query(func.coalesce(func.sum(ReimburseApply.amount), 0))
  529. .filter(base, ReimburseApply.create_time >= m0, ReimburseApply.create_time < m1)
  530. .scalar()
  531. or 0
  532. )
  533. month_apply_count = (
  534. db.query(func.count())
  535. .select_from(ReimburseApply)
  536. .filter(base, ReimburseApply.create_time >= m0, ReimburseApply.create_time < m1)
  537. .scalar()
  538. or 0
  539. )
  540. return {
  541. "total_count": int(total_count),
  542. "pending_count": int(pending),
  543. "approved_count": int(approved_count),
  544. "rejected_count": int(rejected_count),
  545. "approved_amount": float(approved_amount),
  546. "month_apply_amount": float(month_apply_amount),
  547. "month_apply_count": int(month_apply_count),
  548. }
  549. @staticmethod
  550. def pending_approval_stats(db: Session, user) -> dict:
  551. """待我审批的报销:总条数 + 其中申请提交于本月的金额合计(create_time)。"""
  552. subquery = (
  553. db.query(ReimburseApply.id)
  554. .join(
  555. WorkflowNode,
  556. and_(
  557. WorkflowNode.wf_id == ReimburseApply.wf_id,
  558. WorkflowNode.node_name == ReimburseApply.current_node,
  559. ),
  560. )
  561. .filter(
  562. ReimburseApply.del_flag == 0,
  563. ReimburseApply.status == "pending",
  564. WorkflowNode.approver_id == user.id,
  565. )
  566. ).subquery()
  567. total = db.query(func.count()).select_from(subquery).scalar() or 0
  568. m0, m1 = current_month_bounds()
  569. month_sum = (
  570. db.query(func.coalesce(func.sum(ReimburseApply.amount), 0))
  571. .join(subquery, ReimburseApply.id == subquery.c.id)
  572. .filter(ReimburseApply.create_time >= m0, ReimburseApply.create_time < m1)
  573. .scalar()
  574. or 0
  575. )
  576. return {"pending_total": int(total), "month_pending_amount": float(month_sum)}
  577. @staticmethod
  578. def finance_payment_stats(db: Session) -> dict:
  579. """财务支付看板:待处理=approved;已打款=有 pay_time;冲抵=offset 或 无 pay_time 的 processed 且有冲抵额;本月金额=打款按 pay_time、冲抵按 update_time。"""
  580. base = ReimburseApply.del_flag == 0
  581. pending = (
  582. db.query(func.count()).select_from(ReimburseApply).filter(base, ReimburseApply.status == "approved").scalar() or 0
  583. )
  584. bank_paid = and_(ReimburseApply.pay_time.isnot(None))
  585. paid = (
  586. db.query(func.count()).select_from(ReimburseApply).filter(base, bank_paid).scalar() or 0
  587. )
  588. offset_settled = or_(
  589. ReimburseApply.status == "offset",
  590. and_(
  591. ReimburseApply.status == "processed",
  592. ReimburseApply.pay_time.is_(None),
  593. func.coalesce(ReimburseApply.offset_amount, 0) > 0,
  594. ),
  595. )
  596. offset = db.query(func.count()).select_from(ReimburseApply).filter(base, offset_settled).scalar() or 0
  597. m0, m1 = current_month_bounds()
  598. month_paid = (
  599. db.query(func.coalesce(func.sum(ReimburseApply.amount), 0))
  600. .filter(
  601. base,
  602. bank_paid,
  603. ReimburseApply.pay_time >= m0,
  604. ReimburseApply.pay_time < m1,
  605. )
  606. .scalar()
  607. or 0
  608. )
  609. month_offset = (
  610. db.query(func.coalesce(func.sum(ReimburseApply.amount), 0))
  611. .filter(base, offset_settled, ReimburseApply.update_time >= m0, ReimburseApply.update_time < m1)
  612. .scalar()
  613. or 0
  614. )
  615. month_amount = float(month_paid) + float(month_offset)
  616. return {
  617. "pending_count": int(pending),
  618. "paid_count": int(paid),
  619. "offset_count": int(offset),
  620. "month_processed_amount": month_amount,
  621. }