From b1f734dbb0d9893496ea9e353b9b43fafcbb81a3 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 21 Aug 2026 18:17:10 +0800 Subject: [PATCH] feat(pl): add Excel export for freight unsettled plans and settlement tabs Support filtered and selected export across freight settlement board panels. Co-authored-by: Cursor --- backend/app/api/pl/freight.py | 137 +++++++++++++++++- .../services/pl_freight_settlement_service.py | 14 ++ web-admin/src/api/pl/index.ts | 4 + .../freight/components/ApprovalDonePanel.vue | 26 ++++ .../components/ApprovalPendingPanel.vue | 24 +++ .../pl/freight/components/DisbursePanel.vue | 24 +++ .../pl/freight/components/SettledPanel.vue | 24 +++ .../components/SettlementBillPanel.vue | 26 ++++ .../freight/components/UnsettledPlanPanel.vue | 26 ++++ 9 files changed, 303 insertions(+), 2 deletions(-) diff --git a/backend/app/api/pl/freight.py b/backend/app/api/pl/freight.py index a8d800a72..f00ebdddc 100644 --- a/backend/app/api/pl/freight.py +++ b/backend/app/api/pl/freight.py @@ -1,13 +1,21 @@ """运输计划管理 - 运费结算 API""" -from datetime import date -from typing import Optional +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, @@ -101,6 +109,131 @@ def list_settlements( 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, diff --git a/backend/app/services/pl_freight_settlement_service.py b/backend/app/services/pl_freight_settlement_service.py index a56f72ee3..68f0fec43 100644 --- a/backend/app/services/pl_freight_settlement_service.py +++ b/backend/app/services/pl_freight_settlement_service.py @@ -153,8 +153,10 @@ def list_unsettled_plans( carrier_id: Optional[str] = None, plan_type: Optional[str] = None, license_plate: Optional[str] = None, + ids: Optional[List[str]] = None, page: int = 1, page_size: int = 20, + max_page_size: Optional[int] = None, ) -> Tuple[int, List[Dict[str, Any]]]: occupied = _occupied_transport_ids_subquery(db, tenant_no).subquery() @@ -182,11 +184,16 @@ def list_unsettled_plans( query = query.filter(PlPlan.plan_type == plan_type) if license_plate: query = query.filter(PlPlan.license_plate.contains(license_plate)) + if ids: + query = query.filter(PlPlan.id.in_(ids)) if user: query = apply_pl_scope(query, PlPlan, user, db, tenant_no) total = query.count() + page = max(1, page) + cap = int(max_page_size) if max_page_size else 500 + page_size = min(max(1, page_size), cap) rows = ( query.order_by(PlPlan.update_time.desc()) .offset((page - 1) * page_size) @@ -1107,8 +1114,10 @@ def list_settlements_enriched( carrier_id: Optional[str] = None, settlement_status: Optional[str] = None, tab: Optional[str] = None, + ids: Optional[List[str]] = None, page: int = 1, page_size: int = 20, + max_page_size: Optional[int] = None, ) -> Tuple[int, List[Dict[str, Any]]]: _sync_disbursed_settlements(db, tenant_no) query = db.query(PlFreightSettlement).filter( @@ -1141,11 +1150,16 @@ def list_settlements_enriched( .distinct() ) query = query.filter(PlFreightSettlement.id.in_(carrier_settlement_ids)) + if ids: + query = query.filter(PlFreightSettlement.id.in_(ids)) if user and tab != "approval_pending": query = apply_pl_scope(query, PlFreightSettlement, user, db, tenant_no) total = query.count() + page = max(1, page) + cap = int(max_page_size) if max_page_size else 500 + page_size = min(max(1, page_size), cap) rows = ( query.order_by(PlFreightSettlement.create_time.desc()) .offset((page - 1) * page_size) diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index 37f8decca..f891b247e 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -1179,10 +1179,14 @@ export interface FreightSettlementUpdateBody { export const freightApi = { listUnsettledPlans: (params?: UnsettledPlanQuery) => request.get('/transport/freight/unsettled-plans', { params }), + exportUnsettledPlans: (data: Record) => + request.post('/transport/freight/unsettled-plans/export', data, { responseType: 'blob' }), getByTransport: (transportId: string) => request.get(`/transport/freight/by-transport/${transportId}`), list: (params?: FreightSettlementQuery) => request.get('/transport/freight/', { params }), + export: (data: Record) => + request.post('/transport/freight/export', data, { responseType: 'blob' }), get: (id: string) => request.get(`/transport/freight/${id}`), create: (data: { planIds?: string[]; transportIds?: string[] }) => request.post('/transport/freight/', data), diff --git a/web-admin/src/views/pl/freight/components/ApprovalDonePanel.vue b/web-admin/src/views/pl/freight/components/ApprovalDonePanel.vue index 1c3ea7a43..d1b14faa6 100644 --- a/web-admin/src/views/pl/freight/components/ApprovalDonePanel.vue +++ b/web-admin/src/views/pl/freight/components/ApprovalDonePanel.vue @@ -39,6 +39,15 @@ @search="handleQuickSearch" @clear="handleQuickSearchClear" /> + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue' import type { StatsRowColumn } from '@/utils/list-column-stats' import { useDbInstantSearch } from '@/composables/useDbInstantSearch' @@ -232,6 +242,22 @@ function getDoneStatCellValue(row: FreightSettlement, prop: string): unknown { const loading = ref(false) const selectedRows = ref([]) + +function getSettlementExportFilterParams() { + return { + tab: 'approval_done', + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + carrierId: searchForm.carrierId || undefined, + settlementStatus: searchForm.settlementStatus || undefined + } +} + +const settlementExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('settlementNo')) cols.unshift('settlementNo') + return cols +}) + const settlementList = ref([]) const viewDialogRef = ref>() const quickSearchKeyword = ref('') diff --git a/web-admin/src/views/pl/freight/components/ApprovalPendingPanel.vue b/web-admin/src/views/pl/freight/components/ApprovalPendingPanel.vue index a497fcde4..8be2551b5 100644 --- a/web-admin/src/views/pl/freight/components/ApprovalPendingPanel.vue +++ b/web-admin/src/views/pl/freight/components/ApprovalPendingPanel.vue @@ -57,6 +57,15 @@ > 驳回 + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue' import type { StatsRowColumn } from '@/utils/list-column-stats' import { useDbInstantSearch } from '@/composables/useDbInstantSearch' @@ -262,6 +272,20 @@ function getPendingStatCellValue(row: FreightSettlement, prop: string): unknown const loading = ref(false) const settlementList = ref([]) const selectedRows = ref([]) + +function getSettlementExportFilterParams() { + return { + tab: 'approval_pending', + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + } +} + +const settlementExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('settlementNo')) cols.unshift('settlementNo') + return cols +}) + const viewingRow = ref(null) const viewDialogRef = ref>() const approvalDialogRef = ref>() diff --git a/web-admin/src/views/pl/freight/components/DisbursePanel.vue b/web-admin/src/views/pl/freight/components/DisbursePanel.vue index 54310f291..5585742bd 100644 --- a/web-admin/src/views/pl/freight/components/DisbursePanel.vue +++ b/web-admin/src/views/pl/freight/components/DisbursePanel.vue @@ -41,6 +41,15 @@ > 发放登记 + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue' import type { StatsRowColumn } from '@/utils/list-column-stats' import { useDbInstantSearch } from '@/composables/useDbInstantSearch' @@ -226,6 +236,20 @@ function getDisburseStatCellValue(row: FreightSettlement, prop: string): unknown const loading = ref(false) const settlementList = ref([]) const selectedRows = ref([]) + +function getSettlementExportFilterParams() { + return { + tab: 'disburse', + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + } +} + +const settlementExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('settlementNo')) cols.unshift('settlementNo') + return cols +}) + const viewingRow = ref(null) const viewDialogRef = ref>() const disburseDialogRef = ref>() diff --git a/web-admin/src/views/pl/freight/components/SettledPanel.vue b/web-admin/src/views/pl/freight/components/SettledPanel.vue index b38359aac..76d213bef 100644 --- a/web-admin/src/views/pl/freight/components/SettledPanel.vue +++ b/web-admin/src/views/pl/freight/components/SettledPanel.vue @@ -33,6 +33,15 @@ @search="handleQuickSearch" @clear="handleQuickSearchClear" /> + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue' import type { StatsRowColumn } from '@/utils/list-column-stats' import { useDbInstantSearch } from '@/composables/useDbInstantSearch' @@ -212,6 +222,20 @@ function getSettledStatCellValue(row: FreightSettlement, prop: string): unknown const loading = ref(false) const selectedRows = ref([]) + +function getSettlementExportFilterParams() { + return { + tab: 'settled', + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + } +} + +const settlementExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('settlementNo')) cols.unshift('settlementNo') + return cols +}) + const settlementList = ref([]) const viewDialogRef = ref>() const quickSearchKeyword = ref('') diff --git a/web-admin/src/views/pl/freight/components/SettlementBillPanel.vue b/web-admin/src/views/pl/freight/components/SettlementBillPanel.vue index 4e82056be..1b1a1d73d 100644 --- a/web-admin/src/views/pl/freight/components/SettlementBillPanel.vue +++ b/web-admin/src/views/pl/freight/components/SettlementBillPanel.vue @@ -112,6 +112,15 @@ > 撤回 + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue' import type { StatsRowColumn } from '@/utils/list-column-stats' import { useDbInstantSearch } from '@/composables/useDbInstantSearch' @@ -413,6 +423,22 @@ const { }) const settlementList = ref([]) const selectedRows = ref([]) + +function getSettlementExportFilterParams() { + return { + tab: 'bills', + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + carrierId: searchForm.carrierId || undefined, + settlementStatus: searchForm.settlementStatus || undefined + } +} + +const settlementExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('settlementNo')) cols.unshift('settlementNo') + return cols +}) + const viewingRow = ref(null) const viewDialogRef = ref>() const formDialogRef = ref>() diff --git a/web-admin/src/views/pl/freight/components/UnsettledPlanPanel.vue b/web-admin/src/views/pl/freight/components/UnsettledPlanPanel.vue index c7042b790..1faef3938 100644 --- a/web-admin/src/views/pl/freight/components/UnsettledPlanPanel.vue +++ b/web-admin/src/views/pl/freight/components/UnsettledPlanPanel.vue @@ -71,6 +71,15 @@ > 生成结算单 + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue' import type { StatsRowColumn } from '@/utils/list-column-stats' import { useDbInstantSearch } from '@/composables/useDbInstantSearch' @@ -297,6 +307,22 @@ const vehicleOptions = ref([]) const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value)) const planList = ref([]) const selectedRows = ref([]) + +function getUnsettledExportFilterParams() { + return { + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + carrierId: searchForm.carrierId || undefined, + planType: searchForm.planType || undefined, + licensePlate: searchForm.licensePlate || undefined + } +} + +const unsettledExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('planNo')) cols.unshift('planNo') + return cols +}) + const planViewVisible = ref(false) const viewPlanId = ref('') const quickSearchKeyword = ref('')