payment.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. from typing import Optional
  2. from fastapi import APIRouter, Depends, HTTPException, status
  3. from fastapi.responses import StreamingResponse
  4. from sqlalchemy import func, or_
  5. from sqlalchemy.orm import Session
  6. from app.database import get_db
  7. from app.core.deps import require_roles
  8. from app.models.user import User
  9. from app.models.advance import AdvanceApply
  10. from app.models.reimburse import ReimburseApply, ReimburseOffset
  11. from app.schemas.common import ApiResponse, PagedData
  12. from app.schemas.reimburse import (
  13. ReimburseApplyOut,
  14. OffsetRequest,
  15. PayRequest,
  16. BatchOffsetRequest,
  17. ReimburseOffsetRecordOut,
  18. PaymentFinanceStatsOut,
  19. )
  20. from app.schemas.advance import AdvanceIssueOut, AdvanceIssueRequest, AdvanceOffsetRecordOut, AdvanceFinanceIssueStatsOut
  21. from app.services.advance import AdvanceService
  22. from app.services.attachment import AttachmentService
  23. from app.services.reimburse import ReimburseService
  24. from app.utils.date_range import build_create_time_filters
  25. router = APIRouter(prefix="/api/payments", tags=["支付处理"])
  26. @router.get("/pending", response_model=ApiResponse[PagedData])
  27. def list_pending_payments(
  28. page: int = 1,
  29. pageSize: int = 10,
  30. keyword: str = "",
  31. applicantId: Optional[int] = None,
  32. applyDate: Optional[str] = None,
  33. applyDateStart: Optional[str] = None,
  34. applyDateEnd: Optional[str] = None,
  35. db: Session = Depends(get_db),
  36. _: User = Depends(require_roles("FINANCE")),
  37. ):
  38. filters = [ReimburseApply.del_flag == 0, ReimburseApply.status == "approved"]
  39. if keyword:
  40. filters.append(or_(ReimburseApply.apply_code.contains(keyword), ReimburseApply.applicant_name.contains(keyword)))
  41. if applicantId is not None:
  42. filters.append(ReimburseApply.applicant_id == applicantId)
  43. filters.extend(
  44. build_create_time_filters(
  45. ReimburseApply.create_time, applyDate, applyDateStart, applyDateEnd
  46. )
  47. )
  48. rows_r, total_r = ReimburseService.get_paged(db, page=page, page_size=pageSize, filters=filters)
  49. outs = ReimburseService.out_rows_with_attachment_counts(db, rows_r)
  50. all_rows = [{"type": "reimburse", "data": o} for o in outs]
  51. paged_data = PagedData(rows=all_rows, total=total_r, page=page, page_size=pageSize)
  52. return ApiResponse(data=paged_data)
  53. @router.get("/stats", response_model=ApiResponse[PaymentFinanceStatsOut])
  54. def payment_finance_stats(
  55. db: Session = Depends(get_db),
  56. _: User = Depends(require_roles("FINANCE")),
  57. ):
  58. data = ReimburseService.finance_payment_stats(db)
  59. return ApiResponse(data=PaymentFinanceStatsOut.model_validate(data))
  60. @router.get("/processed", response_model=ApiResponse[PagedData])
  61. def list_processed_payments(
  62. page: int = 1,
  63. pageSize: int = 10,
  64. keyword: str = "",
  65. applicantId: Optional[int] = None,
  66. applyDate: Optional[str] = None,
  67. applyDateStart: Optional[str] = None,
  68. applyDateEnd: Optional[str] = None,
  69. db: Session = Depends(get_db),
  70. _: User = Depends(require_roles("FINANCE")),
  71. ):
  72. filters = [ReimburseApply.del_flag == 0, ReimburseApply.status.in_(["processed", "paid", "offset"])]
  73. if keyword:
  74. filters.append(or_(ReimburseApply.apply_code.contains(keyword), ReimburseApply.applicant_name.contains(keyword)))
  75. if applicantId is not None:
  76. filters.append(ReimburseApply.applicant_id == applicantId)
  77. filters.extend(
  78. build_create_time_filters(
  79. ReimburseApply.create_time, applyDate, applyDateStart, applyDateEnd
  80. )
  81. )
  82. rows_r, total_r = ReimburseService.get_paged(db, page=page, page_size=pageSize, filters=filters)
  83. outs = ReimburseService.out_rows_with_attachment_counts(db, rows_r)
  84. all_rows = [{"type": "reimburse", "data": o} for o in outs]
  85. paged_data = PagedData(rows=all_rows, total=total_r, page=page, page_size=pageSize)
  86. return ApiResponse(data=paged_data)
  87. @router.post("/{id}/pay", response_model=ApiResponse[ReimburseApplyOut])
  88. def pay(
  89. id: int,
  90. body: PayRequest | None = None,
  91. db: Session = Depends(get_db),
  92. current_user: User = Depends(require_roles("FINANCE")),
  93. ):
  94. pay_remark = body.pay_remark if body else ""
  95. actual_amount = body.actual_amount if body else None
  96. reimburse = ReimburseService.pay(db, id, current_user, pay_remark=pay_remark, actual_amount=actual_amount)
  97. if not reimburse:
  98. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  99. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, reimburse))
  100. @router.post("/{id}/offset", response_model=ApiResponse[ReimburseApplyOut])
  101. def offset(
  102. id: int,
  103. body: OffsetRequest,
  104. db: Session = Depends(get_db),
  105. current_user: User = Depends(require_roles("FINANCE")),
  106. ):
  107. reimburse = ReimburseService.offset(db, id, body.advance_id, body.offset_amount, current_user, remark=body.remark or "")
  108. if not reimburse:
  109. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  110. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, reimburse))
  111. @router.get("/advance-issues", response_model=ApiResponse[PagedData[AdvanceIssueOut]])
  112. def list_advance_issues(
  113. page: int = 1,
  114. pageSize: int = 10,
  115. issue_status: str | None = None,
  116. keyword: str = "",
  117. applicantId: Optional[int] = None,
  118. applyDate: Optional[str] = None,
  119. applyDateStart: Optional[str] = None,
  120. applyDateEnd: Optional[str] = None,
  121. db: Session = Depends(get_db),
  122. _: User = Depends(require_roles("FINANCE")),
  123. ):
  124. filters = [AdvanceApply.del_flag == 0]
  125. if issue_status == "pending":
  126. filters.append(AdvanceApply.status == "approved")
  127. filters.append(or_(AdvanceApply.issue_status == None, AdvanceApply.issue_status != "issued"))
  128. elif issue_status == "issued":
  129. filters.append(AdvanceApply.status == "issued")
  130. else:
  131. filters.append(or_(AdvanceApply.status == "approved", AdvanceApply.status == "issued"))
  132. if keyword:
  133. filters.append(or_(AdvanceApply.apply_code.contains(keyword), AdvanceApply.applicant_name.contains(keyword)))
  134. if applicantId is not None:
  135. filters.append(AdvanceApply.applicant_id == applicantId)
  136. filters.extend(
  137. build_create_time_filters(
  138. AdvanceApply.create_time, applyDate, applyDateStart, applyDateEnd
  139. )
  140. )
  141. rows, total = AdvanceService.get_paged(db, page=page, page_size=pageSize, filters=filters)
  142. out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
  143. # Compute total offset_amount per advance from bx_reimburse_offset
  144. if out_rows:
  145. advance_ids = [r.id for r in rows]
  146. offset_map = dict(
  147. db.query(ReimburseOffset.advance_id, func.coalesce(func.sum(ReimburseOffset.offset_amount), 0))
  148. .filter(ReimburseOffset.advance_id.in_(advance_ids))
  149. .group_by(ReimburseOffset.advance_id)
  150. .all()
  151. )
  152. for out_row in out_rows:
  153. out_row.offset_amount = float(offset_map.get(out_row.id, 0))
  154. paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
  155. return ApiResponse(data=paged_data)
  156. @router.get("/advance-issues/stats", response_model=ApiResponse[AdvanceFinanceIssueStatsOut])
  157. def advance_issue_stats(
  158. db: Session = Depends(get_db),
  159. _: User = Depends(require_roles("FINANCE")),
  160. ):
  161. data = AdvanceService.finance_issue_stats(db)
  162. return ApiResponse(data=AdvanceFinanceIssueStatsOut.model_validate(data))
  163. @router.get("/advance-issues/export")
  164. def export_advance_issues(
  165. issue_status: str | None = None,
  166. keyword: str = "",
  167. applicantId: Optional[int] = None,
  168. applyDate: Optional[str] = None,
  169. applyDateStart: Optional[str] = None,
  170. applyDateEnd: Optional[str] = None,
  171. db: Session = Depends(get_db),
  172. _: User = Depends(require_roles("FINANCE")),
  173. ):
  174. import csv
  175. import io
  176. from datetime import datetime
  177. filters = [AdvanceApply.del_flag == 0]
  178. if issue_status == "pending":
  179. filters.append(AdvanceApply.status == "approved")
  180. filters.append(or_(AdvanceApply.issue_status == None, AdvanceApply.issue_status != "issued"))
  181. elif issue_status == "issued":
  182. filters.append(AdvanceApply.status == "issued")
  183. else:
  184. filters.append(or_(AdvanceApply.status == "approved", AdvanceApply.status == "issued"))
  185. if keyword:
  186. filters.append(or_(AdvanceApply.apply_code.contains(keyword), AdvanceApply.applicant_name.contains(keyword)))
  187. if applicantId is not None:
  188. filters.append(AdvanceApply.applicant_id == applicantId)
  189. filters.extend(
  190. build_create_time_filters(
  191. AdvanceApply.create_time, applyDate, applyDateStart, applyDateEnd
  192. )
  193. )
  194. rows, _ = AdvanceService.get_paged(db, page=1, page_size=99999, filters=filters)
  195. out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
  196. buf = io.StringIO()
  197. writer = csv.writer(buf)
  198. writer.writerow(["申请单号", "申请人", "申请原因", "申请金额", "审批通过时间", "发放状态", "发放时间", "发放操作人", "实际发放金额", "发放备注"])
  199. for r in out_rows:
  200. writer.writerow([
  201. r.apply_code,
  202. r.applicant_name,
  203. r.reason,
  204. r.amount,
  205. r.approve_time or "",
  206. "已发放" if r.issue_status == "issued" else "待发放",
  207. r.issue_time or "",
  208. r.issue_user_name or "",
  209. r.actual_amount or "",
  210. r.issue_remark or "",
  211. ])
  212. buf.seek(0)
  213. filename = f"备用金发放列表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
  214. return StreamingResponse(
  215. buf,
  216. media_type="text/csv",
  217. headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
  218. )
  219. @router.post("/advance/{advance_id}/issue", response_model=ApiResponse[AdvanceIssueOut])
  220. def issue_advance(
  221. advance_id: int,
  222. body: AdvanceIssueRequest,
  223. db: Session = Depends(get_db),
  224. current_user: User = Depends(require_roles("FINANCE")),
  225. ):
  226. issue_atts = AttachmentService.get_by_biz(db, "advance_issue", advance_id)
  227. if not issue_atts:
  228. raise HTTPException(
  229. status_code=status.HTTP_400_BAD_REQUEST,
  230. detail="请至少上传 1 个发放附件",
  231. )
  232. advance = AdvanceService.issue(db, advance_id, current_user, body.issue_remark or "", body.actual_amount)
  233. if not advance:
  234. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  235. return ApiResponse(data=AdvanceIssueOut.model_validate(advance))
  236. @router.post("/{id}/batch-offset", response_model=ApiResponse[ReimburseApplyOut])
  237. def batch_offset(
  238. id: int,
  239. body: BatchOffsetRequest,
  240. db: Session = Depends(get_db),
  241. current_user: User = Depends(require_roles("FINANCE")),
  242. ):
  243. reimburse = ReimburseService.batch_offset(
  244. db, id,
  245. [item.model_dump() for item in body.items],
  246. body.pay_amount,
  247. current_user,
  248. remark=body.remark or "",
  249. )
  250. if not reimburse:
  251. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  252. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, reimburse))
  253. @router.get("/advance/{advance_id}/offset-records", response_model=ApiResponse[list[AdvanceOffsetRecordOut]])
  254. def list_advance_offset_records(
  255. advance_id: int,
  256. db: Session = Depends(get_db),
  257. _: User = Depends(require_roles("FINANCE")),
  258. ):
  259. rows = (
  260. db.query(ReimburseOffset, ReimburseApply)
  261. .join(ReimburseApply, ReimburseApply.id == ReimburseOffset.reimburse_id)
  262. .filter(
  263. ReimburseOffset.advance_id == advance_id,
  264. ReimburseApply.del_flag == 0,
  265. )
  266. .order_by(ReimburseOffset.create_time.desc(), ReimburseOffset.id.desc())
  267. .all()
  268. )
  269. records: list[AdvanceOffsetRecordOut] = []
  270. for offset, reimburse in rows:
  271. records.append(
  272. AdvanceOffsetRecordOut(
  273. reimburse_id=reimburse.id,
  274. reimburse_apply_code=reimburse.apply_code,
  275. reimburse_title=reimburse.title,
  276. reimburse_applicant_name=reimburse.applicant_name,
  277. offset_amount=float(offset.offset_amount or 0),
  278. remark=offset.remark,
  279. operator=offset.create_by,
  280. create_time=offset.create_time,
  281. )
  282. )
  283. return ApiResponse(data=records)
  284. @router.get("/{id}/offset-records", response_model=ApiResponse[list[ReimburseOffsetRecordOut]])
  285. def list_reimburse_offset_records(
  286. id: int,
  287. db: Session = Depends(get_db),
  288. _: User = Depends(require_roles("FINANCE")),
  289. ):
  290. rows = (
  291. db.query(ReimburseOffset, AdvanceApply)
  292. .join(AdvanceApply, AdvanceApply.id == ReimburseOffset.advance_id)
  293. .filter(
  294. ReimburseOffset.reimburse_id == id,
  295. AdvanceApply.del_flag == 0,
  296. )
  297. .order_by(ReimburseOffset.create_time.desc(), ReimburseOffset.id.desc())
  298. .all()
  299. )
  300. records: list[ReimburseOffsetRecordOut] = []
  301. for offset, advance in rows:
  302. records.append(
  303. ReimburseOffsetRecordOut(
  304. advance_id=advance.id,
  305. advance_apply_code=advance.apply_code,
  306. advance_applicant_name=advance.applicant_name,
  307. advance_reason=advance.reason,
  308. offset_amount=float(offset.offset_amount or 0),
  309. remark=offset.remark,
  310. operator=offset.create_by,
  311. create_time=offset.create_time,
  312. )
  313. )
  314. return ApiResponse(data=records)