From 6a89452f1ab80994b2c09cf39cf3037caae49dc1 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 21 Aug 2026 17:59:04 +0800 Subject: [PATCH] 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 --- backend/app/api/pl/plan.py | 128 +++++++++++++++++- backend/app/api/pl/transport.py | 88 +++++++++++- web-admin/src/api/pl/index.ts | 8 +- web-admin/src/views/pl/plan-archive/index.vue | 32 +++++ web-admin/src/views/pl/plan-cert/index.vue | 31 +++++ web-admin/src/views/pl/plan/index.vue | 31 +++++ web-admin/src/views/pl/transport/index.vue | 26 ++++ 7 files changed, 339 insertions(+), 5 deletions(-) diff --git a/backend/app/api/pl/plan.py b/backend/app/api/pl/plan.py index 6d6063db1..c446c05f4 100644 --- a/backend/app/api/pl/plan.py +++ b/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,逗号分隔"), diff --git a/backend/app/api/pl/transport.py b/backend/app/api/pl/transport.py index 7cd8d9eba..cbf406039 100644 --- a/backend/app/api/pl/transport.py +++ b/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, diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index ebb341313..234f58d19 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -138,9 +138,9 @@ export interface VehicleQuery { export const vehicleApi = { list: (params?: VehicleQuery) => + request.get>('/transport/vehicle', { params }), export: (data: Record) => request.post('/transport/vehicle/export', data, { responseType: 'blob' }), - request.get>('/transport/vehicle', { params }), get: (id: string) => request.get(`/transport/vehicle/${id}`), create: (data: VehiclePayload) => request.post('/transport/vehicle', data), update: (id: string, data: Partial) => @@ -194,9 +194,9 @@ export interface PersonnelQuery { export const personnelApi = { list: (params?: PersonnelQuery) => + request.get>('/transport/personnel', { params }), export: (data: Record) => request.post('/transport/personnel/export', data, { responseType: 'blob' }), - request.get>('/transport/personnel', { params }), get: (id: string) => request.get(`/transport/personnel/${id}`), create: (data: PersonnelPayload) => request.post('/transport/personnel', data), update: (id: string, data: Partial) => @@ -696,6 +696,8 @@ export interface PlanContractGapQuery { export const planApi = { list: (params?: PlanQuery) => request.get('/transport/plan/', { params }), + export: (data: Record) => + request.post('/transport/plan/export', data, { responseType: 'blob' }), get: (id: string) => request.get(`/transport/plan/${id}`), countByContract: (contractIds: string[]) => request.get<{ counts: Record }>('/transport/plan/count-by-contract', { @@ -1002,6 +1004,8 @@ export interface TransportListResponse { export const transportApi = { list: (params?: TransportQuery) => request.get('/transport/order/', { params }), + export: (data: Record) => + request.post('/transport/order/export', data, { responseType: 'blob' }), get: (id: string) => request.get(`/transport/order/${id}`), create: (data: Partial & { planIds?: string[] }) => request.post('/transport/order/', data), diff --git a/web-admin/src/views/pl/plan-archive/index.vue b/web-admin/src/views/pl/plan-archive/index.vue index bfcd98b3a..900ce192c 100644 --- a/web-admin/src/views/pl/plan-archive/index.vue +++ b/web-admin/src/views/pl/plan-archive/index.vue @@ -95,6 +95,15 @@ {{ selectedPlan ? archiveButtonLabel(selectedPlan) : '归档' }} + + 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([]) +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(null) function syncViewingPlanFromList() { diff --git a/web-admin/src/views/pl/plan-cert/index.vue b/web-admin/src/views/pl/plan-cert/index.vue index 210d303e2..bb021e2f4 100644 --- a/web-admin/src/views/pl/plan-cert/index.vue +++ b/web-admin/src/views/pl/plan-cert/index.vue @@ -135,6 +135,15 @@ 取消齐备 + + ([]) +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(null) const selectedPlan = computed(() => (selectedRows.value.length === 1 ? selectedRows.value[0] : null)) const dateRange = ref([]) diff --git a/web-admin/src/views/pl/plan/index.vue b/web-admin/src/views/pl/plan/index.vue index c99955d06..eafc4e08c 100644 --- a/web-admin/src/views/pl/plan/index.vue +++ b/web-admin/src/views/pl/plan/index.vue @@ -183,6 +183,15 @@ 强制改状态 + + ([]) +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(null) function syncViewingPlanFromList() { diff --git a/web-admin/src/views/pl/transport/index.vue b/web-admin/src/views/pl/transport/index.vue index 467e20760..b365e467b 100644 --- a/web-admin/src/views/pl/transport/index.vue +++ b/web-admin/src/views/pl/transport/index.vue @@ -62,6 +62,15 @@ 二维码 + + ([]) const selectedRows = ref([]) +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(null)