payment.py 13 KB

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