Prechádzať zdrojové kódy

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 <cursoragent@cursor.com>
zlwu 2 mesiacov pred
rodič
commit
b1f734dbb0

+ 135 - 2
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,

+ 14 - 0
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)

+ 4 - 0
web-admin/src/api/pl/index.ts

@@ -1179,10 +1179,14 @@ export interface FreightSettlementUpdateBody {
 export const freightApi = {
   listUnsettledPlans: (params?: UnsettledPlanQuery) =>
     request.get<UnsettledPlanListResponse>('/transport/freight/unsettled-plans', { params }),
+  exportUnsettledPlans: (data: Record<string, any>) =>
+    request.post('/transport/freight/unsettled-plans/export', data, { responseType: 'blob' }),
   getByTransport: (transportId: string) =>
     request.get<FreightSettlementBrief | null>(`/transport/freight/by-transport/${transportId}`),
   list: (params?: FreightSettlementQuery) =>
     request.get<FreightSettlementListResponse>('/transport/freight/', { params }),
+  export: (data: Record<string, any>) =>
+    request.post('/transport/freight/export', data, { responseType: 'blob' }),
   get: (id: string) => request.get<FreightSettlement>(`/transport/freight/${id}`),
   create: (data: { planIds?: string[]; transportIds?: string[] }) =>
     request.post<FreightSettlement>('/transport/freight/', data),

+ 26 - 0
web-admin/src/views/pl/freight/components/ApprovalDonePanel.vue

@@ -39,6 +39,15 @@
           @search="handleQuickSearch"
           @clear="handleQuickSearchClear"
         />
+          <ListExportDropdown
+            :export-fn="freightApi.export"
+            :get-filter-params="getSettlementExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="settlementExportColumns"
+            filename="已审批结算单.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -169,6 +178,7 @@
 <script setup lang="ts">
 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<FreightSettlement[]>([])
+
+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<FreightSettlement[]>([])
 const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
 const quickSearchKeyword = ref('')

+ 24 - 0
web-admin/src/views/pl/freight/components/ApprovalPendingPanel.vue

@@ -57,6 +57,15 @@
         >
           <el-icon><CircleClose /></el-icon>驳回
         </el-button>
+          <ListExportDropdown
+            :export-fn="freightApi.export"
+            :get-filter-params="getSettlementExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="settlementExportColumns"
+            filename="待审批结算单.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -201,6 +210,7 @@
 <script setup lang="ts">
 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<FreightSettlement[]>([])
 const selectedRows = ref<FreightSettlement[]>([])
+
+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<FreightSettlement | null>(null)
 const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
 const approvalDialogRef = ref<InstanceType<typeof PlFreightApprovalDialog>>()

+ 24 - 0
web-admin/src/views/pl/freight/components/DisbursePanel.vue

@@ -41,6 +41,15 @@
         >
           <el-icon><Money /></el-icon>发放登记
         </el-button>
+          <ListExportDropdown
+            :export-fn="freightApi.export"
+            :get-filter-params="getSettlementExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="settlementExportColumns"
+            filename="待发放结算单.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -165,6 +174,7 @@
 <script setup lang="ts">
 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<FreightSettlement[]>([])
 const selectedRows = ref<FreightSettlement[]>([])
+
+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<FreightSettlement | null>(null)
 const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
 const disburseDialogRef = ref<InstanceType<typeof PlFreightDisburseDialog>>()

+ 24 - 0
web-admin/src/views/pl/freight/components/SettledPanel.vue

@@ -33,6 +33,15 @@
           @search="handleQuickSearch"
           @clear="handleQuickSearchClear"
         />
+          <ListExportDropdown
+            :export-fn="freightApi.export"
+            :get-filter-params="getSettlementExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="settlementExportColumns"
+            filename="已结算.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -151,6 +160,7 @@
 <script setup lang="ts">
 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<FreightSettlement[]>([])
+
+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<FreightSettlement[]>([])
 const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
 const quickSearchKeyword = ref('')

+ 26 - 0
web-admin/src/views/pl/freight/components/SettlementBillPanel.vue

@@ -112,6 +112,15 @@
         >
           <el-icon><RefreshLeft /></el-icon>撤回
         </el-button>
+          <ListExportDropdown
+            :export-fn="freightApi.export"
+            :get-filter-params="getSettlementExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="settlementExportColumns"
+            filename="结算单.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -320,6 +329,7 @@
 <script setup lang="ts">
 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<FreightSettlement[]>([])
 const selectedRows = ref<FreightSettlement[]>([])
+
+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<FreightSettlement | null>(null)
 const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
 const formDialogRef = ref<InstanceType<typeof PlFreightSettlementFormDialog>>()

+ 26 - 0
web-admin/src/views/pl/freight/components/UnsettledPlanPanel.vue

@@ -71,6 +71,15 @@
         >
           <el-icon><DocumentAdd /></el-icon>生成结算单
         </el-button>
+        <ListExportDropdown
+          :export-fn="freightApi.exportUnsettledPlans"
+          :get-filter-params="getUnsettledExportFilterParams"
+          :has-selection="true"
+          :selected-ids="selectedRows.map((r) => r.id)"
+          :visible-columns="unsettledExportColumns"
+          filename="待结算计划.xlsx"
+        />
+
         <ListToolbarEndActions
           :loading="loading"
           :column-defs="listColumnDefs"
@@ -214,6 +223,7 @@
 <script setup lang="ts">
 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<Vehicle[]>([])
 const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value))
 const planList = ref<UnsettledPlanItem[]>([])
 const selectedRows = ref<UnsettledPlanItem[]>([])
+
+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('')