Pārlūkot izejas kodu

feat(pl): add Excel export for vehicle maintenance apply list

Support filtered and selected rows via backend export and ListExportDropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 mēneši atpakaļ
vecāks
revīzija
441e1fdae1

+ 81 - 2
backend/app/api/pl/vehicle_maintenance.py

@@ -1,13 +1,22 @@
 """车辆维保 API"""
 from datetime import date
-from typing import Optional
+from datetime import datetime
+from typing import List, Optional
 
-from fastapi import APIRouter, Depends, Query, status
+from pydantic import BaseModel, Field
+from fastapi import APIRouter, Depends, HTTPException, Query, status
 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.schemas.pl.vehicle_maintenance import (
     VehicleMaintenanceApplyCreate,
     VehicleMaintenanceApplyListResponse,
@@ -74,6 +83,76 @@ def list_reminders(
     )
 
 
+
+MAINT_APPLY_EXPORT_COLUMNS = [
+    ExportColumn(title="申请单号", field="applyNo"),
+    ExportColumn(title="车牌号", field="licensePlate"),
+    ExportColumn(title="维保类型", field="maintenanceType"),
+    ExportColumn(title="申请日期", field="applyDate"),
+    ExportColumn(title="开始日期", field="startDate"),
+    ExportColumn(title="预计取车", field="expectedPickupDate"),
+    ExportColumn(title="申请人", field="applicantName"),
+    ExportColumn(title="状态", field="status"),
+    ExportColumn(title="创建时间", field="createTime"),
+]
+
+
+class MaintenanceApplyExportBody(BaseModel):
+    q: Optional[str] = None
+    maintenance_type: Optional[str] = Field(default=None, alias="maintenanceType")
+    status: Optional[str] = None
+    apply_date_from: Optional[date] = Field(default=None, alias="applyDateFrom")
+    apply_date_to: Optional[date] = Field(default=None, alias="applyDateTo")
+    ids: Optional[List[str]] = None
+    columns: Optional[List[str]] = None
+
+    model_config = {"populate_by_name": True}
+
+
+@apply_router.post("/export")
+def export_applies(
+    body: MaintenanceApplyExportBody,
+    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())
+        items, _ = svc.list_applies(
+            db,
+            tenant_no,
+            user,
+            q=body.q,
+            maintenance_type=body.maintenance_type,
+            status=body.status,
+            apply_date_from=body.apply_date_from,
+            apply_date_to=body.apply_date_to,
+            ids=body.ids,
+            page=1,
+            page_size=EXPORT_MAX_ROWS,
+            max_page_size=EXPORT_MAX_ROWS,
+        )
+        rows = []
+        for item in items:
+            data = item.model_dump(by_alias=True)
+            for k in ("applyDate", "startDate", "expectedPickupDate", "createTime"):
+                if data.get(k) is not None:
+                    data[k] = str(data[k])[:19]
+            rows.append(data)
+        cols = filter_export_columns(MAINT_APPLY_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
+
+
 @apply_router.get("/{id}", response_model=VehicleMaintenanceApplyRead)
 def get_apply(
     id: str,

+ 7 - 0
backend/app/services/pl_vehicle_maintenance_service.py

@@ -268,8 +268,10 @@ def list_applies(
     apply_date_from: Optional[date] = None,
     apply_date_to: Optional[date] = None,
     applicant_id: Optional[str] = None,
+    ids: Optional[list[str]] = None,
     page: int = 1,
     page_size: int = 20,
+    max_page_size: Optional[int] = None,
 ) -> tuple[list[VehicleMaintenanceApplyRead], int]:
     query = db.query(PlVehicleMaintenanceApply).filter(
         PlVehicleMaintenanceApply.tenant_no == tenant_no,
@@ -294,9 +296,14 @@ def list_applies(
         query = query.filter(PlVehicleMaintenanceApply.apply_date <= apply_date_to)
     if applicant_id:
         query = query.filter(PlVehicleMaintenanceApply.applicant_id == applicant_id)
+    if ids:
+        query = query.filter(PlVehicleMaintenanceApply.id.in_(ids))
 
     query = apply_pl_scope(query, PlVehicleMaintenanceApply, 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(PlVehicleMaintenanceApply.create_time.desc())
         .offset((page - 1) * page_size)

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

@@ -1530,6 +1530,8 @@ export interface VehicleMaintenanceProcessBoardListResponse {
 
 export const vehicleMaintenanceApplyApi = {
   list: (params?: Record<string, unknown>) =>
+  export: (data: Record<string, any>) =>
+    request.post('/transport/vehicle-maintenance-apply/export', data, { responseType: 'blob' }),
     request.get<VehicleMaintenanceApplyListResponse>('/transport/vehicle-maintenance-apply/', {
       params
     }),

+ 27 - 0
web-admin/src/views/pl/vehicle-maintenance-apply/index.vue

@@ -86,6 +86,15 @@
             <el-icon><CircleClose /></el-icon>作废
           </el-button>
 
+          <ListExportDropdown
+            :export-fn="vehicleMaintenanceApplyApi.export"
+            :get-filter-params="getMaintApplyExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="maintApplyExportColumns"
+            filename="维保申请.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -370,6 +379,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'
@@ -472,6 +482,23 @@ const submitLoading = ref(false)
 const cancelLoadingMap = reactive<Record<string, boolean>>({})
 const tableData = ref<VehicleMaintenanceApply[]>([])
 const selectedRows = ref<VehicleMaintenanceApply[]>([])
+
+function getMaintApplyExportFilterParams() {
+  return {
+    q: quickSearchKeyword.value.trim() || searchForm.q || undefined,
+    maintenanceType: searchForm.maintenanceType || undefined,
+    status: searchForm.status || undefined,
+    applyDateFrom: dateRange.value?.[0],
+    applyDateTo: dateRange.value?.[1]
+  }
+}
+
+const maintApplyExportColumns = computed(() => {
+  const cols = [...visibleColumns.value]
+  if (!cols.includes('applyNo')) cols.unshift('applyNo')
+  return cols
+})
+
 const vehicleOptions = ref<Vehicle[]>([])
 const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value))
 const dateRange = ref<string[]>([])