"""运输计划管理 - 运费结算 API""" from datetime import date, datetime from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.database import get_db from app.core.db_auth import DbUserContext, get_db_current_user from app.core.tenant_context import get_tenant_no from app.utils.excel_export import ( EXPORT_MAX_ROWS, ExportColumn, ExportValidationError, export_rows_to_xlsx_response, filter_export_columns, ) from app.models.pl_models import PlFreightSettlement from app.schemas.pl.freight import ( FreightSettlementApprovalActionBody, FreightSettlementByTransportRead, FreightSettlementCreate, FreightSettlementDisburseBody, FreightSettlementListResponse, FreightSettlementRead, FreightSettlementSubmitApprovalBody, FreightSettlementUpdate, FreightSettlementWithTransports, RemittanceVoucherRead, UnsettledPlanListResponse, ) from app.services import pl_freight_settlement_service as freight_svc router = APIRouter(prefix="/freight", tags=["运输计划-运费结算"]) def _generate_settlement_no(db: Session) -> str: today = date.today() prefix = f"YF{today.strftime('%Y%m%d')}" last = ( db.query(PlFreightSettlement) .filter( PlFreightSettlement.settlement_no.like(f"{prefix}%"), PlFreightSettlement.del_flag == 0, ) .order_by(PlFreightSettlement.settlement_no.desc()) .first() ) seq = int(last.settlement_no[-2:]) + 1 if last else 1 return f"{prefix}{seq:02d}" @router.get("/unsettled-plans", response_model=UnsettledPlanListResponse) def list_unsettled_plans( q: Optional[str] = Query(default=None), carrier_id: Optional[str] = Query(default=None, alias="carrierId"), plan_type: Optional[str] = Query(default=None, alias="planType"), license_plate: Optional[str] = Query(default=None, alias="licensePlate"), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"), db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> UnsettledPlanListResponse: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) total, items = freight_svc.list_unsettled_plans( db, tenant_no=tenant_no, user=user, q=q, carrier_id=carrier_id, plan_type=plan_type, license_plate=license_plate, page=page, page_size=page_size, ) return UnsettledPlanListResponse(total=total, list=items) @router.get("/", response_model=FreightSettlementListResponse) def list_settlements( q: Optional[str] = Query(default=None), carrier_id: Optional[str] = Query(default=None, alias="carrierId"), settlement_status: Optional[str] = Query(default=None, alias="settlementStatus"), tab: Optional[str] = Query( default=None, description="bills|approval_pending|approval_done|disburse|settled", ), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"), db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementListResponse: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) total, items = freight_svc.list_settlements_enriched( db, tenant_no=tenant_no, user_id=user.id, user=user, q=q, carrier_id=carrier_id, settlement_status=settlement_status, tab=tab, page=page, page_size=page_size, ) return FreightSettlementListResponse(total=total, list=items) UNSETTLED_EXPORT_COLUMNS = [ ExportColumn(title="计划编号", field="planNo"), ExportColumn(title="计划日期", field="planDate"), ExportColumn(title="单据类型", field="planType"), ExportColumn(title="客户/供应商", field="partnerName"), ExportColumn(title="车牌号", field="licensePlate"), ExportColumn(title="承运商", field="transportCompany"), ExportColumn(title="运费(元)", field="freightAmount"), ExportColumn(title="归档时间", field="archiveTime"), ] PLAN_TYPE_LABELS = {"direct": "直销单", "inbound": "入库单", "outbound": "出库单"} SETTLEMENT_STATUS_LABELS = { "pending": "草稿", "rejected": "已驳回", "approving": "审批中", "approved": "待发放", "confirmed": "已发放", "cancelled": "已作废", } class UnsettledExportBody(BaseModel): q: Optional[str] = None carrier_id: Optional[str] = Field(default=None, alias="carrierId") plan_type: Optional[str] = Field(default=None, alias="planType") license_plate: Optional[str] = Field(default=None, alias="licensePlate") ids: Optional[List[str]] = None columns: Optional[List[str]] = None model_config = {"populate_by_name": True} @router.post("/unsettled-plans/export") def export_unsettled_plans( body: UnsettledExportBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ): try: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) _, items = freight_svc.list_unsettled_plans( db, tenant_no=tenant_no, user=user, q=body.q, carrier_id=body.carrier_id, plan_type=body.plan_type, license_plate=body.license_plate, ids=body.ids, page=1, page_size=EXPORT_MAX_ROWS, max_page_size=EXPORT_MAX_ROWS, ) rows = [] for item in items: data = dict(item) data["partnerName"] = data.get("customerName") or data.get("supplierName") or "" data["planType"] = PLAN_TYPE_LABELS.get(str(data.get("planType") or ""), data.get("planType")) for k, v in list(data.items()): if hasattr(v, "isoformat"): data[k] = str(v)[:19] rows.append(data) cols = filter_export_columns(UNSETTLED_EXPORT_COLUMNS, body.columns) date_str = datetime.now().strftime("%Y%m%d") return export_rows_to_xlsx_response( db, tenant_no=tenant_no, sheet_title="待结算计划", columns=cols, rows=rows, filename_stem=f"待结算计划_{date_str}", ) except ExportValidationError as e: raise HTTPException(status_code=400, detail=str(e)) from e SETTLEMENT_EXPORT_COLUMNS = [ ExportColumn(title="结算单号", field="settlementNo"), ExportColumn(title="运费合计(元)", field="totalFreight"), ExportColumn(title="关联计划数", field="planCount"), ExportColumn(title="车牌摘要", field="licensePlateSummary"), ExportColumn(title="结算状态", field="settlementStatus"), ExportColumn(title="发放金额(元)", field="disburseAmount"), ExportColumn(title="发放时间", field="disburseTime"), ExportColumn(title="当前审批人", field="currentApproverName"), ExportColumn(title="提交人", field="submitterName"), ExportColumn(title="创建时间", field="createTime"), ExportColumn(title="审批完成时间", field="approvalFinishedTime"), ] class SettlementExportBody(BaseModel): q: Optional[str] = None carrier_id: Optional[str] = Field(default=None, alias="carrierId") settlement_status: Optional[str] = Field(default=None, alias="settlementStatus") tab: Optional[str] = None ids: Optional[List[str]] = None columns: Optional[List[str]] = None model_config = {"populate_by_name": True} @router.post("/export") def export_settlements( body: SettlementExportBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ): try: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) _, items = freight_svc.list_settlements_enriched( db, tenant_no=tenant_no, user_id=user.id, user=user, q=body.q, carrier_id=body.carrier_id, settlement_status=body.settlement_status, tab=body.tab, ids=body.ids, page=1, page_size=EXPORT_MAX_ROWS, max_page_size=EXPORT_MAX_ROWS, ) rows = [] for item in items: data = dict(item) stv = data.get("settlementStatus") if stv is not None: data["settlementStatus"] = SETTLEMENT_STATUS_LABELS.get(str(stv), stv) for k, v in list(data.items()): if hasattr(v, "isoformat"): data[k] = str(v)[:19] rows.append(data) cols = filter_export_columns(SETTLEMENT_EXPORT_COLUMNS, body.columns) date_str = datetime.now().strftime("%Y%m%d") return export_rows_to_xlsx_response( db, tenant_no=tenant_no, sheet_title="运费结算", columns=cols, rows=rows, filename_stem=f"运费结算_{date_str}", ) except ExportValidationError as e: raise HTTPException(status_code=400, detail=str(e)) from e @router.get("/by-transport/{transport_id}", response_model=Optional[FreightSettlementByTransportRead]) def get_settlement_by_transport( transport_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> Optional[FreightSettlementByTransportRead]: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) detail = freight_svc.get_settlement_by_transport_id( db, tenant_no=tenant_no, transport_id=transport_id, user=user ) if not detail: return None return FreightSettlementByTransportRead.model_validate(detail) @router.post("/", response_model=FreightSettlementRead, status_code=status.HTTP_201_CREATED) def create_settlement( payload: FreightSettlementCreate, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) settlement = freight_svc.create_settlement( db, tenant_no=tenant_no, plan_ids=payload.plan_ids, transport_ids=payload.transport_ids, user_id=user.id, dept_id=user.dept_id, settlement_no=_generate_settlement_no(db), ) return settlement @router.get("/{id}", response_model=FreightSettlementWithTransports) def get_settlement( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementWithTransports: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) detail = freight_svc.get_settlement_detail( db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user ) return FreightSettlementWithTransports.model_validate(detail) @router.put("/{id}", response_model=FreightSettlementRead) def update_settlement( id: str, payload: FreightSettlementUpdate, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) return freight_svc.update_settlement_draft( db, tenant_no=tenant_no, settlement_id=id, payload=payload, user_id=user.id, user=user, ) @router.post("/{id}/cancel", response_model=FreightSettlementRead) def cancel_settlement( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) return freight_svc.cancel_draft_settlement( db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user ) @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) def delete_settlement( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> None: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) freight_svc.delete_draft_settlement( db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user ) @router.post("/{id}/submit-approval", response_model=FreightSettlementRead) def submit_settlement_approval( id: str, payload: FreightSettlementSubmitApprovalBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) return freight_svc.submit_settlement_approval( db, tenant_no=tenant_no, settlement_id=id, body=payload, user_id=user.id, dept_id=user.dept_id, user=user, ) @router.post("/approval/{approval_id}", response_model=FreightSettlementRead) def process_settlement_approval( approval_id: str, payload: FreightSettlementApprovalActionBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) if payload.action not in ("approve", "reject"): raise HTTPException(status_code=400, detail="action 必须为 approve 或 reject") return freight_svc.process_settlement_approval( db, tenant_no=tenant_no, approval_id=approval_id, action=payload.action, comment=payload.comment, user_id=user.id, ) @router.post("/{id}/withdraw-approval", response_model=FreightSettlementRead) def withdraw_settlement_approval( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) return freight_svc.withdraw_settlement_approval( db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user ) @router.get("/{id}/approval-flow") def get_settlement_approval_flow( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ): tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) freight_svc.get_settlement_row(db, tenant_no, id, user=user) return freight_svc.get_approval_flow(db, id) @router.post("/{id}/disburse", response_model=FreightSettlementRead) def disburse_settlement( id: str, payload: FreightSettlementDisburseBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) return freight_svc.disburse_settlement( db, tenant_no=tenant_no, settlement_id=id, body=payload, user_id=user.id, dept_id=user.dept_id, user=user, ) # 向后兼容旧接口 @router.post("/{id}/submit", response_model=FreightSettlementRead, deprecated=True) def submit_settlement_legacy( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: raise HTTPException(status_code=400, detail="请使用 submit-approval 接口并选择审批人") @router.post("/{id}/approve", response_model=FreightSettlementRead, deprecated=True) def approve_settlement_legacy( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> FreightSettlementRead: raise HTTPException(status_code=400, detail="请使用 approval/{approvalId} 接口审批") @router.get("/voucher/{settlement_id}", response_model=list[RemittanceVoucherRead]) def list_vouchers( settlement_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> list[RemittanceVoucherRead]: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) freight_svc.get_settlement_row(db, tenant_no, settlement_id, user=user) detail = freight_svc.get_settlement_detail( db, tenant_no=tenant_no, settlement_id=settlement_id, user_id=user.id, user=user ) return detail.get("vouchers") or []