payment.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  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, Request, status, Query
  7. from pydantic import Field
  8. from fastapi.responses import StreamingResponse
  9. from sqlalchemy import or_
  10. from sqlalchemy.orm import Session
  11. from app.api.bx.export_common import (
  12. EXPORT_MAX_ROWS,
  13. ExportColumn,
  14. ExportValidationError,
  15. BxApplyListExportBody,
  16. dump_export_row,
  17. issue_status_label,
  18. respond_xlsx,
  19. )
  20. from app.core.bx_auth import BxUserContext, get_bx_current_user
  21. from app.core.database import get_db
  22. from app.core.tenant_context import get_tenant_no
  23. from app.models.bx_models import BxAdvanceApply, BxReimburseApply, BxReimburseOffset
  24. from app.schemas.bx.advance import (
  25. AdvanceFinanceIssueStatsOut,
  26. AdvanceIssueOut,
  27. AdvanceIssueRequest,
  28. AdvanceOffsetRecordOut,
  29. )
  30. from app.schemas.bx.common import ApiResponse, PagedData
  31. from app.schemas.bx.reimburse import (
  32. BatchOffsetRequest,
  33. OffsetRequest,
  34. PayRequest,
  35. PaymentFinanceStatsOut,
  36. ReimburseApplyOut,
  37. ReimburseOffsetRecordOut,
  38. )
  39. from app.services.bx.advance import AdvanceService
  40. from app.services.bx.advance_list_filters import build_advance_column_filters
  41. from app.services.bx.attachment import AttachmentService
  42. from app.services.bx.list_sort import order_advance, order_reimburse, parse_sort_params
  43. from app.services.bx.payment_list_filters import (
  44. payment_filters_from_params as _payment_filters_from_params,
  45. )
  46. from app.services.bx.reimburse import ReimburseService
  47. router = APIRouter(prefix="/payments", tags=["支付处理"])
  48. def _tenant(db: Session, user: BxUserContext) -> str:
  49. return get_tenant_no(db, user.to_tenant_user_dict())
  50. def _wrap_reimburse_rows(outs: List[ReimburseApplyOut]) -> List[dict]:
  51. return [{"type": "reimburse", "data": o.model_dump(by_alias=True)} for o in outs]
  52. def _merge_query(request: Request, **known) -> dict:
  53. out = {k: v for k, v in known.items() if v is not None and v != ""}
  54. for k, v in request.query_params.multi_items():
  55. if k in ("page", "pageSize"):
  56. continue
  57. if k not in out and v not in (None, ""):
  58. out[k] = v
  59. return out
  60. def _param_get(params: dict, *keys: str) -> Any:
  61. for key in keys:
  62. if key in params and params[key] not in (None, ""):
  63. return params[key]
  64. return None
  65. def _advance_filters_from_params(params: dict, *, tenant_no: str = "") -> list:
  66. ids = _param_get(params, "ids")
  67. if isinstance(ids, str):
  68. ids = [s.strip() for s in ids.split(",") if s.strip()]
  69. return build_advance_column_filters(
  70. tenant_no=tenant_no,
  71. keyword=str(_param_get(params, "keyword") or ""),
  72. status=_param_get(params, "status"),
  73. status_in=_param_get(params, "statusIn", "status_in"),
  74. apply_code=_param_get(params, "applyCode", "apply_code"),
  75. apply_code_in=_param_get(params, "applyCodeIn", "apply_code_in"),
  76. reason=_param_get(params, "reason"),
  77. reason_in=_param_get(params, "reasonIn", "reason_in"),
  78. applicant_id=_param_get(params, "applicantId", "applicant_id"),
  79. applicant_name=_param_get(params, "applicantName", "applicant_name"),
  80. applicant_name_in=_param_get(params, "applicantNameIn", "applicant_name_in"),
  81. current_node=_param_get(params, "currentNode", "current_node"),
  82. current_node_in=_param_get(params, "currentNodeIn", "current_node_in"),
  83. issue_by=_param_get(params, "issueBy", "issue_by"),
  84. issue_user_id=_param_get(params, "issueUserId", "issue_user_id"),
  85. issue_user_name=_param_get(params, "issueUserName", "issue_user_name"),
  86. issue_user_name_in=_param_get(params, "issueUserNameIn", "issue_user_name_in"),
  87. amount_op=_param_get(params, "amountOp", "amount_op"),
  88. amount=_param_get(params, "amount"),
  89. amount_min=_param_get(params, "amountMin", "amount_min"),
  90. amount_max=_param_get(params, "amountMax", "amount_max"),
  91. actual_amount_op=_param_get(params, "actualAmountOp", "actual_amount_op"),
  92. actual_amount=_param_get(params, "actualAmount", "actual_amount"),
  93. actual_amount_min=_param_get(params, "actualAmountMin", "actual_amount_min"),
  94. actual_amount_max=_param_get(params, "actualAmountMax", "actual_amount_max"),
  95. offset_amount_op=_param_get(params, "offsetAmountOp", "offset_amount_op"),
  96. offset_amount=_param_get(params, "offsetAmount", "offset_amount"),
  97. offset_amount_min=_param_get(params, "offsetAmountMin", "offset_amount_min"),
  98. offset_amount_max=_param_get(params, "offsetAmountMax", "offset_amount_max"),
  99. apply_date=_param_get(params, "applyDate", "apply_date"),
  100. apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
  101. apply_date_to=_param_get(params, "applyDateTo", "apply_date_to"),
  102. issue_time_from=_param_get(params, "issueTimeFrom", "issue_time_from"),
  103. issue_time_to=_param_get(params, "issueTimeTo", "issue_time_to"),
  104. ids=ids if isinstance(ids, list) else None,
  105. )
  106. def _body_to_params(body: BxApplyListExportBody) -> dict:
  107. data = body.model_dump(by_alias=True, exclude_none=True)
  108. extra = getattr(body, "model_extra", None) or {}
  109. for key, value in extra.items():
  110. if key not in data and value not in (None, ""):
  111. data[key] = value
  112. return data
  113. def _strip_tab_status_params(params: dict) -> None:
  114. """Tab 已固定 status 语义时,去掉冲突的 status 列参。"""
  115. params.pop("status", None)
  116. params.pop("statusIn", None)
  117. params.pop("status_in", None)
  118. def _advance_issue_tab_filters(issue_status: Optional[str]) -> list:
  119. filters = []
  120. if issue_status == "pending":
  121. filters.append(BxAdvanceApply.status == "approved")
  122. filters.append(or_(BxAdvanceApply.issue_status.is_(None), BxAdvanceApply.issue_status != "issued"))
  123. elif issue_status == "issued":
  124. filters.append(BxAdvanceApply.status == "issued")
  125. else:
  126. filters.append(or_(BxAdvanceApply.status == "approved", BxAdvanceApply.status == "issued"))
  127. return filters
  128. @router.get("/pending", response_model=ApiResponse[PagedData[Any]])
  129. def list_pending_payments(
  130. request: Request,
  131. page: int = 1,
  132. pageSize: int = Query(10, ge=1, le=500),
  133. keyword: str = "",
  134. applicantId: Optional[str] = None,
  135. applyDate: Optional[str] = None,
  136. applyDateFrom: Optional[str] = None,
  137. applyDateTo: Optional[str] = None,
  138. db: Session = Depends(get_db),
  139. user: BxUserContext = Depends(get_bx_current_user),
  140. ):
  141. tenant_no = _tenant(db, user)
  142. filters = [BxReimburseApply.status == "approved"]
  143. params = _merge_query(
  144. request,
  145. keyword=keyword or None,
  146. applicantId=applicantId,
  147. applyDate=applyDate,
  148. applyDateFrom=applyDateFrom,
  149. applyDateTo=applyDateTo,
  150. )
  151. # Tab 语义固定 approved;不把 status 列参传入 builder
  152. _strip_tab_status_params(params)
  153. filters.extend(_payment_filters_from_params(params, db=db, tenant_no=tenant_no))
  154. sf, so = parse_sort_params(params)
  155. rows, total = ReimburseService.get_paged(
  156. db,
  157. tenant_no,
  158. page=page,
  159. page_size=pageSize,
  160. filters=filters,
  161. user=user,
  162. order_by=order_reimburse(sf, so),
  163. )
  164. outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  165. return ApiResponse(
  166. data=PagedData(rows=_wrap_reimburse_rows(outs), total=total, page=page, page_size=pageSize)
  167. )
  168. @router.get("/stats", response_model=ApiResponse[PaymentFinanceStatsOut])
  169. def payment_finance_stats(
  170. db: Session = Depends(get_db),
  171. user: BxUserContext = Depends(get_bx_current_user),
  172. ):
  173. tenant_no = _tenant(db, user)
  174. data = ReimburseService.finance_payment_stats(db, tenant_no, user=user)
  175. return ApiResponse(data=PaymentFinanceStatsOut.model_validate(data))
  176. @router.get("/processed", response_model=ApiResponse[PagedData[Any]])
  177. def list_processed_payments(
  178. request: Request,
  179. page: int = 1,
  180. pageSize: int = Query(10, ge=1, le=500),
  181. keyword: str = "",
  182. applicantId: Optional[str] = None,
  183. applyDate: Optional[str] = None,
  184. applyDateFrom: Optional[str] = None,
  185. applyDateTo: Optional[str] = None,
  186. db: Session = Depends(get_db),
  187. user: BxUserContext = Depends(get_bx_current_user),
  188. ):
  189. tenant_no = _tenant(db, user)
  190. filters = [BxReimburseApply.status.in_(["processed", "paid", "offset"])]
  191. params = _merge_query(
  192. request,
  193. keyword=keyword or None,
  194. applicantId=applicantId,
  195. applyDate=applyDate,
  196. applyDateFrom=applyDateFrom,
  197. applyDateTo=applyDateTo,
  198. )
  199. filters.extend(_payment_filters_from_params(params, db=db, tenant_no=tenant_no))
  200. sf, so = parse_sort_params(params)
  201. rows, total = ReimburseService.get_paged(
  202. db,
  203. tenant_no,
  204. page=page,
  205. page_size=pageSize,
  206. filters=filters,
  207. user=user,
  208. order_by=order_reimburse(sf, so),
  209. )
  210. outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  211. return ApiResponse(
  212. data=PagedData(rows=_wrap_reimburse_rows(outs), total=total, page=page, page_size=pageSize)
  213. )
  214. @router.post("/{payment_id}/pay", response_model=ApiResponse[ReimburseApplyOut])
  215. def pay(
  216. payment_id: str,
  217. body: Optional[PayRequest] = None,
  218. db: Session = Depends(get_db),
  219. user: BxUserContext = Depends(get_bx_current_user),
  220. ):
  221. tenant_no = _tenant(db, user)
  222. pay_remark = body.pay_remark if body else ""
  223. actual_amount = body.actual_amount if body else None
  224. reimburse = ReimburseService.pay(
  225. db, payment_id, user, pay_remark=pay_remark or "", actual_amount=actual_amount
  226. )
  227. if not reimburse:
  228. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  229. db.commit()
  230. db.refresh(reimburse)
  231. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
  232. @router.post("/{payment_id}/offset", response_model=ApiResponse[ReimburseApplyOut])
  233. def offset(
  234. payment_id: str,
  235. body: OffsetRequest,
  236. db: Session = Depends(get_db),
  237. user: BxUserContext = Depends(get_bx_current_user),
  238. ):
  239. tenant_no = _tenant(db, user)
  240. reimburse = ReimburseService.offset(
  241. db,
  242. payment_id,
  243. body.advance_id,
  244. body.offset_amount,
  245. user,
  246. remark=body.remark or "",
  247. )
  248. if not reimburse:
  249. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  250. db.commit()
  251. db.refresh(reimburse)
  252. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
  253. @router.post("/{payment_id}/batch-offset", response_model=ApiResponse[ReimburseApplyOut])
  254. def batch_offset(
  255. payment_id: str,
  256. body: BatchOffsetRequest,
  257. db: Session = Depends(get_db),
  258. user: BxUserContext = Depends(get_bx_current_user),
  259. ):
  260. tenant_no = _tenant(db, user)
  261. reimburse = ReimburseService.batch_offset(
  262. db,
  263. payment_id,
  264. [item.model_dump() for item in body.items],
  265. body.pay_amount,
  266. user,
  267. remark=body.remark or "",
  268. )
  269. if not reimburse:
  270. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
  271. db.commit()
  272. db.refresh(reimburse)
  273. return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
  274. @router.get("/advance-issues", response_model=ApiResponse[PagedData[AdvanceIssueOut]])
  275. def list_advance_issues(
  276. request: Request,
  277. page: int = 1,
  278. pageSize: int = Query(10, ge=1, le=500),
  279. issue_status: Optional[str] = None,
  280. keyword: str = "",
  281. applicantId: Optional[str] = None,
  282. applyDate: Optional[str] = None,
  283. applyDateFrom: Optional[str] = None,
  284. applyDateTo: Optional[str] = None,
  285. db: Session = Depends(get_db),
  286. user: BxUserContext = Depends(get_bx_current_user),
  287. ):
  288. tenant_no = _tenant(db, user)
  289. filters = _advance_issue_tab_filters(issue_status)
  290. params = _merge_query(
  291. request,
  292. keyword=keyword or None,
  293. applicantId=applicantId,
  294. applyDate=applyDate,
  295. applyDateFrom=applyDateFrom,
  296. applyDateTo=applyDateTo,
  297. )
  298. # Tab 语义固定 status;不把 status 列参传入 builder
  299. _strip_tab_status_params(params)
  300. filters.extend(_advance_filters_from_params(params, tenant_no=tenant_no))
  301. sf, so = parse_sort_params(params)
  302. rows, total = AdvanceService.get_paged(
  303. db,
  304. tenant_no,
  305. page=page,
  306. page_size=pageSize,
  307. filters=filters,
  308. user=user,
  309. order_by=order_advance(sf, so),
  310. )
  311. out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
  312. if out_rows:
  313. offset_map = AdvanceService.offset_amount_map(db, tenant_no, [r.id for r in rows])
  314. for out_row in out_rows:
  315. out_row.offset_amount = offset_map.get(out_row.id, 0.0)
  316. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  317. @router.get("/advance-issues/stats", response_model=ApiResponse[AdvanceFinanceIssueStatsOut])
  318. def advance_issue_stats(
  319. db: Session = Depends(get_db),
  320. user: BxUserContext = Depends(get_bx_current_user),
  321. ):
  322. tenant_no = _tenant(db, user)
  323. data = AdvanceService.finance_issue_stats(db, tenant_no, user=user)
  324. return ApiResponse(data=AdvanceFinanceIssueStatsOut.model_validate(data))
  325. PAYMENT_EXPORT_COLUMNS = [
  326. ExportColumn(title="申请单号", field="applyCode"),
  327. ExportColumn(title="申请时间", field="applyTime"),
  328. ExportColumn(title="申请人", field="applicantName"),
  329. ExportColumn(title="报销主题", field="title"),
  330. ExportColumn(title="金额(元)", field="amount"),
  331. ExportColumn(title="冲抵金额(元)", field="offsetAmount"),
  332. ExportColumn(title="实付金额(元)", field="payAmount"),
  333. ExportColumn(title="处理状态", field="status"),
  334. ExportColumn(title="支付时间", field="payTime"),
  335. ]
  336. ADVANCE_ISSUE_EXPORT_COLUMNS = [
  337. ExportColumn(title="申请单号", field="applyCode"),
  338. ExportColumn(title="申请时间", field="applyTime"),
  339. ExportColumn(title="申请人", field="applicantName"),
  340. ExportColumn(title="申请原因", field="reason"),
  341. ExportColumn(title="申请金额(元)", field="amount"),
  342. ExportColumn(title="实际发放金额(元)", field="actualAmount"),
  343. ExportColumn(title="冲抵总金额(元)", field="offsetAmount"),
  344. ExportColumn(title="审批通过时间", field="approveTime"),
  345. ExportColumn(title="发放时间", field="issueTime"),
  346. ExportColumn(title="发放人", field="issueUserName"),
  347. ExportColumn(title="发放状态", field="issueStatus"),
  348. ]
  349. class PaymentExportBody(BxApplyListExportBody):
  350. issue_status: Optional[str] = Field(default=None, alias="issueStatus")
  351. @router.post("/pending/export")
  352. def export_pending_payments(
  353. body: BxApplyListExportBody,
  354. db: Session = Depends(get_db),
  355. user: BxUserContext = Depends(get_bx_current_user),
  356. ):
  357. try:
  358. tenant_no = _tenant(db, user)
  359. filters = [BxReimburseApply.status == "approved"]
  360. params = _body_to_params(body)
  361. _strip_tab_status_params(params)
  362. filters.extend(_payment_filters_from_params(params, db=db, tenant_no=tenant_no))
  363. sf, so = parse_sort_params(params)
  364. rows, _ = ReimburseService.get_paged(
  365. db,
  366. tenant_no,
  367. page=1,
  368. page_size=EXPORT_MAX_ROWS,
  369. filters=filters,
  370. user=user,
  371. order_by=order_reimburse(sf, so),
  372. )
  373. outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  374. export_rows = [dump_export_row(o) for o in outs]
  375. return respond_xlsx(
  376. db, tenant_no=tenant_no, sheet_title="待付款", columns=PAYMENT_EXPORT_COLUMNS,
  377. rows=export_rows, filename_stem="报销待付款", requested_columns=body.columns,
  378. )
  379. except ExportValidationError as e:
  380. raise HTTPException(status_code=400, detail=str(e)) from e
  381. @router.post("/processed/export")
  382. def export_processed_payments(
  383. body: BxApplyListExportBody,
  384. db: Session = Depends(get_db),
  385. user: BxUserContext = Depends(get_bx_current_user),
  386. ):
  387. try:
  388. tenant_no = _tenant(db, user)
  389. filters = [BxReimburseApply.status.in_(["processed", "paid", "offset"])]
  390. params = _body_to_params(body)
  391. filters.extend(_payment_filters_from_params(params, db=db, tenant_no=tenant_no))
  392. sf, so = parse_sort_params(params)
  393. rows, _ = ReimburseService.get_paged(
  394. db,
  395. tenant_no,
  396. page=1,
  397. page_size=EXPORT_MAX_ROWS,
  398. filters=filters,
  399. user=user,
  400. order_by=order_reimburse(sf, so),
  401. )
  402. outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
  403. export_rows = [dump_export_row(o) for o in outs]
  404. return respond_xlsx(
  405. db, tenant_no=tenant_no, sheet_title="已付款", columns=PAYMENT_EXPORT_COLUMNS,
  406. rows=export_rows, filename_stem="报销已付款", requested_columns=body.columns,
  407. )
  408. except ExportValidationError as e:
  409. raise HTTPException(status_code=400, detail=str(e)) from e
  410. @router.post("/advance-issues/export-xlsx")
  411. def export_advance_issues_xlsx(
  412. body: PaymentExportBody,
  413. db: Session = Depends(get_db),
  414. user: BxUserContext = Depends(get_bx_current_user),
  415. ):
  416. try:
  417. tenant_no = _tenant(db, user)
  418. params = _body_to_params(body)
  419. issue_status = body.issue_status or _param_get(params, "issueStatus", "issue_status")
  420. filters = _advance_issue_tab_filters(issue_status)
  421. _strip_tab_status_params(params)
  422. filters.extend(_advance_filters_from_params(params, tenant_no=tenant_no))
  423. sf, so = parse_sort_params(params)
  424. rows, _ = AdvanceService.get_paged(
  425. db,
  426. tenant_no,
  427. page=1,
  428. page_size=EXPORT_MAX_ROWS,
  429. filters=filters,
  430. user=user,
  431. order_by=order_advance(sf, so),
  432. )
  433. out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
  434. export_rows = []
  435. for o in out_rows:
  436. data = dump_export_row(o, status_fields=())
  437. data["issueStatus"] = issue_status_label(getattr(o, "issue_status", None) or data.get("issueStatus"))
  438. export_rows.append(data)
  439. return respond_xlsx(
  440. db, tenant_no=tenant_no, sheet_title="备用金发放", columns=ADVANCE_ISSUE_EXPORT_COLUMNS,
  441. rows=export_rows, filename_stem="备用金发放", requested_columns=body.columns,
  442. )
  443. except ExportValidationError as e:
  444. raise HTTPException(status_code=400, detail=str(e)) from e
  445. @router.get("/advance-issues/export")
  446. def export_advance_issues(
  447. request: Request,
  448. issue_status: Optional[str] = None,
  449. keyword: str = "",
  450. applicantId: Optional[str] = None,
  451. applyDate: Optional[str] = None,
  452. applyDateFrom: Optional[str] = None,
  453. applyDateTo: Optional[str] = None,
  454. db: Session = Depends(get_db),
  455. user: BxUserContext = Depends(get_bx_current_user),
  456. ):
  457. tenant_no = _tenant(db, user)
  458. filters = _advance_issue_tab_filters(issue_status)
  459. params = _merge_query(
  460. request,
  461. keyword=keyword or None,
  462. applicantId=applicantId,
  463. applyDate=applyDate,
  464. applyDateFrom=applyDateFrom,
  465. applyDateTo=applyDateTo,
  466. )
  467. _strip_tab_status_params(params)
  468. filters.extend(_advance_filters_from_params(params, tenant_no=tenant_no))
  469. rows, _ = AdvanceService.get_paged(db, tenant_no, page=1, page_size=99999, filters=filters, user=user)
  470. out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
  471. buf = io.StringIO()
  472. writer = csv.writer(buf)
  473. writer.writerow(
  474. [
  475. "申请单号",
  476. "申请人",
  477. "申请原因",
  478. "申请金额",
  479. "审批通过时间",
  480. "发放状态",
  481. "发放时间",
  482. "发放操作人",
  483. "实际发放金额",
  484. "发放备注",
  485. ]
  486. )
  487. for row in out_rows:
  488. writer.writerow(
  489. [
  490. row.apply_code,
  491. row.applicant_name,
  492. row.reason,
  493. row.amount,
  494. row.approve_time or "",
  495. "已发放" if row.issue_status == "issued" else "待发放",
  496. row.issue_time or "",
  497. row.issue_user_name or "",
  498. row.actual_amount or "",
  499. row.issue_remark or "",
  500. ]
  501. )
  502. buf.seek(0)
  503. filename = f"备用金发放列表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
  504. encoded_name = quote(filename)
  505. return StreamingResponse(
  506. iter([buf.getvalue()]),
  507. media_type="text/csv; charset=utf-8",
  508. headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}"},
  509. )
  510. @router.post("/advance/{advance_id}/issue", response_model=ApiResponse[AdvanceIssueOut])
  511. def issue_advance(
  512. advance_id: str,
  513. body: AdvanceIssueRequest,
  514. db: Session = Depends(get_db),
  515. user: BxUserContext = Depends(get_bx_current_user),
  516. ):
  517. tenant_no = _tenant(db, user)
  518. issue_atts = AttachmentService.get_by_biz(db, "advance_issue", advance_id, tenant_no, user=user)
  519. if not issue_atts:
  520. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少上传 1 个发放附件")
  521. advance = AdvanceService.issue(
  522. db, advance_id, user, body.issue_remark or "", body.actual_amount
  523. )
  524. if not advance:
  525. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
  526. db.commit()
  527. db.refresh(advance)
  528. out = AdvanceIssueOut.model_validate(advance)
  529. offset_map = AdvanceService.offset_amount_map(db, tenant_no, [advance_id])
  530. out.offset_amount = offset_map.get(advance_id, 0.0)
  531. return ApiResponse(data=out)
  532. @router.get("/advance/{advance_id}/offset-records", response_model=ApiResponse[List[AdvanceOffsetRecordOut]])
  533. def list_advance_offset_records(
  534. advance_id: str,
  535. db: Session = Depends(get_db),
  536. user: BxUserContext = Depends(get_bx_current_user),
  537. ):
  538. tenant_no = _tenant(db, user)
  539. AdvanceService.get_by_id(db, advance_id, tenant_no, user=user)
  540. rows = (
  541. db.query(BxReimburseOffset, BxReimburseApply)
  542. .join(
  543. BxReimburseApply,
  544. BxReimburseApply.id == BxReimburseOffset.reimburse_id,
  545. )
  546. .filter(
  547. BxReimburseOffset.advance_id == advance_id,
  548. BxReimburseOffset.tenant_no == tenant_no,
  549. BxReimburseOffset.del_flag == 0,
  550. BxReimburseApply.del_flag == 0,
  551. BxReimburseApply.tenant_no == tenant_no,
  552. )
  553. .order_by(BxReimburseOffset.create_time.desc(), BxReimburseOffset.id.desc())
  554. .all()
  555. )
  556. records = [
  557. AdvanceOffsetRecordOut(
  558. reimburse_id=reimburse.id,
  559. reimburse_apply_code=reimburse.apply_code,
  560. reimburse_title=reimburse.title,
  561. reimburse_applicant_name=reimburse.applicant_name,
  562. offset_amount=float(offset.offset_amount or 0),
  563. remark=offset.remark,
  564. operator=offset.create_by,
  565. create_time=offset.create_time,
  566. )
  567. for offset, reimburse in rows
  568. ]
  569. return ApiResponse(data=records)
  570. @router.get("/{payment_id}/offset-records", response_model=ApiResponse[List[ReimburseOffsetRecordOut]])
  571. def list_reimburse_offset_records(
  572. payment_id: str,
  573. db: Session = Depends(get_db),
  574. user: BxUserContext = Depends(get_bx_current_user),
  575. ):
  576. tenant_no = _tenant(db, user)
  577. ReimburseService.get_by_id(db, payment_id, tenant_no, user=user)
  578. rows = (
  579. db.query(BxReimburseOffset, BxAdvanceApply)
  580. .join(BxAdvanceApply, BxAdvanceApply.id == BxReimburseOffset.advance_id)
  581. .filter(
  582. BxReimburseOffset.reimburse_id == payment_id,
  583. BxReimburseOffset.tenant_no == tenant_no,
  584. BxReimburseOffset.del_flag == 0,
  585. BxAdvanceApply.del_flag == 0,
  586. BxAdvanceApply.tenant_no == tenant_no,
  587. )
  588. .order_by(BxReimburseOffset.create_time.desc(), BxReimburseOffset.id.desc())
  589. .all()
  590. )
  591. records = [
  592. ReimburseOffsetRecordOut(
  593. advance_id=advance.id,
  594. advance_apply_code=advance.apply_code,
  595. advance_applicant_name=advance.applicant_name,
  596. advance_reason=advance.reason,
  597. offset_amount=float(offset.offset_amount or 0),
  598. remark=offset.remark,
  599. operator=offset.create_by,
  600. create_time=offset.create_time,
  601. )
  602. for offset, advance in rows
  603. ]
  604. return ApiResponse(data=records)