payment.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import csv
  2. import io
  3. from datetime import datetime
  4. from typing import Any, List, Optional
  5. from urllib.parse import quote
  6. from fastapi import APIRouter, Depends, HTTPException, status
  7. from fastapi.responses import StreamingResponse
  8. from sqlalchemy import or_
  9. from sqlalchemy.orm import Session
  10. from app.core.bx_auth import BxUserContext, get_bx_current_user
  11. from app.core.database import get_db
  12. from app.core.tenant_context import get_tenant_no
  13. from app.models.bx_models import BxAdvanceApply, BxReimburseApply, BxReimburseOffset
  14. from app.schemas.bx.advance import (
  15. AdvanceFinanceIssueStatsOut,
  16. AdvanceIssueOut,
  17. AdvanceIssueRequest,
  18. AdvanceOffsetRecordOut,
  19. )
  20. from app.schemas.bx.common import ApiResponse, PagedData
  21. from app.schemas.bx.reimburse import (
  22. BatchOffsetRequest,
  23. OffsetRequest,
  24. PayRequest,
  25. PaymentFinanceStatsOut,
  26. ReimburseApplyOut,
  27. ReimburseOffsetRecordOut,
  28. )
  29. from app.services.bx.advance import AdvanceService
  30. from app.services.bx.attachment import AttachmentService
  31. from app.services.bx.reimburse import ReimburseService
  32. from app.utils.bx_date_range import parse_create_time_day_bounds
  33. router = APIRouter(prefix="/payments", tags=["支付处理"])
  34. def _tenant(db: Session, user: BxUserContext) -> str:
  35. return get_tenant_no(db, user.to_tenant_user_dict())
  36. def _wrap_reimburse_rows(outs: List[ReimburseApplyOut]) -> List[dict]:
  37. return [{"type": "reimburse", "data": o.model_dump(by_alias=True)} for o in outs]
  38. @router.get("/pending", response_model=ApiResponse[PagedData[Any]])
  39. def list_pending_payments(
  40. page: int = 1,
  41. pageSize: int = 10,
  42. keyword: str = "",
  43. applicantId: Optional[str] = None,
  44. applyDate: Optional[str] = None,
  45. db: Session = Depends(get_db),
  46. user: BxUserContext = Depends(get_bx_current_user),
  47. ):
  48. tenant_no = _tenant(db, user)
  49. filters = [BxReimburseApply.status == "approved"]
  50. if keyword:
  51. filters.append(
  52. or_(
  53. BxReimburseApply.apply_code.contains(keyword),
  54. BxReimburseApply.applicant_name.contains(keyword),
  55. )
  56. )
  57. if applicantId:
  58. filters.append(BxReimburseApply.applicant_id == str(applicantId).strip())
  59. bounds = parse_create_time_day_bounds(applyDate)
  60. if bounds:
  61. d0, d1 = bounds
  62. filters.append(BxReimburseApply.create_time >= d0)
  63. filters.append(BxReimburseApply.create_time < d1)
  64. rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
  65. outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  66. return ApiResponse(
  67. data=PagedData(rows=_wrap_reimburse_rows(outs), total=total, page=page, page_size=pageSize)
  68. )
  69. @router.get("/stats", response_model=ApiResponse[PaymentFinanceStatsOut])
  70. def payment_finance_stats(
  71. db: Session = Depends(get_db),
  72. user: BxUserContext = Depends(get_bx_current_user),
  73. ):
  74. tenant_no = _tenant(db, user)
  75. data = ReimburseService.finance_payment_stats(db, tenant_no, user=user)
  76. return ApiResponse(data=PaymentFinanceStatsOut.model_validate(data))
  77. @router.get("/processed", response_model=ApiResponse[PagedData[Any]])
  78. def list_processed_payments(
  79. page: int = 1,
  80. pageSize: int = 10,
  81. keyword: str = "",
  82. applicantId: Optional[str] = None,
  83. applyDate: Optional[str] = None,
  84. db: Session = Depends(get_db),
  85. user: BxUserContext = Depends(get_bx_current_user),
  86. ):
  87. tenant_no = _tenant(db, user)
  88. filters = [BxReimburseApply.status.in_(["processed", "paid", "offset"])]
  89. if keyword:
  90. filters.append(
  91. or_(
  92. BxReimburseApply.apply_code.contains(keyword),
  93. BxReimburseApply.applicant_name.contains(keyword),
  94. )
  95. )
  96. if applicantId:
  97. filters.append(BxReimburseApply.applicant_id == str(applicantId).strip())
  98. bounds = parse_create_time_day_bounds(applyDate)
  99. if bounds:
  100. d0, d1 = bounds
  101. filters.append(BxReimburseApply.create_time >= d0)
  102. filters.append(BxReimburseApply.create_time < d1)
  103. rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
  104. outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  105. return ApiResponse(
  106. data=PagedData(rows=_wrap_reimburse_rows(outs), total=total, page=page, page_size=pageSize)
  107. )
  108. @router.post("/{payment_id}/pay", response_model=ApiResponse[ReimburseApplyOut])
  109. def pay(
  110. payment_id: str,
  111. body: Optional[PayRequest] = None,
  112. db: Session = Depends(get_db),
  113. user: BxUserContext = Depends(get_bx_current_user),
  114. ):
  115. tenant_no = _tenant(db, user)
  116. pay_remark = body.pay_remark if body else ""
  117. actual_amount = body.actual_amount if body else None
  118. reimburse = ReimburseService.pay(
  119. db, payment_id, user, pay_remark=pay_remark or "", actual_amount=actual_amount
  120. )
  121. if not reimburse:
  122. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  123. db.commit()
  124. db.refresh(reimburse)
  125. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
  126. @router.post("/{payment_id}/offset", response_model=ApiResponse[ReimburseApplyOut])
  127. def offset(
  128. payment_id: str,
  129. body: OffsetRequest,
  130. db: Session = Depends(get_db),
  131. user: BxUserContext = Depends(get_bx_current_user),
  132. ):
  133. tenant_no = _tenant(db, user)
  134. reimburse = ReimburseService.offset(
  135. db,
  136. payment_id,
  137. body.advance_id,
  138. body.offset_amount,
  139. user,
  140. remark=body.remark or "",
  141. )
  142. if not reimburse:
  143. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  144. db.commit()
  145. db.refresh(reimburse)
  146. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
  147. @router.post("/{payment_id}/batch-offset", response_model=ApiResponse[ReimburseApplyOut])
  148. def batch_offset(
  149. payment_id: str,
  150. body: BatchOffsetRequest,
  151. db: Session = Depends(get_db),
  152. user: BxUserContext = Depends(get_bx_current_user),
  153. ):
  154. tenant_no = _tenant(db, user)
  155. reimburse = ReimburseService.batch_offset(
  156. db,
  157. payment_id,
  158. [item.model_dump() for item in body.items],
  159. body.pay_amount,
  160. user,
  161. remark=body.remark or "",
  162. )
  163. if not reimburse:
  164. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  165. db.commit()
  166. db.refresh(reimburse)
  167. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
  168. @router.get("/advance-issues", response_model=ApiResponse[PagedData[AdvanceIssueOut]])
  169. def list_advance_issues(
  170. page: int = 1,
  171. pageSize: int = 10,
  172. issue_status: Optional[str] = None,
  173. keyword: str = "",
  174. applicantId: Optional[str] = None,
  175. applyDate: Optional[str] = None,
  176. db: Session = Depends(get_db),
  177. user: BxUserContext = Depends(get_bx_current_user),
  178. ):
  179. tenant_no = _tenant(db, user)
  180. filters = []
  181. if issue_status == "pending":
  182. filters.append(BxAdvanceApply.status == "approved")
  183. filters.append(or_(BxAdvanceApply.issue_status.is_(None), BxAdvanceApply.issue_status != "issued"))
  184. elif issue_status == "issued":
  185. filters.append(BxAdvanceApply.status == "issued")
  186. else:
  187. filters.append(or_(BxAdvanceApply.status == "approved", BxAdvanceApply.status == "issued"))
  188. if keyword:
  189. filters.append(
  190. or_(
  191. BxAdvanceApply.apply_code.contains(keyword),
  192. BxAdvanceApply.applicant_name.contains(keyword),
  193. )
  194. )
  195. if applicantId:
  196. filters.append(BxAdvanceApply.applicant_id == str(applicantId).strip())
  197. bounds = parse_create_time_day_bounds(applyDate)
  198. if bounds:
  199. d0, d1 = bounds
  200. filters.append(BxAdvanceApply.create_time >= d0)
  201. filters.append(BxAdvanceApply.create_time < d1)
  202. rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
  203. out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
  204. if out_rows:
  205. offset_map = AdvanceService.offset_amount_map(db, tenant_no, [r.id for r in rows])
  206. for out_row in out_rows:
  207. out_row.offset_amount = offset_map.get(out_row.id, 0.0)
  208. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  209. @router.get("/advance-issues/stats", response_model=ApiResponse[AdvanceFinanceIssueStatsOut])
  210. def advance_issue_stats(
  211. db: Session = Depends(get_db),
  212. user: BxUserContext = Depends(get_bx_current_user),
  213. ):
  214. tenant_no = _tenant(db, user)
  215. data = AdvanceService.finance_issue_stats(db, tenant_no, user=user)
  216. return ApiResponse(data=AdvanceFinanceIssueStatsOut.model_validate(data))
  217. @router.get("/advance-issues/export")
  218. def export_advance_issues(
  219. issue_status: Optional[str] = None,
  220. keyword: str = "",
  221. applicantId: Optional[str] = None,
  222. applyDate: Optional[str] = None,
  223. db: Session = Depends(get_db),
  224. user: BxUserContext = Depends(get_bx_current_user),
  225. ):
  226. tenant_no = _tenant(db, user)
  227. filters = []
  228. if issue_status == "pending":
  229. filters.append(BxAdvanceApply.status == "approved")
  230. filters.append(or_(BxAdvanceApply.issue_status.is_(None), BxAdvanceApply.issue_status != "issued"))
  231. elif issue_status == "issued":
  232. filters.append(BxAdvanceApply.status == "issued")
  233. else:
  234. filters.append(or_(BxAdvanceApply.status == "approved", BxAdvanceApply.status == "issued"))
  235. if keyword:
  236. filters.append(
  237. or_(
  238. BxAdvanceApply.apply_code.contains(keyword),
  239. BxAdvanceApply.applicant_name.contains(keyword),
  240. )
  241. )
  242. if applicantId:
  243. filters.append(BxAdvanceApply.applicant_id == str(applicantId).strip())
  244. bounds = parse_create_time_day_bounds(applyDate)
  245. if bounds:
  246. d0, d1 = bounds
  247. filters.append(BxAdvanceApply.create_time >= d0)
  248. filters.append(BxAdvanceApply.create_time < d1)
  249. rows, _ = AdvanceService.get_paged(db, tenant_no, page=1, page_size=99999, filters=filters, user=user)
  250. out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
  251. buf = io.StringIO()
  252. writer = csv.writer(buf)
  253. writer.writerow(
  254. [
  255. "申请单号",
  256. "申请人",
  257. "申请原因",
  258. "申请金额",
  259. "审批通过时间",
  260. "发放状态",
  261. "发放时间",
  262. "发放操作人",
  263. "实际发放金额",
  264. "发放备注",
  265. ]
  266. )
  267. for row in out_rows:
  268. writer.writerow(
  269. [
  270. row.apply_code,
  271. row.applicant_name,
  272. row.reason,
  273. row.amount,
  274. row.approve_time or "",
  275. "已发放" if row.issue_status == "issued" else "待发放",
  276. row.issue_time or "",
  277. row.issue_user_name or "",
  278. row.actual_amount or "",
  279. row.issue_remark or "",
  280. ]
  281. )
  282. buf.seek(0)
  283. filename = f"备用金发放列表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
  284. encoded_name = quote(filename)
  285. return StreamingResponse(
  286. iter([buf.getvalue()]),
  287. media_type="text/csv; charset=utf-8",
  288. headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}"},
  289. )
  290. @router.post("/advance/{advance_id}/issue", response_model=ApiResponse[AdvanceIssueOut])
  291. def issue_advance(
  292. advance_id: str,
  293. body: AdvanceIssueRequest,
  294. db: Session = Depends(get_db),
  295. user: BxUserContext = Depends(get_bx_current_user),
  296. ):
  297. tenant_no = _tenant(db, user)
  298. issue_atts = AttachmentService.get_by_biz(db, "advance_issue", advance_id, tenant_no, user=user)
  299. if not issue_atts:
  300. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少上传 1 个发放附件")
  301. advance = AdvanceService.issue(
  302. db, advance_id, user, body.issue_remark or "", body.actual_amount
  303. )
  304. if not advance:
  305. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  306. db.commit()
  307. db.refresh(advance)
  308. out = AdvanceIssueOut.model_validate(advance)
  309. offset_map = AdvanceService.offset_amount_map(db, tenant_no, [advance_id])
  310. out.offset_amount = offset_map.get(advance_id, 0.0)
  311. return ApiResponse(data=out)
  312. @router.get("/advance/{advance_id}/offset-records", response_model=ApiResponse[List[AdvanceOffsetRecordOut]])
  313. def list_advance_offset_records(
  314. advance_id: str,
  315. db: Session = Depends(get_db),
  316. user: BxUserContext = Depends(get_bx_current_user),
  317. ):
  318. tenant_no = _tenant(db, user)
  319. AdvanceService.get_by_id(db, advance_id, tenant_no, user=user)
  320. rows = (
  321. db.query(BxReimburseOffset, BxReimburseApply)
  322. .join(
  323. BxReimburseApply,
  324. BxReimburseApply.id == BxReimburseOffset.reimburse_id,
  325. )
  326. .filter(
  327. BxReimburseOffset.advance_id == advance_id,
  328. BxReimburseOffset.tenant_no == tenant_no,
  329. BxReimburseOffset.del_flag == 0,
  330. BxReimburseApply.del_flag == 0,
  331. BxReimburseApply.tenant_no == tenant_no,
  332. )
  333. .order_by(BxReimburseOffset.create_time.desc(), BxReimburseOffset.id.desc())
  334. .all()
  335. )
  336. records = [
  337. AdvanceOffsetRecordOut(
  338. reimburse_id=reimburse.id,
  339. reimburse_apply_code=reimburse.apply_code,
  340. reimburse_title=reimburse.title,
  341. reimburse_applicant_name=reimburse.applicant_name,
  342. offset_amount=float(offset.offset_amount or 0),
  343. remark=offset.remark,
  344. operator=offset.create_by,
  345. create_time=offset.create_time,
  346. )
  347. for offset, reimburse in rows
  348. ]
  349. return ApiResponse(data=records)
  350. @router.get("/{payment_id}/offset-records", response_model=ApiResponse[List[ReimburseOffsetRecordOut]])
  351. def list_reimburse_offset_records(
  352. payment_id: str,
  353. db: Session = Depends(get_db),
  354. user: BxUserContext = Depends(get_bx_current_user),
  355. ):
  356. tenant_no = _tenant(db, user)
  357. ReimburseService.get_by_id(db, payment_id, tenant_no, user=user)
  358. rows = (
  359. db.query(BxReimburseOffset, BxAdvanceApply)
  360. .join(BxAdvanceApply, BxAdvanceApply.id == BxReimburseOffset.advance_id)
  361. .filter(
  362. BxReimburseOffset.reimburse_id == payment_id,
  363. BxReimburseOffset.tenant_no == tenant_no,
  364. BxReimburseOffset.del_flag == 0,
  365. BxAdvanceApply.del_flag == 0,
  366. BxAdvanceApply.tenant_no == tenant_no,
  367. )
  368. .order_by(BxReimburseOffset.create_time.desc(), BxReimburseOffset.id.desc())
  369. .all()
  370. )
  371. records = [
  372. ReimburseOffsetRecordOut(
  373. advance_id=advance.id,
  374. advance_apply_code=advance.apply_code,
  375. advance_applicant_name=advance.applicant_name,
  376. advance_reason=advance.reason,
  377. offset_amount=float(offset.offset_amount or 0),
  378. remark=offset.remark,
  379. operator=offset.create_by,
  380. create_time=offset.create_time,
  381. )
  382. for offset, advance in rows
  383. ]
  384. return ApiResponse(data=records)