reimburse.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. import io
  2. from datetime import date as date_cls
  3. from typing import List, Optional
  4. from urllib.parse import quote
  5. from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
  6. from fastapi.responses import StreamingResponse
  7. from sqlalchemy import or_
  8. from sqlalchemy.orm import Session
  9. from app.core.bx_auth import BxUserContext, get_bx_current_user
  10. from app.core.database import get_db
  11. from app.core.tenant_context import get_tenant_no
  12. from app.models.bx_models import BxReimburseApply, BxWorkflowDef
  13. from app.schemas.bx.common import ApiResponse, PagedData
  14. from app.schemas.bx.reimburse import (
  15. ReimburseApplyCreate,
  16. ReimburseApplyOut,
  17. ReimburseApplyUpdate,
  18. ReimburseApprovalCreate,
  19. ReimburseApprovalOut,
  20. ReimburseApproverPendingStatsOut,
  21. ReimburseMyStatsOut,
  22. ReimburseSubmitBody,
  23. )
  24. from app.services.bx.expense_item import ExpenseItemService
  25. from app.services.bx.reimburse import ReimburseService
  26. from app.services.bx.reimburse_import import (
  27. build_applicant_label,
  28. build_import_template_bytes,
  29. import_reimburse_workbook,
  30. load_applicant_options,
  31. load_expense_nodes_flat,
  32. )
  33. from app.utils.bx_codegen import generate_apply_code, generate_title, parse_optional_date
  34. from app.utils.bx_date_range import append_create_time_filters
  35. router = APIRouter(prefix="/reimburses", tags=["报销管理"])
  36. def _tenant(db: Session, user: BxUserContext) -> str:
  37. return get_tenant_no(db, user.to_tenant_user_dict())
  38. def _build_details_data(
  39. db: Session,
  40. tenant_no: str,
  41. body_details,
  42. *,
  43. user: BxUserContext,
  44. ) -> List[dict]:
  45. details_data: List[dict] = []
  46. for d in body_details:
  47. item = ExpenseItemService.get_by_id(
  48. db, d.expense_item_id, tenant_no, user=user, skip_scope=True
  49. )
  50. if not item:
  51. raise HTTPException(
  52. status_code=status.HTTP_400_BAD_REQUEST,
  53. detail=f"费用项目 {d.expense_item_id} 不存在",
  54. )
  55. try:
  56. occur_date = parse_optional_date(d.occur_date)
  57. except ValueError:
  58. raise HTTPException(
  59. status_code=status.HTTP_400_BAD_REQUEST,
  60. detail="发生时间格式无效,请使用 YYYY-MM-DD",
  61. )
  62. details_data.append(
  63. {
  64. "expense_id": d.expense_item_id,
  65. "expense_name": item.item_name,
  66. "amount": d.amount,
  67. "remark": d.remark,
  68. "occur_date": occur_date,
  69. }
  70. )
  71. return details_data
  72. @router.get("/generate-title", response_model=ApiResponse[str])
  73. def get_generated_title(
  74. reimburseDate: Optional[str] = None,
  75. excludeApplyId: Optional[str] = None,
  76. applicantId: Optional[str] = None,
  77. db: Session = Depends(get_db),
  78. user: BxUserContext = Depends(get_bx_current_user),
  79. ):
  80. tenant_no = _tenant(db, user)
  81. exclude_id = str(excludeApplyId).strip() if excludeApplyId else None
  82. _, applicant_name = ReimburseService.resolve_applicant(
  83. db, user, applicantId, tenant_no=tenant_no
  84. )
  85. title = generate_title(db, applicant_name, tenant_no, reimburseDate, exclude_id)
  86. return ApiResponse(data=title)
  87. @router.get("/import-template")
  88. def download_reimburse_import_template(
  89. db: Session = Depends(get_db),
  90. user: BxUserContext = Depends(get_bx_current_user),
  91. ):
  92. tenant_no = _tenant(db, user)
  93. nodes = load_expense_nodes_flat(db, tenant_no)
  94. applicants = load_applicant_options(db, user=user, tenant_no=tenant_no)
  95. me = next((a for a in applicants if a["id"] == user.id), None)
  96. if me:
  97. cur_label = build_applicant_label(me["nick_name"], me["user_no"], me["id"])
  98. else:
  99. cur_label = build_applicant_label(user.name or "", "", user.id)
  100. applicants = [
  101. {"id": user.id, "nick_name": user.name or "", "user_no": ""}
  102. ] + applicants
  103. raw = build_import_template_bytes(
  104. expense_nodes=nodes,
  105. applicants=applicants,
  106. current_applicant_label=cur_label,
  107. today=date_cls.today(),
  108. )
  109. filename = quote("报销申请导入模板.xlsx")
  110. return StreamingResponse(
  111. io.BytesIO(raw),
  112. media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  113. headers={
  114. "Content-Disposition": f"attachment; filename={filename}; filename*=UTF-8''{filename}"
  115. },
  116. )
  117. @router.post("/import", response_model=ApiResponse[dict])
  118. def import_reimburses(
  119. file: UploadFile = File(...),
  120. db: Session = Depends(get_db),
  121. user: BxUserContext = Depends(get_bx_current_user),
  122. ):
  123. content = file.file.read()
  124. if not content:
  125. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件为空")
  126. try:
  127. result = import_reimburse_workbook(db, file_bytes=content, user=user)
  128. except ValueError as exc:
  129. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
  130. return ApiResponse(data=result)
  131. @router.get("/", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
  132. def list_reimburses(
  133. page: int = 1,
  134. pageSize: int = 10,
  135. status: Optional[str] = None,
  136. keyword: str = "",
  137. expense_id: Optional[str] = None,
  138. expenseName: Optional[str] = None,
  139. applicantId: Optional[str] = None,
  140. applyDate: Optional[str] = None,
  141. applyDateFrom: Optional[str] = None,
  142. applyDateTo: Optional[str] = None,
  143. db: Session = Depends(get_db),
  144. user: BxUserContext = Depends(get_bx_current_user),
  145. ):
  146. tenant_no = _tenant(db, user)
  147. filters = []
  148. if status:
  149. filters.append(BxReimburseApply.status == status)
  150. if keyword:
  151. filters.append(
  152. or_(
  153. BxReimburseApply.apply_code.contains(keyword),
  154. BxReimburseApply.applicant_name.contains(keyword),
  155. BxReimburseApply.title.contains(keyword),
  156. )
  157. )
  158. if expense_id:
  159. filters.append(BxReimburseApply.expense_id == str(expense_id).strip())
  160. expense_filter = ReimburseService.build_expense_name_condition(db, tenant_no, expenseName)
  161. if expense_filter is not None:
  162. filters.append(expense_filter)
  163. if applicantId:
  164. filters.append(BxReimburseApply.applicant_id == str(applicantId).strip())
  165. append_create_time_filters(
  166. filters,
  167. BxReimburseApply,
  168. apply_date=applyDate,
  169. apply_date_from=applyDateFrom,
  170. apply_date_to=applyDateTo,
  171. )
  172. rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
  173. out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  174. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  175. @router.get("/my", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
  176. def my_reimburses(
  177. page: int = 1,
  178. pageSize: int = Query(10, ge=1, le=500),
  179. status: Optional[str] = None,
  180. keyword: str = "",
  181. expenseName: 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. filters = [
  190. or_(
  191. BxReimburseApply.applicant_id == user.id,
  192. BxReimburseApply.create_by == user.id,
  193. )
  194. ]
  195. if status:
  196. filters.append(BxReimburseApply.status == status)
  197. if keyword:
  198. filters.append(
  199. or_(
  200. BxReimburseApply.apply_code.contains(keyword),
  201. BxReimburseApply.applicant_name.contains(keyword),
  202. BxReimburseApply.title.contains(keyword),
  203. )
  204. )
  205. expense_filter = ReimburseService.build_expense_name_condition(db, tenant_no, expenseName)
  206. if expense_filter is not None:
  207. filters.append(expense_filter)
  208. append_create_time_filters(
  209. filters,
  210. BxReimburseApply,
  211. apply_date=applyDate,
  212. apply_date_from=applyDateFrom,
  213. apply_date_to=applyDateTo,
  214. )
  215. rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
  216. out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  217. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  218. @router.get("/my/stats", response_model=ApiResponse[ReimburseMyStatsOut])
  219. def my_reimburse_stats(
  220. db: Session = Depends(get_db),
  221. user: BxUserContext = Depends(get_bx_current_user),
  222. ):
  223. tenant_no = _tenant(db, user)
  224. data = ReimburseService.my_apply_stats(db, user, tenant_no)
  225. return ApiResponse(data=ReimburseMyStatsOut.model_validate(data))
  226. @router.get("/stats/status-counts", response_model=ApiResponse[dict])
  227. def reimburse_status_counts(
  228. db: Session = Depends(get_db),
  229. user: BxUserContext = Depends(get_bx_current_user),
  230. ):
  231. tenant_no = _tenant(db, user)
  232. return ApiResponse(data=ReimburseService.global_status_counts(db, tenant_no, user=user))
  233. @router.get("/pending-approval", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
  234. def pending_approval(
  235. page: int = 1,
  236. pageSize: int = 10,
  237. keyword: str = "",
  238. applicantId: Optional[str] = None,
  239. expenseName: Optional[str] = None,
  240. applyDate: Optional[str] = None,
  241. applyDateFrom: Optional[str] = None,
  242. applyDateTo: Optional[str] = None,
  243. db: Session = Depends(get_db),
  244. user: BxUserContext = Depends(get_bx_current_user),
  245. ):
  246. tenant_no = _tenant(db, user)
  247. applicant_id = str(applicantId).strip() if applicantId else None
  248. rows, total = ReimburseService.pending_approval(
  249. db,
  250. user,
  251. tenant_no,
  252. page=page,
  253. page_size=pageSize,
  254. keyword=keyword,
  255. applicant_id=applicant_id,
  256. expense_name=expenseName,
  257. apply_date=applyDate,
  258. apply_date_from=applyDateFrom,
  259. apply_date_to=applyDateTo,
  260. )
  261. out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  262. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  263. @router.get("/pending-approval/stats", response_model=ApiResponse[ReimburseApproverPendingStatsOut])
  264. def pending_approval_stats(
  265. db: Session = Depends(get_db),
  266. user: BxUserContext = Depends(get_bx_current_user),
  267. ):
  268. tenant_no = _tenant(db, user)
  269. data = ReimburseService.pending_approval_stats(db, user, tenant_no)
  270. return ApiResponse(data=ReimburseApproverPendingStatsOut.model_validate(data))
  271. @router.get("/approved-by-me", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
  272. def approved_by_me(
  273. page: int = 1,
  274. pageSize: int = 10,
  275. keyword: str = "",
  276. applicantId: Optional[str] = None,
  277. expenseName: Optional[str] = None,
  278. applyDate: Optional[str] = None,
  279. applyDateFrom: Optional[str] = None,
  280. applyDateTo: Optional[str] = None,
  281. db: Session = Depends(get_db),
  282. user: BxUserContext = Depends(get_bx_current_user),
  283. ):
  284. tenant_no = _tenant(db, user)
  285. applicant_id = str(applicantId).strip() if applicantId else None
  286. rows, total = ReimburseService.approved_by_me(
  287. db,
  288. user,
  289. tenant_no,
  290. page=page,
  291. page_size=pageSize,
  292. keyword=keyword,
  293. applicant_id=applicant_id,
  294. expense_name=expenseName,
  295. apply_date=applyDate,
  296. apply_date_from=applyDateFrom,
  297. apply_date_to=applyDateTo,
  298. )
  299. out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  300. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  301. @router.get("/{reimburse_id}", response_model=ApiResponse[ReimburseApplyOut])
  302. def get_reimburse(
  303. reimburse_id: str,
  304. db: Session = Depends(get_db),
  305. user: BxUserContext = Depends(get_bx_current_user),
  306. ):
  307. tenant_no = _tenant(db, user)
  308. reimburse = ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user)
  309. if not reimburse:
  310. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
  311. out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)
  312. return ApiResponse(data=out)
  313. @router.get("/{reimburse_id}/approvals", response_model=ApiResponse[List[ReimburseApprovalOut]])
  314. def get_approvals(
  315. reimburse_id: str,
  316. db: Session = Depends(get_db),
  317. user: BxUserContext = Depends(get_bx_current_user),
  318. ):
  319. tenant_no = _tenant(db, user)
  320. if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
  321. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
  322. approvals = ReimburseService.get_approvals(db, reimburse_id, tenant_no, user=user)
  323. return ApiResponse(data=[ReimburseApprovalOut.model_validate(a) for a in approvals])
  324. @router.get("/{reimburse_id}/approval-history", response_model=ApiResponse[List[ReimburseApprovalOut]])
  325. def get_approval_history(
  326. reimburse_id: str,
  327. db: Session = Depends(get_db),
  328. user: BxUserContext = Depends(get_bx_current_user),
  329. ):
  330. tenant_no = _tenant(db, user)
  331. if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
  332. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
  333. history = ReimburseService.get_approval_history(db, reimburse_id, tenant_no, user=user)
  334. return ApiResponse(data=[ReimburseApprovalOut.model_validate(a) for a in history])
  335. @router.post("/", response_model=ApiResponse[ReimburseApplyOut])
  336. def create_reimburse(
  337. body: ReimburseApplyCreate,
  338. db: Session = Depends(get_db),
  339. user: BxUserContext = Depends(get_bx_current_user),
  340. ):
  341. tenant_no = _tenant(db, user)
  342. details_data = _build_details_data(db, tenant_no, body.details, user=user)
  343. data = {
  344. "apply_code": generate_apply_code(db, "BX", tenant_no),
  345. "status": "draft",
  346. "remark": body.remark,
  347. "reimburse_date": body.reimburse_date,
  348. }
  349. if body.title:
  350. data["title"] = body.title
  351. wf = (
  352. db.query(BxWorkflowDef)
  353. .filter(
  354. BxWorkflowDef.biz_type == "reimburse",
  355. BxWorkflowDef.tenant_no == tenant_no,
  356. BxWorkflowDef.del_flag == 0,
  357. )
  358. .first()
  359. )
  360. if wf:
  361. data["wf_id"] = wf.id
  362. applicant_id, applicant_name = ReimburseService.resolve_applicant(
  363. db, user, body.applicant_id, tenant_no=tenant_no
  364. )
  365. reimburse = ReimburseService.create_with_details(
  366. db,
  367. data,
  368. details_data,
  369. user=user,
  370. applicant_id=applicant_id,
  371. applicant_name=applicant_name,
  372. )
  373. if body.invoice_links is not None or body.invoice_ids is not None:
  374. ReimburseService.sync_reimburse_invoices(
  375. db,
  376. reimburse.id,
  377. body.invoice_ids,
  378. tenant_no,
  379. user,
  380. applicant_id=applicant_id,
  381. invoice_links=body.invoice_links,
  382. )
  383. db.commit()
  384. db.refresh(reimburse)
  385. out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)
  386. return ApiResponse(data=out)
  387. @router.put("/{reimburse_id}", response_model=ApiResponse[ReimburseApplyOut])
  388. def update_reimburse(
  389. reimburse_id: str,
  390. body: ReimburseApplyUpdate,
  391. db: Session = Depends(get_db),
  392. user: BxUserContext = Depends(get_bx_current_user),
  393. ):
  394. tenant_no = _tenant(db, user)
  395. details_data = None
  396. if body.details is not None:
  397. details_data = _build_details_data(db, tenant_no, body.details, user=user)
  398. main_data = {}
  399. if body.remark is not None:
  400. main_data["remark"] = body.remark
  401. if body.reimburse_date is not None:
  402. main_data["reimburse_date"] = body.reimburse_date
  403. if body.title is not None:
  404. main_data["title"] = body.title
  405. if body.applicant_id is not None:
  406. applicant_id, applicant_name = ReimburseService.resolve_applicant(
  407. db, user, body.applicant_id, tenant_no=tenant_no
  408. )
  409. main_data["applicant_id"] = applicant_id
  410. main_data["applicant_name"] = applicant_name
  411. reimburse = ReimburseService.update_with_details(
  412. db, reimburse_id, main_data, details_data, user=user
  413. )
  414. if not reimburse:
  415. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
  416. if body.invoice_links is not None or body.invoice_ids is not None:
  417. ReimburseService.sync_reimburse_invoices(
  418. db,
  419. reimburse_id,
  420. body.invoice_ids,
  421. tenant_no,
  422. user,
  423. applicant_id=reimburse.applicant_id,
  424. invoice_links=body.invoice_links,
  425. )
  426. db.commit()
  427. db.refresh(reimburse)
  428. out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)
  429. return ApiResponse(data=out)
  430. @router.post("/{reimburse_id}/submit", response_model=ApiResponse[ReimburseApplyOut])
  431. def submit_reimburse(
  432. reimburse_id: str,
  433. body: ReimburseSubmitBody = ReimburseSubmitBody(),
  434. db: Session = Depends(get_db),
  435. user: BxUserContext = Depends(get_bx_current_user),
  436. ):
  437. tenant_no = _tenant(db, user)
  438. reimburse = ReimburseService.submit(
  439. db,
  440. reimburse_id,
  441. user,
  442. invoice_ids=body.invoice_ids,
  443. invoice_links=body.invoice_links,
  444. )
  445. if not reimburse:
  446. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
  447. db.commit()
  448. db.refresh(reimburse)
  449. return ApiResponse(data=ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse))
  450. @router.post("/{reimburse_id}/approve", response_model=ApiResponse[ReimburseApplyOut])
  451. def approve_reimburse(
  452. reimburse_id: str,
  453. body: ReimburseApprovalCreate,
  454. db: Session = Depends(get_db),
  455. user: BxUserContext = Depends(get_bx_current_user),
  456. ):
  457. tenant_no = _tenant(db, user)
  458. reimburse = ReimburseService.approve(db, reimburse_id, user, body.approval_opinion or "")
  459. if not reimburse:
  460. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在或无权审批")
  461. db.commit()
  462. db.refresh(reimburse)
  463. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
  464. @router.post("/{reimburse_id}/reject", response_model=ApiResponse[ReimburseApplyOut])
  465. def reject_reimburse(
  466. reimburse_id: str,
  467. body: ReimburseApprovalCreate,
  468. db: Session = Depends(get_db),
  469. user: BxUserContext = Depends(get_bx_current_user),
  470. ):
  471. tenant_no = _tenant(db, user)
  472. reimburse = ReimburseService.reject(db, reimburse_id, user, body.approval_opinion or "")
  473. if not reimburse:
  474. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在或无权审批")
  475. db.commit()
  476. db.refresh(reimburse)
  477. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
  478. @router.post("/{reimburse_id}/withdraw", response_model=ApiResponse[ReimburseApplyOut])
  479. def withdraw_reimburse(
  480. reimburse_id: str,
  481. db: Session = Depends(get_db),
  482. user: BxUserContext = Depends(get_bx_current_user),
  483. ):
  484. tenant_no = _tenant(db, user)
  485. reimburse = ReimburseService.withdraw(db, reimburse_id, user)
  486. if not reimburse:
  487. raise HTTPException(
  488. status_code=status.HTTP_400_BAD_REQUEST,
  489. detail="报销单不存在、非本人申请或当前状态不可撤回",
  490. )
  491. db.commit()
  492. db.refresh(reimburse)
  493. return ApiResponse(data=ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse))
  494. @router.delete("/{reimburse_id}", response_model=ApiResponse[None])
  495. def delete_reimburse(
  496. reimburse_id: str,
  497. db: Session = Depends(get_db),
  498. user: BxUserContext = Depends(get_bx_current_user),
  499. ):
  500. tenant_no = _tenant(db, user)
  501. if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
  502. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
  503. ReimburseService.soft_delete(db, reimburse_id, user=user)
  504. db.commit()
  505. return ApiResponse(data=None)