Browse Source

feat(pl): add Excel export for plan and transport lists

Support filtered/selected export on plan, archive, cert and transport pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 months ago
parent
commit
6a89452f1a

+ 126 - 2
backend/app/api/pl/plan.py

@@ -1,9 +1,10 @@
 """运输计划管理 - 计划 API"""
 import re
-from datetime import date
-from typing import Optional
+from datetime import date, datetime
+from typing import List, Optional
 
 from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
+from pydantic import BaseModel, Field
 from sqlalchemy import func, or_
 from sqlalchemy.orm import Session
 
@@ -14,6 +15,13 @@ from app.core.delete_guard import assert_row_deletable_by_creator, user_info_fro
 from app.core.menu_guard import SUPER_ADMIN_ROLE, get_user_menu_codes
 from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
 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.core.utils import generate_uuid
 from app.models.crm_models import CrmContract, CrmContractProduct, CrmProduct
 from app.models.db_models import DbInboundOutbound
@@ -465,6 +473,122 @@ def list_plans(
     return PlanListResponse(total=total, list=result_list)
 
 
+
+PLAN_EXPORT_COLUMNS = [
+    ExportColumn(title="计划编号", field="planNo"),
+    ExportColumn(title="跟进状态", field="followStatus"),
+    ExportColumn(title="计划日期", field="planDate"),
+    ExportColumn(title="单据类型", field="planType"),
+    ExportColumn(title="供方", field="supplierName"),
+    ExportColumn(title="需方", field="customerName"),
+    ExportColumn(title="车牌号", field="licensePlate"),
+    ExportColumn(title="司机", field="driverName"),
+    ExportColumn(title="运输状态", field="transportStatus"),
+    ExportColumn(title="归档状态", field="archiveStatus"),
+    ExportColumn(title="备注", field="remark"),
+]
+
+
+class PlanExportBody(BaseModel):
+    q: Optional[str] = None
+    plan_no: Optional[str] = Field(default=None, alias="planNo")
+    plan_type: Optional[str] = Field(default=None, alias="planType")
+    follow_status: Optional[str] = Field(default=None, alias="followStatus")
+    transport_status: Optional[str] = Field(default=None, alias="transportStatus")
+    archive_status: Optional[str] = Field(default=None, alias="archiveStatus")
+    customer_name: Optional[str] = Field(default=None, alias="customerName")
+    product_name: Optional[str] = Field(default=None, alias="productName")
+    license_plate: Optional[str] = Field(default=None, alias="licensePlate")
+    plan_date_from: Optional[date] = Field(default=None, alias="planDateFrom")
+    plan_date_to: Optional[date] = Field(default=None, alias="planDateTo")
+    has_hazard_product: Optional[int] = Field(default=None, alias="hasHazardProduct")
+    cert_upload_status: Optional[str] = Field(default=None, alias="certUploadStatus")
+    ids: Optional[List[str]] = None
+    columns: Optional[List[str]] = None
+
+    model_config = {"populate_by_name": True}
+
+
+@router.post("/export")
+def export_plans(
+    body: PlanExportBody,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+):
+    """导出计划列表(当前筛选全量,或 ids 已选)。"""
+    try:
+        tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+        query = db.query(PlPlan).filter(
+            PlPlan.tenant_no == tenant_no,
+            PlPlan.del_flag == 0,
+        )
+        if body.q:
+            q_kw = body.q.strip()
+            if q_kw:
+                q_lower = q_kw.lower()
+                query = query.filter(
+                    or_(
+                        PlPlan.plan_no.contains(q_kw),
+                        func.lower(PlPlan.supplier_name).contains(q_lower),
+                        func.lower(PlPlan.customer_name).contains(q_lower),
+                        func.lower(PlPlan.destination_name).contains(q_lower),
+                        _plan_product_name_exists(db, tenant_no, q_kw),
+                        _plan_plate_match(q_kw),
+                    )
+                )
+        if body.plan_no:
+            query = query.filter(PlPlan.plan_no.contains(body.plan_no))
+        if body.plan_type:
+            query = query.filter(PlPlan.plan_type == body.plan_type)
+        if body.follow_status:
+            query = query.filter(PlPlan.follow_status == body.follow_status)
+        if body.transport_status:
+            query = query.filter(PlPlan.transport_status == body.transport_status)
+        if body.archive_status:
+            query = query.filter(PlPlan.archive_status == body.archive_status)
+        if body.customer_name:
+            query = query.filter(func.lower(PlPlan.customer_name).contains(body.customer_name.lower()))
+        if body.product_name:
+            product_name_kw = body.product_name.strip()
+            if product_name_kw:
+                query = query.filter(_plan_product_name_exists(db, tenant_no, product_name_kw))
+        if body.license_plate:
+            plate_kw = body.license_plate.strip()
+            if plate_kw:
+                query = query.filter(_plan_plate_match(plate_kw))
+        if body.plan_date_from:
+            query = query.filter(PlPlan.plan_date >= body.plan_date_from)
+        if body.plan_date_to:
+            query = query.filter(PlPlan.plan_date <= body.plan_date_to)
+        if body.has_hazard_product == 1:
+            query = query.filter(plan_has_hazard_product_exists(tenant_no))
+        if (body.cert_upload_status or "").strip().lower() == "incomplete":
+            query = query.filter(plan_has_incomplete_hazard_cert_exists(tenant_no))
+        if body.ids:
+            query = query.filter(PlPlan.id.in_(body.ids))
+        query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
+        plans = query.order_by(PlPlan.create_time.desc()).limit(EXPORT_MAX_ROWS + 1).all()
+        items = []
+        for plan in plans:
+            data = PlanWithProducts.model_validate(plan).model_dump(by_alias=True)
+            # flatten date
+            if data.get("planDate") is not None:
+                data["planDate"] = str(data["planDate"])[:10]
+            items.append(data)
+        cols = filter_export_columns(PLAN_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=items,
+            filename_stem=f"计划列表_{date_str}",
+        )
+    except ExportValidationError as e:
+        raise HTTPException(status_code=400, detail=str(e)) from e
+
+
 @router.get("/count-by-contract")
 def count_plans_by_contract(
     contract_ids: str = Query(..., alias="contractIds", description="合同ID,逗号分隔"),

+ 87 - 1
backend/app/api/pl/transport.py

@@ -1,6 +1,6 @@
 """运输计划管理 - 运输 API"""
 from datetime import date, datetime
-from typing import Optional
+from typing import List, Optional
 import json
 import secrets
 
@@ -13,6 +13,13 @@ from app.core.database import get_db
 from app.core.db_auth import DbUserContext, get_db_current_user
 from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
 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.core.utils import generate_uuid
 from app.core.config import settings
 from app.models.pl_models import (
@@ -432,6 +439,85 @@ def list_transports(
 # =============================================================================
 
 
+
+TRANSPORT_EXPORT_COLUMNS = [
+    ExportColumn(title="运输编号", field="transportNo"),
+    ExportColumn(title="车牌号", field="licensePlate"),
+    ExportColumn(title="司机", field="driverName"),
+    ExportColumn(title="承运商", field="carrierName"),
+    ExportColumn(title="运输介质", field="transportMedium"),
+    ExportColumn(title="起点", field="startPoint"),
+    ExportColumn(title="终点", field="endPoint"),
+    ExportColumn(title="运输状态", field="transportStatus"),
+    ExportColumn(title="审批状态", field="approvalStatus"),
+    ExportColumn(title="备注", field="remark"),
+]
+
+
+class TransportExportBody(BaseModel):
+    q: Optional[str] = None
+    transport_no: Optional[str] = Field(default=None, alias="transportNo")
+    transport_status: Optional[str] = Field(default=None, alias="transportStatus")
+    approval_status: Optional[str] = Field(default=None, alias="approvalStatus")
+    vehicle_id: Optional[str] = Field(default=None, alias="vehicleId")
+    driver_id: Optional[str] = Field(default=None, alias="driverId")
+    ids: Optional[List[str]] = None
+    columns: Optional[List[str]] = None
+
+    model_config = {"populate_by_name": True}
+
+
+@router.post("/export")
+def export_transports(
+    body: TransportExportBody,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+):
+    """导出运输列表(当前筛选全量,或 ids 已选)。"""
+    try:
+        tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+        query = db.query(PlTransport).filter(
+            PlTransport.tenant_no == tenant_no,
+            PlTransport.del_flag == 0,
+        )
+        if body.q:
+            query = query.filter(
+                or_(
+                    PlTransport.transport_no.contains(body.q),
+                    PlTransport.license_plate.contains(body.q),
+                    PlTransport.driver_name.contains(body.q),
+                )
+            )
+        if body.transport_no:
+            query = query.filter(PlTransport.transport_no.contains(body.transport_no))
+        if body.transport_status:
+            query = query.filter(PlTransport.transport_status == body.transport_status)
+        if body.approval_status:
+            query = query.filter(PlTransport.approval_status == body.approval_status)
+        if body.vehicle_id:
+            query = query.filter(PlTransport.vehicle_id == body.vehicle_id)
+        if body.driver_id:
+            query = query.filter(PlTransport.driver_id == body.driver_id)
+        if body.ids:
+            query = query.filter(PlTransport.id.in_(body.ids))
+        query = apply_pl_scope(query, PlTransport, user, db, tenant_no)
+        rows = query.order_by(PlTransport.create_time.desc()).limit(EXPORT_MAX_ROWS + 1).all()
+        items = [TransportRead.model_validate(r).model_dump(by_alias=True) for r in rows]
+        cols = filter_export_columns(TRANSPORT_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=items,
+            filename_stem=f"运输列表_{date_str}",
+        )
+    except ExportValidationError as e:
+        raise HTTPException(status_code=400, detail=str(e)) from e
+
+
+
 @router.post("/", response_model=TransportRead, status_code=status.HTTP_201_CREATED)
 def create_transport(
     payload: TransportCreate,

+ 6 - 2
web-admin/src/api/pl/index.ts

@@ -138,9 +138,9 @@ export interface VehicleQuery {
 
 export const vehicleApi = {
   list: (params?: VehicleQuery) =>
+    request.get<PlListResponse<Vehicle>>('/transport/vehicle', { params }),
   export: (data: Record<string, any>) =>
     request.post('/transport/vehicle/export', data, { responseType: 'blob' }),
-    request.get<PlListResponse<Vehicle>>('/transport/vehicle', { params }),
   get: (id: string) => request.get<VehicleDetail>(`/transport/vehicle/${id}`),
   create: (data: VehiclePayload) => request.post<VehicleDetail>('/transport/vehicle', data),
   update: (id: string, data: Partial<VehiclePayload>) =>
@@ -194,9 +194,9 @@ export interface PersonnelQuery {
 
 export const personnelApi = {
   list: (params?: PersonnelQuery) =>
+    request.get<PlListResponse<Personnel>>('/transport/personnel', { params }),
   export: (data: Record<string, any>) =>
     request.post('/transport/personnel/export', data, { responseType: 'blob' }),
-    request.get<PlListResponse<Personnel>>('/transport/personnel', { params }),
   get: (id: string) => request.get<PersonnelDetail>(`/transport/personnel/${id}`),
   create: (data: PersonnelPayload) => request.post<PersonnelDetail>('/transport/personnel', data),
   update: (id: string, data: Partial<PersonnelPayload>) =>
@@ -696,6 +696,8 @@ export interface PlanContractGapQuery {
 
 export const planApi = {
   list: (params?: PlanQuery) => request.get<PlanListResponse>('/transport/plan/', { params }),
+  export: (data: Record<string, any>) =>
+    request.post('/transport/plan/export', data, { responseType: 'blob' }),
   get: (id: string) => request.get<Plan>(`/transport/plan/${id}`),
   countByContract: (contractIds: string[]) =>
     request.get<{ counts: Record<string, number> }>('/transport/plan/count-by-contract', {
@@ -1002,6 +1004,8 @@ export interface TransportListResponse {
 
 export const transportApi = {
   list: (params?: TransportQuery) => request.get<TransportListResponse>('/transport/order/', { params }),
+  export: (data: Record<string, any>) =>
+    request.post('/transport/order/export', data, { responseType: 'blob' }),
   get: (id: string) => request.get<Transport>(`/transport/order/${id}`),
   create: (data: Partial<Transport> & { planIds?: string[] }) =>
     request.post<Transport>('/transport/order/', data),

+ 32 - 0
web-admin/src/views/pl/plan-archive/index.vue

@@ -95,6 +95,15 @@
             <el-icon><FolderChecked /></el-icon>
             {{ selectedPlan ? archiveButtonLabel(selectedPlan) : '归档' }}
           </el-button>
+          <ListExportDropdown
+            :export-fn="planApi.export"
+            :get-filter-params="getPlanArchiveExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="planArchiveExportColumns"
+            filename="计划归档.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -351,6 +360,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 { usePersistedListColumns } from '@/composables/usePersistedListColumns'
@@ -478,6 +488,28 @@ function getArchiveStatCellValue(row: Plan, prop: string): unknown {
 }
 
 const selectedRows = ref<Plan[]>([])
+function getPlanArchiveExportFilterParams() {
+  const keyword = quickSearch.value.trim()
+  return {
+    q: keyword || undefined,
+    planNo: keyword ? undefined : searchForm.planNo || undefined,
+    planType: searchForm.planType || undefined,
+    followStatus: searchForm.followStatus || undefined,
+    transportStatus: 'arranged',
+    customerName: keyword ? undefined : searchForm.customerName || undefined,
+    productName: keyword ? undefined : searchForm.productName || undefined,
+    archiveStatus: searchForm.archiveStatus || undefined,
+    planDateFrom: dateRange.value?.[0] || undefined,
+    planDateTo: dateRange.value?.[1] || undefined
+  }
+}
+
+const planArchiveExportColumns = computed(() => {
+  const cols = [...visibleColumns.value]
+  if (!cols.includes('planNo')) cols.unshift('planNo')
+  return cols
+})
+
 const viewingPlan = ref<Plan | null>(null)
 
 function syncViewingPlanFromList() {

+ 31 - 0
web-admin/src/views/pl/plan-cert/index.vue

@@ -135,6 +135,15 @@
             <el-icon><CircleClose /></el-icon>取消齐备
           </el-button>
 
+          <ListExportDropdown
+            :export-fn="planApi.export"
+            :get-filter-params="getPlanCertExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="planCertExportColumns"
+            filename="计划证件.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -413,6 +422,7 @@ import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/ind
 import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
 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 {
   buildHazardCertReadyConfirmMessage,
   isHazardCertReady,
@@ -477,6 +487,27 @@ const {
 } = usePersistedListColumns('pl-plan-cert-list-columns', listColumnDefs)
 
 const selectedRows = ref<Plan[]>([])
+function getPlanCertExportFilterParams() {
+  const keyword = quickSearch.value.trim()
+  return {
+    q: keyword || undefined,
+    planNo: keyword ? undefined : searchForm.planNo || undefined,
+    planType: searchForm.planType || undefined,
+    followStatus: searchForm.followStatus || undefined,
+    customerName: keyword ? undefined : searchForm.customerName || undefined,
+    planDateFrom: dateRange.value?.[0] || undefined,
+    planDateTo: dateRange.value?.[1] || undefined,
+    hasHazardProduct: 1,
+    certUploadStatus: searchForm.certUploadStatus || undefined
+  }
+}
+
+const planCertExportColumns = computed(() => {
+  const cols = [...visibleColumns.value]
+  if (!cols.includes('planNo')) cols.unshift('planNo')
+  return cols
+})
+
 const viewingPlan = ref<Plan | null>(null)
 const selectedPlan = computed(() => (selectedRows.value.length === 1 ? selectedRows.value[0] : null))
 const dateRange = ref<string[]>([])

+ 31 - 0
web-admin/src/views/pl/plan/index.vue

@@ -183,6 +183,15 @@
             <el-icon><EditPen /></el-icon>强制改状态
           </el-button>
 
+          <ListExportDropdown
+            :export-fn="planApi.export"
+            :get-filter-params="getPlanExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="planExportColumns"
+            filename="计划列表.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -626,6 +635,7 @@ import PlPasteRecognizeDialog from '@/components/business/pl-paste-recognize-dia
 import PlPlanFormDialog from '@/components/business/pl-plan-form-dialog/index.vue'
 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 { usePersistedListColumns } from '@/composables/usePersistedListColumns'
@@ -761,6 +771,27 @@ function getPlanStatCellValue(row: Plan, prop: string): unknown {
 }
 
 const selectedRows = ref<Plan[]>([])
+function getPlanExportFilterParams() {
+  const keyword = quickSearch.value.trim()
+  return {
+    q: keyword || undefined,
+    planNo: keyword ? undefined : searchForm.planNo || undefined,
+    planType: tabFilters.planType || undefined,
+    followStatus: tabFilters.followStatus || searchForm.followStatus || undefined,
+    customerName: keyword ? undefined : searchForm.customerName || undefined,
+    productName: keyword ? undefined : searchForm.productName || undefined,
+    licensePlate: keyword ? undefined : searchForm.licensePlate || undefined,
+    planDateFrom: dateRange.value?.[0] || undefined,
+    planDateTo: dateRange.value?.[1] || undefined
+  }
+}
+
+const planExportColumns = computed(() => {
+  const cols = [...visibleColumns.value]
+  if (!cols.includes('planNo')) cols.unshift('planNo')
+  return cols
+})
+
 const viewingPlan = ref<Plan | null>(null)
 
 function syncViewingPlanFromList() {

+ 26 - 0
web-admin/src/views/pl/transport/index.vue

@@ -62,6 +62,15 @@
             <el-icon><Share /></el-icon>二维码
           </el-button>
 
+          <ListExportDropdown
+            :export-fn="transportApi.export"
+            :get-filter-params="getTransportExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="transportExportColumns"
+            filename="运输列表.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -250,6 +259,7 @@ import SystemViewDialog, {
 import { transportApi, type Transport } from '@/api/pl'
 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 { usePersistedListColumns } from '@/composables/usePersistedListColumns'
 import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
 import { formatPlVehiclePlateDisplay } from '@/utils/pl-display'
@@ -283,6 +293,22 @@ const detailLoading = ref(false)
 const qrcodeLoading = ref(false)
 const transportList = ref<Transport[]>([])
 const selectedRows = ref<Transport[]>([])
+function getTransportExportFilterParams() {
+  const keyword = quickSearchKeyword.value.trim()
+  return {
+    q: keyword || undefined,
+    transportNo: keyword ? undefined : searchForm.transportNo || undefined,
+    transportStatus: searchForm.transportStatus || undefined,
+    approvalStatus: searchForm.approvalStatus || undefined
+  }
+}
+
+const transportExportColumns = computed(() => {
+  const cols = [...visibleColumns.value]
+  if (!cols.includes('transportNo')) cols.unshift('transportNo')
+  return cols
+})
+
 const detailVisible = ref(false)
 const currentTransport = ref<Transport | null>(null)