| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 |
- import csv
- import io
- from datetime import datetime
- from typing import Any, List, Optional
- from urllib.parse import quote
- from fastapi import APIRouter, Depends, HTTPException, status
- from fastapi.responses import StreamingResponse
- from sqlalchemy import or_
- from sqlalchemy.orm import Session
- from app.core.bx_auth import BxUserContext, get_bx_current_user
- from app.core.database import get_db
- from app.core.tenant_context import get_tenant_no
- from app.models.bx_models import BxAdvanceApply, BxReimburseApply, BxReimburseOffset
- from app.schemas.bx.advance import (
- AdvanceFinanceIssueStatsOut,
- AdvanceIssueOut,
- AdvanceIssueRequest,
- AdvanceOffsetRecordOut,
- )
- from app.schemas.bx.common import ApiResponse, PagedData
- from app.schemas.bx.reimburse import (
- BatchOffsetRequest,
- OffsetRequest,
- PayRequest,
- PaymentFinanceStatsOut,
- ReimburseApplyOut,
- ReimburseOffsetRecordOut,
- )
- from app.services.bx.advance import AdvanceService
- from app.services.bx.attachment import AttachmentService
- from app.services.bx.reimburse import ReimburseService
- from app.utils.bx_date_range import parse_create_time_day_bounds
- router = APIRouter(prefix="/payments", tags=["支付处理"])
- def _tenant(db: Session, user: BxUserContext) -> str:
- return get_tenant_no(db, user.to_tenant_user_dict())
- def _wrap_reimburse_rows(outs: List[ReimburseApplyOut]) -> List[dict]:
- return [{"type": "reimburse", "data": o.model_dump(by_alias=True)} for o in outs]
- @router.get("/pending", response_model=ApiResponse[PagedData[Any]])
- def list_pending_payments(
- page: int = 1,
- pageSize: int = 10,
- keyword: str = "",
- applicantId: Optional[str] = None,
- applyDate: Optional[str] = None,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- filters = [BxReimburseApply.status == "approved"]
- if keyword:
- filters.append(
- or_(
- BxReimburseApply.apply_code.contains(keyword),
- BxReimburseApply.applicant_name.contains(keyword),
- )
- )
- if applicantId:
- filters.append(BxReimburseApply.applicant_id == str(applicantId).strip())
- bounds = parse_create_time_day_bounds(applyDate)
- if bounds:
- d0, d1 = bounds
- filters.append(BxReimburseApply.create_time >= d0)
- filters.append(BxReimburseApply.create_time < d1)
- rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
- outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
- return ApiResponse(
- data=PagedData(rows=_wrap_reimburse_rows(outs), total=total, page=page, page_size=pageSize)
- )
- @router.get("/stats", response_model=ApiResponse[PaymentFinanceStatsOut])
- def payment_finance_stats(
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- data = ReimburseService.finance_payment_stats(db, tenant_no, user=user)
- return ApiResponse(data=PaymentFinanceStatsOut.model_validate(data))
- @router.get("/processed", response_model=ApiResponse[PagedData[Any]])
- def list_processed_payments(
- page: int = 1,
- pageSize: int = 10,
- keyword: str = "",
- applicantId: Optional[str] = None,
- applyDate: Optional[str] = None,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- filters = [BxReimburseApply.status.in_(["processed", "paid", "offset"])]
- if keyword:
- filters.append(
- or_(
- BxReimburseApply.apply_code.contains(keyword),
- BxReimburseApply.applicant_name.contains(keyword),
- )
- )
- if applicantId:
- filters.append(BxReimburseApply.applicant_id == str(applicantId).strip())
- bounds = parse_create_time_day_bounds(applyDate)
- if bounds:
- d0, d1 = bounds
- filters.append(BxReimburseApply.create_time >= d0)
- filters.append(BxReimburseApply.create_time < d1)
- rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
- outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
- return ApiResponse(
- data=PagedData(rows=_wrap_reimburse_rows(outs), total=total, page=page, page_size=pageSize)
- )
- @router.post("/{payment_id}/pay", response_model=ApiResponse[ReimburseApplyOut])
- def pay(
- payment_id: str,
- body: Optional[PayRequest] = None,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- pay_remark = body.pay_remark if body else ""
- actual_amount = body.actual_amount if body else None
- reimburse = ReimburseService.pay(
- db, payment_id, user, pay_remark=pay_remark or "", actual_amount=actual_amount
- )
- if not reimburse:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
- db.commit()
- db.refresh(reimburse)
- return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
- @router.post("/{payment_id}/offset", response_model=ApiResponse[ReimburseApplyOut])
- def offset(
- payment_id: str,
- body: OffsetRequest,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- reimburse = ReimburseService.offset(
- db,
- payment_id,
- body.advance_id,
- body.offset_amount,
- user,
- remark=body.remark or "",
- )
- if not reimburse:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
- db.commit()
- db.refresh(reimburse)
- return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
- @router.post("/{payment_id}/batch-offset", response_model=ApiResponse[ReimburseApplyOut])
- def batch_offset(
- payment_id: str,
- body: BatchOffsetRequest,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- reimburse = ReimburseService.batch_offset(
- db,
- payment_id,
- [item.model_dump() for item in body.items],
- body.pay_amount,
- user,
- remark=body.remark or "",
- )
- if not reimburse:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
- db.commit()
- db.refresh(reimburse)
- return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
- @router.get("/advance-issues", response_model=ApiResponse[PagedData[AdvanceIssueOut]])
- def list_advance_issues(
- page: int = 1,
- pageSize: int = 10,
- issue_status: Optional[str] = None,
- keyword: str = "",
- applicantId: Optional[str] = None,
- applyDate: Optional[str] = None,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- filters = []
- if issue_status == "pending":
- filters.append(BxAdvanceApply.status == "approved")
- filters.append(or_(BxAdvanceApply.issue_status.is_(None), BxAdvanceApply.issue_status != "issued"))
- elif issue_status == "issued":
- filters.append(BxAdvanceApply.status == "issued")
- else:
- filters.append(or_(BxAdvanceApply.status == "approved", BxAdvanceApply.status == "issued"))
- if keyword:
- filters.append(
- or_(
- BxAdvanceApply.apply_code.contains(keyword),
- BxAdvanceApply.applicant_name.contains(keyword),
- )
- )
- if applicantId:
- filters.append(BxAdvanceApply.applicant_id == str(applicantId).strip())
- bounds = parse_create_time_day_bounds(applyDate)
- if bounds:
- d0, d1 = bounds
- filters.append(BxAdvanceApply.create_time >= d0)
- filters.append(BxAdvanceApply.create_time < d1)
- rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
- out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
- if out_rows:
- offset_map = AdvanceService.offset_amount_map(db, tenant_no, [r.id for r in rows])
- for out_row in out_rows:
- out_row.offset_amount = offset_map.get(out_row.id, 0.0)
- return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
- @router.get("/advance-issues/stats", response_model=ApiResponse[AdvanceFinanceIssueStatsOut])
- def advance_issue_stats(
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- data = AdvanceService.finance_issue_stats(db, tenant_no, user=user)
- return ApiResponse(data=AdvanceFinanceIssueStatsOut.model_validate(data))
- @router.get("/advance-issues/export")
- def export_advance_issues(
- issue_status: Optional[str] = None,
- keyword: str = "",
- applicantId: Optional[str] = None,
- applyDate: Optional[str] = None,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- filters = []
- if issue_status == "pending":
- filters.append(BxAdvanceApply.status == "approved")
- filters.append(or_(BxAdvanceApply.issue_status.is_(None), BxAdvanceApply.issue_status != "issued"))
- elif issue_status == "issued":
- filters.append(BxAdvanceApply.status == "issued")
- else:
- filters.append(or_(BxAdvanceApply.status == "approved", BxAdvanceApply.status == "issued"))
- if keyword:
- filters.append(
- or_(
- BxAdvanceApply.apply_code.contains(keyword),
- BxAdvanceApply.applicant_name.contains(keyword),
- )
- )
- if applicantId:
- filters.append(BxAdvanceApply.applicant_id == str(applicantId).strip())
- bounds = parse_create_time_day_bounds(applyDate)
- if bounds:
- d0, d1 = bounds
- filters.append(BxAdvanceApply.create_time >= d0)
- filters.append(BxAdvanceApply.create_time < d1)
- rows, _ = AdvanceService.get_paged(db, tenant_no, page=1, page_size=99999, filters=filters, user=user)
- out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
- buf = io.StringIO()
- writer = csv.writer(buf)
- writer.writerow(
- [
- "申请单号",
- "申请人",
- "申请原因",
- "申请金额",
- "审批通过时间",
- "发放状态",
- "发放时间",
- "发放操作人",
- "实际发放金额",
- "发放备注",
- ]
- )
- for row in out_rows:
- writer.writerow(
- [
- row.apply_code,
- row.applicant_name,
- row.reason,
- row.amount,
- row.approve_time or "",
- "已发放" if row.issue_status == "issued" else "待发放",
- row.issue_time or "",
- row.issue_user_name or "",
- row.actual_amount or "",
- row.issue_remark or "",
- ]
- )
- buf.seek(0)
- filename = f"备用金发放列表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
- encoded_name = quote(filename)
- return StreamingResponse(
- iter([buf.getvalue()]),
- media_type="text/csv; charset=utf-8",
- headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}"},
- )
- @router.post("/advance/{advance_id}/issue", response_model=ApiResponse[AdvanceIssueOut])
- def issue_advance(
- advance_id: str,
- body: AdvanceIssueRequest,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- issue_atts = AttachmentService.get_by_biz(db, "advance_issue", advance_id, tenant_no, user=user)
- if not issue_atts:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少上传 1 个发放附件")
- advance = AdvanceService.issue(
- db, advance_id, user, body.issue_remark or "", body.actual_amount
- )
- if not advance:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
- db.commit()
- db.refresh(advance)
- out = AdvanceIssueOut.model_validate(advance)
- offset_map = AdvanceService.offset_amount_map(db, tenant_no, [advance_id])
- out.offset_amount = offset_map.get(advance_id, 0.0)
- return ApiResponse(data=out)
- @router.get("/advance/{advance_id}/offset-records", response_model=ApiResponse[List[AdvanceOffsetRecordOut]])
- def list_advance_offset_records(
- advance_id: str,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- AdvanceService.get_by_id(db, advance_id, tenant_no, user=user)
- rows = (
- db.query(BxReimburseOffset, BxReimburseApply)
- .join(
- BxReimburseApply,
- BxReimburseApply.id == BxReimburseOffset.reimburse_id,
- )
- .filter(
- BxReimburseOffset.advance_id == advance_id,
- BxReimburseOffset.tenant_no == tenant_no,
- BxReimburseOffset.del_flag == 0,
- BxReimburseApply.del_flag == 0,
- BxReimburseApply.tenant_no == tenant_no,
- )
- .order_by(BxReimburseOffset.create_time.desc(), BxReimburseOffset.id.desc())
- .all()
- )
- records = [
- AdvanceOffsetRecordOut(
- reimburse_id=reimburse.id,
- reimburse_apply_code=reimburse.apply_code,
- reimburse_title=reimburse.title,
- reimburse_applicant_name=reimburse.applicant_name,
- offset_amount=float(offset.offset_amount or 0),
- remark=offset.remark,
- operator=offset.create_by,
- create_time=offset.create_time,
- )
- for offset, reimburse in rows
- ]
- return ApiResponse(data=records)
- @router.get("/{payment_id}/offset-records", response_model=ApiResponse[List[ReimburseOffsetRecordOut]])
- def list_reimburse_offset_records(
- payment_id: str,
- db: Session = Depends(get_db),
- user: BxUserContext = Depends(get_bx_current_user),
- ):
- tenant_no = _tenant(db, user)
- ReimburseService.get_by_id(db, payment_id, tenant_no, user=user)
- rows = (
- db.query(BxReimburseOffset, BxAdvanceApply)
- .join(BxAdvanceApply, BxAdvanceApply.id == BxReimburseOffset.advance_id)
- .filter(
- BxReimburseOffset.reimburse_id == payment_id,
- BxReimburseOffset.tenant_no == tenant_no,
- BxReimburseOffset.del_flag == 0,
- BxAdvanceApply.del_flag == 0,
- BxAdvanceApply.tenant_no == tenant_no,
- )
- .order_by(BxReimburseOffset.create_time.desc(), BxReimburseOffset.id.desc())
- .all()
- )
- records = [
- ReimburseOffsetRecordOut(
- advance_id=advance.id,
- advance_apply_code=advance.apply_code,
- advance_applicant_name=advance.applicant_name,
- advance_reason=advance.reason,
- offset_amount=float(offset.offset_amount or 0),
- remark=offset.remark,
- operator=offset.create_by,
- create_time=offset.create_time,
- )
- for offset, advance in rows
- ]
- return ApiResponse(data=records)
|