advance.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. from typing import List, Optional
  2. from fastapi import APIRouter, Depends, HTTPException, status, Query
  3. from sqlalchemy import or_
  4. from sqlalchemy.orm import Session
  5. from app.api.bx.export_common import (
  6. EXPORT_MAX_ROWS,
  7. ExportColumn,
  8. ExportValidationError,
  9. BxApplyListExportBody,
  10. dump_export_row,
  11. respond_xlsx,
  12. )
  13. from app.core.bx_auth import BxUserContext, get_bx_current_user
  14. from app.core.database import get_db
  15. from app.core.tenant_context import get_tenant_no
  16. from app.models.bx_models import BxAdvanceApply, BxWorkflowDef
  17. from app.schemas.bx.advance import (
  18. AdvanceApplyCreate,
  19. AdvanceApplyOut,
  20. AdvanceApplyUpdate,
  21. AdvanceApprovalCreate,
  22. AdvanceApprovalOut,
  23. AdvanceApproverPendingStatsOut,
  24. AdvanceMyStatsOut,
  25. )
  26. from app.schemas.bx.common import ApiResponse, PagedData
  27. from app.services.bx.advance import AdvanceService
  28. from app.services.bx.expense_item import ExpenseItemService
  29. from app.utils.bx_codegen import generate_apply_code
  30. from app.utils.bx_date_range import append_create_time_filters
  31. router = APIRouter(prefix="/advances", tags=["备用金管理"])
  32. def _tenant(db: Session, user: BxUserContext) -> str:
  33. return get_tenant_no(db, user.to_tenant_user_dict())
  34. def _fill_offset_amounts(db: Session, tenant_no: str, rows, out_rows: List[AdvanceApplyOut]) -> None:
  35. if not out_rows:
  36. return
  37. offset_map = AdvanceService.offset_amount_map(db, tenant_no, [r.id for r in rows])
  38. for out_row in out_rows:
  39. out_row.offset_amount = offset_map.get(out_row.id, 0.0)
  40. ADVANCE_EXPORT_COLUMNS = [
  41. ExportColumn(title="申请单号", field="applyCode"),
  42. ExportColumn(title="申请时间", field="applyTime"),
  43. ExportColumn(title="申请人", field="applicantName"),
  44. ExportColumn(title="申请原因", field="reason"),
  45. ExportColumn(title="申请金额(元)", field="amount"),
  46. ExportColumn(title="实际发放金额(元)", field="actualAmount"),
  47. ExportColumn(title="冲抵总金额(元)", field="offsetAmount"),
  48. ExportColumn(title="状态", field="status"),
  49. ExportColumn(title="当前节点", field="currentNode"),
  50. ]
  51. def _advance_list_filters(body: BxApplyListExportBody) -> list:
  52. filters = []
  53. if body.status:
  54. filters.append(BxAdvanceApply.status == body.status)
  55. if body.keyword:
  56. filters.append(
  57. or_(
  58. BxAdvanceApply.apply_code.contains(body.keyword),
  59. BxAdvanceApply.applicant_name.contains(body.keyword),
  60. )
  61. )
  62. if body.applicant_id:
  63. filters.append(BxAdvanceApply.applicant_id == str(body.applicant_id).strip())
  64. if body.ids:
  65. filters.append(BxAdvanceApply.id.in_([str(i) for i in body.ids]))
  66. append_create_time_filters(
  67. filters,
  68. BxAdvanceApply,
  69. apply_date=body.apply_date,
  70. apply_date_from=body.apply_date_from,
  71. apply_date_to=body.apply_date_to,
  72. )
  73. return filters
  74. def _export_advance_rows(
  75. db: Session,
  76. tenant_no: str,
  77. rows,
  78. *,
  79. with_offset: bool = True,
  80. ) -> List[dict]:
  81. out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
  82. if with_offset:
  83. _fill_offset_amounts(db, tenant_no, rows, out_rows)
  84. return [dump_export_row(r) for r in out_rows]
  85. @router.get("/", response_model=ApiResponse[PagedData[AdvanceApplyOut]])
  86. def list_advances(
  87. page: int = 1,
  88. pageSize: int = Query(10, ge=1, le=500),
  89. status: Optional[str] = None,
  90. keyword: str = "",
  91. applicantId: Optional[str] = None,
  92. applyDate: Optional[str] = None,
  93. applyDateFrom: Optional[str] = None,
  94. applyDateTo: Optional[str] = None,
  95. db: Session = Depends(get_db),
  96. user: BxUserContext = Depends(get_bx_current_user),
  97. ):
  98. tenant_no = _tenant(db, user)
  99. filters = []
  100. if status:
  101. filters.append(BxAdvanceApply.status == status)
  102. if keyword:
  103. filters.append(
  104. or_(
  105. BxAdvanceApply.apply_code.contains(keyword),
  106. BxAdvanceApply.applicant_name.contains(keyword),
  107. )
  108. )
  109. if applicantId:
  110. filters.append(BxAdvanceApply.applicant_id == str(applicantId).strip())
  111. append_create_time_filters(
  112. filters,
  113. BxAdvanceApply,
  114. apply_date=applyDate,
  115. apply_date_from=applyDateFrom,
  116. apply_date_to=applyDateTo,
  117. )
  118. rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
  119. out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
  120. _fill_offset_amounts(db, tenant_no, rows, out_rows)
  121. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  122. @router.get("/my", response_model=ApiResponse[PagedData[AdvanceApplyOut]])
  123. def my_advances(
  124. page: int = 1,
  125. pageSize: int = Query(10, ge=1, le=500),
  126. status: Optional[str] = None,
  127. keyword: str = "",
  128. applyDate: Optional[str] = None,
  129. applyDateFrom: Optional[str] = None,
  130. applyDateTo: Optional[str] = None,
  131. db: Session = Depends(get_db),
  132. user: BxUserContext = Depends(get_bx_current_user),
  133. ):
  134. tenant_no = _tenant(db, user)
  135. filters = [
  136. or_(
  137. BxAdvanceApply.applicant_id == user.id,
  138. BxAdvanceApply.create_by == user.id,
  139. )
  140. ]
  141. if status:
  142. filters.append(BxAdvanceApply.status == status)
  143. if keyword:
  144. filters.append(
  145. or_(
  146. BxAdvanceApply.apply_code.contains(keyword),
  147. BxAdvanceApply.applicant_name.contains(keyword),
  148. )
  149. )
  150. append_create_time_filters(
  151. filters,
  152. BxAdvanceApply,
  153. apply_date=applyDate,
  154. apply_date_from=applyDateFrom,
  155. apply_date_to=applyDateTo,
  156. )
  157. rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
  158. out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
  159. _fill_offset_amounts(db, tenant_no, rows, out_rows)
  160. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  161. @router.get("/my/stats", response_model=ApiResponse[AdvanceMyStatsOut])
  162. def my_advance_stats(
  163. db: Session = Depends(get_db),
  164. user: BxUserContext = Depends(get_bx_current_user),
  165. ):
  166. tenant_no = _tenant(db, user)
  167. data = AdvanceService.my_apply_stats(db, user, tenant_no)
  168. return ApiResponse(data=AdvanceMyStatsOut.model_validate(data))
  169. @router.get("/stats/status-counts", response_model=ApiResponse[dict])
  170. def advance_status_counts(
  171. db: Session = Depends(get_db),
  172. user: BxUserContext = Depends(get_bx_current_user),
  173. ):
  174. tenant_no = _tenant(db, user)
  175. return ApiResponse(data=AdvanceService.global_status_counts(db, tenant_no, user=user))
  176. @router.get("/pending-approval", response_model=ApiResponse[PagedData[AdvanceApplyOut]])
  177. def pending_approval(
  178. page: int = 1,
  179. pageSize: int = Query(10, ge=1, le=500),
  180. keyword: str = "",
  181. applicantId: Optional[str] = None,
  182. applyDate: Optional[str] = None,
  183. applyDateFrom: Optional[str] = None,
  184. applyDateTo: Optional[str] = None,
  185. db: Session = Depends(get_db),
  186. user: BxUserContext = Depends(get_bx_current_user),
  187. ):
  188. tenant_no = _tenant(db, user)
  189. applicant_id = str(applicantId).strip() if applicantId else None
  190. rows, total = AdvanceService.pending_approval(
  191. db,
  192. user,
  193. tenant_no,
  194. page=page,
  195. page_size=pageSize,
  196. keyword=keyword,
  197. applicant_id=applicant_id,
  198. apply_date=applyDate,
  199. apply_date_from=applyDateFrom,
  200. apply_date_to=applyDateTo,
  201. )
  202. out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
  203. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  204. @router.get("/pending-approval/stats", response_model=ApiResponse[AdvanceApproverPendingStatsOut])
  205. def advance_pending_approval_stats(
  206. db: Session = Depends(get_db),
  207. user: BxUserContext = Depends(get_bx_current_user),
  208. ):
  209. tenant_no = _tenant(db, user)
  210. data = AdvanceService.pending_approval_stats(db, user, tenant_no)
  211. return ApiResponse(data=AdvanceApproverPendingStatsOut.model_validate(data))
  212. @router.get("/approved-by-me", response_model=ApiResponse[PagedData[AdvanceApplyOut]])
  213. def approved_by_me(
  214. page: int = 1,
  215. pageSize: int = Query(10, ge=1, le=500),
  216. keyword: str = "",
  217. applicantId: Optional[str] = None,
  218. applyDate: Optional[str] = None,
  219. applyDateFrom: Optional[str] = None,
  220. applyDateTo: Optional[str] = None,
  221. db: Session = Depends(get_db),
  222. user: BxUserContext = Depends(get_bx_current_user),
  223. ):
  224. tenant_no = _tenant(db, user)
  225. applicant_id = str(applicantId).strip() if applicantId else None
  226. rows, total = AdvanceService.approved_by_me(
  227. db,
  228. user,
  229. tenant_no,
  230. page=page,
  231. page_size=pageSize,
  232. keyword=keyword,
  233. applicant_id=applicant_id,
  234. apply_date=applyDate,
  235. apply_date_from=applyDateFrom,
  236. apply_date_to=applyDateTo,
  237. )
  238. out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
  239. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  240. @router.post("/export")
  241. def export_advances(
  242. body: BxApplyListExportBody,
  243. db: Session = Depends(get_db),
  244. user: BxUserContext = Depends(get_bx_current_user),
  245. ):
  246. try:
  247. tenant_no = _tenant(db, user)
  248. filters = _advance_list_filters(body)
  249. rows, _ = AdvanceService.get_paged(
  250. db, tenant_no, page=1, page_size=EXPORT_MAX_ROWS, filters=filters, user=user
  251. )
  252. export_rows = _export_advance_rows(db, tenant_no, rows)
  253. return respond_xlsx(
  254. db,
  255. tenant_no=tenant_no,
  256. sheet_title="备用金申请",
  257. columns=ADVANCE_EXPORT_COLUMNS,
  258. rows=export_rows,
  259. filename_stem="备用金申请",
  260. requested_columns=body.columns,
  261. )
  262. except ExportValidationError as e:
  263. raise HTTPException(status_code=400, detail=str(e)) from e
  264. @router.post("/my/export")
  265. def export_my_advances(
  266. body: BxApplyListExportBody,
  267. db: Session = Depends(get_db),
  268. user: BxUserContext = Depends(get_bx_current_user),
  269. ):
  270. try:
  271. tenant_no = _tenant(db, user)
  272. filters = [
  273. or_(
  274. BxAdvanceApply.applicant_id == user.id,
  275. BxAdvanceApply.create_by == user.id,
  276. )
  277. ]
  278. filters.extend(_advance_list_filters(body))
  279. rows, _ = AdvanceService.get_paged(
  280. db, tenant_no, page=1, page_size=EXPORT_MAX_ROWS, filters=filters, user=user
  281. )
  282. export_rows = _export_advance_rows(db, tenant_no, rows)
  283. return respond_xlsx(
  284. db,
  285. tenant_no=tenant_no,
  286. sheet_title="我的备用金",
  287. columns=ADVANCE_EXPORT_COLUMNS,
  288. rows=export_rows,
  289. filename_stem="我的备用金申请",
  290. requested_columns=body.columns,
  291. )
  292. except ExportValidationError as e:
  293. raise HTTPException(status_code=400, detail=str(e)) from e
  294. @router.post("/pending-approval/export")
  295. def export_pending_approval_advances(
  296. body: BxApplyListExportBody,
  297. db: Session = Depends(get_db),
  298. user: BxUserContext = Depends(get_bx_current_user),
  299. ):
  300. try:
  301. tenant_no = _tenant(db, user)
  302. applicant_id = str(body.applicant_id).strip() if body.applicant_id else None
  303. rows, _ = AdvanceService.pending_approval(
  304. db,
  305. user,
  306. tenant_no,
  307. page=1,
  308. page_size=EXPORT_MAX_ROWS,
  309. keyword=body.keyword or "",
  310. applicant_id=applicant_id,
  311. apply_date=body.apply_date,
  312. apply_date_from=body.apply_date_from,
  313. apply_date_to=body.apply_date_to,
  314. )
  315. if body.ids:
  316. id_set = {str(i) for i in body.ids}
  317. rows = [r for r in rows if str(r.id) in id_set]
  318. export_rows = _export_advance_rows(db, tenant_no, rows, with_offset=False)
  319. return respond_xlsx(
  320. db,
  321. tenant_no=tenant_no,
  322. sheet_title="备用金待审批",
  323. columns=ADVANCE_EXPORT_COLUMNS,
  324. rows=export_rows,
  325. filename_stem="备用金待审批",
  326. requested_columns=body.columns,
  327. )
  328. except ExportValidationError as e:
  329. raise HTTPException(status_code=400, detail=str(e)) from e
  330. @router.post("/approved-by-me/export")
  331. def export_approved_by_me_advances(
  332. body: BxApplyListExportBody,
  333. db: Session = Depends(get_db),
  334. user: BxUserContext = Depends(get_bx_current_user),
  335. ):
  336. try:
  337. tenant_no = _tenant(db, user)
  338. applicant_id = str(body.applicant_id).strip() if body.applicant_id else None
  339. rows, _ = AdvanceService.approved_by_me(
  340. db,
  341. user,
  342. tenant_no,
  343. page=1,
  344. page_size=EXPORT_MAX_ROWS,
  345. keyword=body.keyword or "",
  346. applicant_id=applicant_id,
  347. apply_date=body.apply_date,
  348. apply_date_from=body.apply_date_from,
  349. apply_date_to=body.apply_date_to,
  350. )
  351. if body.ids:
  352. id_set = {str(i) for i in body.ids}
  353. rows = [r for r in rows if str(r.id) in id_set]
  354. export_rows = _export_advance_rows(db, tenant_no, rows, with_offset=False)
  355. return respond_xlsx(
  356. db,
  357. tenant_no=tenant_no,
  358. sheet_title="备用金已审批",
  359. columns=ADVANCE_EXPORT_COLUMNS,
  360. rows=export_rows,
  361. filename_stem="备用金已审批",
  362. requested_columns=body.columns,
  363. )
  364. except ExportValidationError as e:
  365. raise HTTPException(status_code=400, detail=str(e)) from e
  366. @router.get("/{advance_id}", response_model=ApiResponse[AdvanceApplyOut])
  367. def get_advance(
  368. advance_id: str,
  369. db: Session = Depends(get_db),
  370. user: BxUserContext = Depends(get_bx_current_user),
  371. ):
  372. tenant_no = _tenant(db, user)
  373. advance = AdvanceService.get_by_id(db, advance_id, tenant_no, user=user)
  374. if not advance:
  375. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  376. out = AdvanceApplyOut.model_validate(advance)
  377. offset_map = AdvanceService.offset_amount_map(db, tenant_no, [advance_id])
  378. out.offset_amount = offset_map.get(advance_id, 0.0)
  379. return ApiResponse(data=out)
  380. @router.get("/{advance_id}/approvals", response_model=ApiResponse[List[AdvanceApprovalOut]])
  381. def get_approvals(
  382. advance_id: str,
  383. db: Session = Depends(get_db),
  384. user: BxUserContext = Depends(get_bx_current_user),
  385. ):
  386. tenant_no = _tenant(db, user)
  387. if not AdvanceService.get_by_id(db, advance_id, tenant_no, user=user):
  388. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  389. approvals = AdvanceService.get_approvals(db, advance_id, tenant_no, user=user)
  390. return ApiResponse(data=[AdvanceApprovalOut.model_validate(a) for a in approvals])
  391. @router.get("/{advance_id}/approval-history", response_model=ApiResponse[List[AdvanceApprovalOut]])
  392. def get_approval_history(
  393. advance_id: str,
  394. db: Session = Depends(get_db),
  395. user: BxUserContext = Depends(get_bx_current_user),
  396. ):
  397. tenant_no = _tenant(db, user)
  398. if not AdvanceService.get_by_id(db, advance_id, tenant_no, user=user):
  399. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  400. history = AdvanceService.get_approval_history(db, advance_id, tenant_no, user=user)
  401. return ApiResponse(data=[AdvanceApprovalOut.model_validate(a) for a in history])
  402. @router.post("/", response_model=ApiResponse[AdvanceApplyOut])
  403. def create_advance(
  404. body: AdvanceApplyCreate,
  405. db: Session = Depends(get_db),
  406. user: BxUserContext = Depends(get_bx_current_user),
  407. ):
  408. tenant_no = _tenant(db, user)
  409. data = body.model_dump(exclude_unset=True)
  410. eid = data.get("expense_item_id")
  411. if eid:
  412. if not ExpenseItemService.get_by_id(db, eid, tenant_no, user=user, skip_scope=True):
  413. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"费用项目 {eid} 不存在")
  414. data["applicant_id"] = user.id
  415. data["applicant_name"] = user.name
  416. data["apply_code"] = generate_apply_code(db, "BYJ", tenant_no)
  417. data["status"] = "draft"
  418. wf = (
  419. db.query(BxWorkflowDef)
  420. .filter(
  421. BxWorkflowDef.biz_type == "advance",
  422. BxWorkflowDef.tenant_no == tenant_no,
  423. BxWorkflowDef.del_flag == 0,
  424. )
  425. .first()
  426. )
  427. if wf:
  428. data["wf_id"] = wf.id
  429. advance = AdvanceService.create(db, data, user=user)
  430. db.commit()
  431. db.refresh(advance)
  432. return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
  433. @router.put("/{advance_id}", response_model=ApiResponse[AdvanceApplyOut])
  434. def update_advance(
  435. advance_id: str,
  436. body: AdvanceApplyUpdate,
  437. db: Session = Depends(get_db),
  438. user: BxUserContext = Depends(get_bx_current_user),
  439. ):
  440. tenant_no = _tenant(db, user)
  441. data = body.model_dump(exclude_unset=True)
  442. if data.get("expense_item_id"):
  443. eid = data["expense_item_id"]
  444. if not ExpenseItemService.get_by_id(db, eid, tenant_no, user=user, skip_scope=True):
  445. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"费用项目 {eid} 不存在")
  446. advance = AdvanceService.update(db, advance_id, data, user=user)
  447. if not advance:
  448. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  449. db.commit()
  450. db.refresh(advance)
  451. return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
  452. @router.post("/{advance_id}/submit", response_model=ApiResponse[AdvanceApplyOut])
  453. def submit_advance(
  454. advance_id: str,
  455. db: Session = Depends(get_db),
  456. user: BxUserContext = Depends(get_bx_current_user),
  457. ):
  458. advance = AdvanceService.submit(db, advance_id, user)
  459. if not advance:
  460. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  461. db.commit()
  462. db.refresh(advance)
  463. return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
  464. @router.post("/{advance_id}/approve", response_model=ApiResponse[AdvanceApplyOut])
  465. def approve_advance(
  466. advance_id: str,
  467. body: AdvanceApprovalCreate,
  468. db: Session = Depends(get_db),
  469. user: BxUserContext = Depends(get_bx_current_user),
  470. ):
  471. advance = AdvanceService.approve(db, advance_id, user, body.approval_opinion or "")
  472. if not advance:
  473. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在或无权审批")
  474. db.commit()
  475. db.refresh(advance)
  476. return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
  477. @router.post("/{advance_id}/reject", response_model=ApiResponse[AdvanceApplyOut])
  478. def reject_advance(
  479. advance_id: str,
  480. body: AdvanceApprovalCreate,
  481. db: Session = Depends(get_db),
  482. user: BxUserContext = Depends(get_bx_current_user),
  483. ):
  484. advance = AdvanceService.reject(db, advance_id, user, body.approval_opinion or "")
  485. if not advance:
  486. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在或无权审批")
  487. db.commit()
  488. db.refresh(advance)
  489. return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
  490. @router.delete("/{advance_id}", response_model=ApiResponse[None])
  491. def delete_advance(
  492. advance_id: str,
  493. db: Session = Depends(get_db),
  494. user: BxUserContext = Depends(get_bx_current_user),
  495. ):
  496. tenant_no = _tenant(db, user)
  497. if not AdvanceService.get_by_id(db, advance_id, tenant_no, user=user):
  498. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  499. AdvanceService.soft_delete(db, advance_id, user=user)
  500. db.commit()
  501. return ApiResponse(data=None)