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>
This commit is contained in:
zlwuandCursor committed 2026-08-21 17:59:04 +08:00
1 parent b06fb6ac56
commit 6a89452f1a
7 files changed
+339 -5

No files matched your search

+126 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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),
@@ -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() {
@@ -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
View File
@@ -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() {
@@ -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)