Просмотр исходного кода

feat(pl): add Excel export for carrier list

Add backend /transport/carrier/export and wire ListExportDropdown on the carrier page.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
85c96f0a5d

+ 105 - 1
backend/app/api/pl/base_data.py

@@ -1,11 +1,20 @@
 """运输计划管理 - 基础数据 API(承运商、车辆、人员)"""
 from datetime import datetime
-from typing import Optional
+from typing import List, Optional
 
 from fastapi import APIRouter, Depends, HTTPException, Query, status
+from pydantic import BaseModel, Field
 from sqlalchemy import case, func, or_
 from sqlalchemy.orm import Session
 
+from app.utils.excel_export import (
+    EXPORT_MAX_ROWS,
+    ExportColumn,
+    ExportValidationError,
+    export_rows_to_xlsx_response,
+    filter_export_columns,
+)
+
 from app.core.database import get_db
 from app.core.db_auth import DbUserContext, get_db_current_user
 from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
@@ -114,6 +123,101 @@ def list_carriers(
     )
 
 
+
+
+CARRIER_EXPORT_COLUMNS = [
+    ExportColumn(title="承运商名称", field="carrierName"),
+    ExportColumn(title="联系人", field="contactName"),
+    ExportColumn(title="联系电话", field="contactPhone"),
+    ExportColumn(title="合作状态", field="statusLabel"),
+    ExportColumn(title="资质提示", field="qualificationHint"),
+    ExportColumn(title="协议有效期", field="transportAgreementExpireDate"),
+    ExportColumn(title="备注", field="remark"),
+]
+
+
+class CarrierExportBody(BaseModel):
+    q: Optional[str] = None
+    status: Optional[str] = None
+    audit_status: Optional[str] = Field(default=None, alias="auditStatus")
+    ids: Optional[List[str]] = None
+    columns: Optional[List[str]] = None
+
+    model_config = {"populate_by_name": True}
+
+
+@router.post("/carrier/export")
+def export_carriers(
+    body: CarrierExportBody,
+    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(PlCarrier).filter(
+            PlCarrier.tenant_no == tenant_no,
+            PlCarrier.del_flag == 0,
+        )
+        if body.status:
+            query = query.filter(PlCarrier.status == body.status)
+        if body.audit_status:
+            query = query.filter(PlCarrier.audit_status == body.audit_status)
+        if body.q:
+            query = query.filter(
+                or_(
+                    func.lower(PlCarrier.carrier_name).contains(body.q.lower()),
+                    func.lower(PlCarrier.contact_name).contains(body.q.lower()),
+                )
+            )
+        if body.ids:
+            query = query.filter(PlCarrier.id.in_(body.ids))
+        query = query.order_by(
+            case((PlCarrier.order_num.is_(None), 1), else_=0).asc(),
+            PlCarrier.create_time.desc(),
+        )
+        rows = query.limit(EXPORT_MAX_ROWS + 1).all()
+        items = []
+        for row in rows:
+            data = carrier_to_read(row).model_dump(by_alias=True)
+            data["statusLabel"] = "合作中" if str(data.get("status")) == "1" else "已终止"
+            if data.get("transportAgreementExpired"):
+                data["qualificationHint"] = "协议已过期"
+            elif data.get("qualificationComplete") is False:
+                data["qualificationHint"] = "资质不全"
+            else:
+                data["qualificationHint"] = "资质齐全"
+            items.append(data)
+        requested = None
+        if body.columns:
+            requested = []
+            for key in body.columns:
+                if key in (
+                    "qualificationAttachments",
+                    "qualificationAttachment",
+                    "photoUrls",
+                ):
+                    continue
+                if key == "status":
+                    requested.append("statusLabel")
+                elif key == "qualificationHint":
+                    requested.append("qualificationHint")
+                else:
+                    requested.append(key)
+        cols = filter_export_columns(CARRIER_EXPORT_COLUMNS, requested)
+        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("/carrier", response_model=CarrierRead, status_code=status.HTTP_201_CREATED)
 def create_carrier(
     payload: CarrierCreate,

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

@@ -46,6 +46,8 @@ export interface CarrierQuery {
 export const carrierApi = {
   list: (params?: CarrierQuery) =>
     request.get<PlListResponse<Carrier>>('/transport/carrier', { params }),
+  export: (data: Record<string, any>) =>
+    request.post('/transport/carrier/export', data, { responseType: 'blob' }),
   get: (id: string) => request.get<Carrier>(`/transport/carrier/${id}`),
   create: (data: Partial<Carrier>) => request.post<Carrier>('/transport/carrier', data),
   update: (id: string, data: Partial<Carrier>) => request.put<Carrier>(`/transport/carrier/${id}`, data),

+ 25 - 0
web-admin/src/views/pl/carrier/index.vue

@@ -63,6 +63,15 @@
             <el-icon><Delete /></el-icon>删除
           </el-button>
 
+          <ListExportDropdown
+            :export-fn="carrierApi.export"
+            :get-filter-params="getCarrierExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="carrierExportColumns"
+            filename="承运商列表.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -320,6 +329,7 @@ import AttachmentUploadField from '@/components/business/attachment-upload-field
 import PlCarrierListAttachmentCell from '@/components/business/pl-carrier-list-attachment-cell/index.vue'
 import PlCarrierViewDialog from '@/components/business/pl-carrier-view-dialog/index.vue'
 import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
+import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
 import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
 import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
 import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
@@ -352,6 +362,21 @@ const {
 const submitLoading = ref(false)
 const carrierList = ref<Carrier[]>([])
 const selectedRows = ref<Carrier[]>([])
+
+function getCarrierExportFilterParams() {
+  const quick = quickSearchKeyword.value?.trim()
+  return {
+    q: quick || searchForm.q || undefined,
+    status: searchForm.status || undefined
+  }
+}
+
+const carrierExportColumns = computed(() => {
+  const cols = [...visibleColumns.value]
+  if (!cols.includes('carrierName')) cols.unshift('carrierName')
+  return cols
+})
+
 const dialogVisible = ref(false)
 const dialogTitle = ref('')
 const formRef = ref()