diff --git a/backend/app/api/pl/base_data.py b/backend/app/api/pl/base_data.py index 205f89c30..856a40451 100644 --- a/backend/app/api/pl/base_data.py +++ b/backend/app/api/pl/base_data.py @@ -480,6 +480,96 @@ def list_vehicles( ) + +VEHICLE_EXPORT_COLUMNS = [ + ExportColumn(title="车牌号", field="licensePlate"), + ExportColumn(title="关联人员", field="personnelSummary"), + ExportColumn(title="车辆类型", field="vehicleTypeLabel"), + ExportColumn(title="车辆状态", field="vehicleStatusLabel"), + ExportColumn(title="载重(吨)", field="loadCapacity"), + ExportColumn(title="承运商", field="carrierName"), + ExportColumn(title="备注", field="remark"), +] + +_VEHICLE_TYPE_LABEL = {"self": "自有车", "carrier": "承运商车"} +_VEHICLE_STATUS_LABEL = {"1": "启用", "0": "停用", "idle": "空闲", "busy": "在途", "maintain": "维保"} + + +class VehicleExportBody(BaseModel): + q: Optional[str] = None + vehicle_type: Optional[str] = Field(default=None, alias="vehicleType") + carrier_id: Optional[str] = Field(default=None, alias="carrierId") + vehicle_status: Optional[str] = Field(default=None, alias="vehicleStatus") + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + + model_config = {"populate_by_name": True} + + +@router.post("/vehicle/export") +def export_vehicles( + body: VehicleExportBody, + 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(PlVehicle).filter( + PlVehicle.tenant_no == tenant_no, + PlVehicle.del_flag == 0, + ) + if body.vehicle_type: + query = query.filter(PlVehicle.vehicle_type == body.vehicle_type) + if body.carrier_id: + query = query.filter(PlVehicle.carrier_id == body.carrier_id) + if body.vehicle_status: + query = query.filter(PlVehicle.vehicle_status == body.vehicle_status) + if body.q: + query = query.filter(PlVehicle.license_plate.contains(body.q)) + if body.ids: + query = query.filter(PlVehicle.id.in_(body.ids)) + query = query.order_by( + case((PlVehicle.order_num.is_(None), 1), else_=0).asc(), + PlVehicle.create_time.desc(), + ) + vehicles = query.limit(EXPORT_MAX_ROWS + 1).all() + summaries = load_vehicle_personnel_summaries(db, tenant_no, [v.id for v in vehicles]) + items = [] + for v in vehicles: + data = _vehicle_to_read(v, *summaries.get(v.id, ("", None))).model_dump(by_alias=True) + vt = str(data.get("vehicleType") or "") + data["vehicleTypeLabel"] = _VEHICLE_TYPE_LABEL.get(vt, vt) + vs = str(data.get("vehicleStatus") or "") + data["vehicleStatusLabel"] = _VEHICLE_STATUS_LABEL.get(vs, vs) + items.append(data) + requested = None + if body.columns: + requested = [] + for key in body.columns: + if key in ("photoUrls", "certAttachments", "certList"): + continue + if key == "vehicleType": + requested.append("vehicleTypeLabel") + elif key == "vehicleStatus": + requested.append("vehicleStatusLabel") + else: + requested.append(key) + cols = filter_export_columns(VEHICLE_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("/vehicle", response_model=VehicleDetailRead, status_code=status.HTTP_201_CREATED) def create_vehicle( payload: VehicleCreate, @@ -766,6 +856,106 @@ def list_personnel( ) + +PERSONNEL_EXPORT_COLUMNS = [ + ExportColumn(title="姓名", field="personnelName"), + ExportColumn(title="人员归属", field="personnelAffiliationLabel"), + ExportColumn(title="人员类型", field="personnelTypeLabel"), + ExportColumn(title="手机号", field="phoneNo"), + ExportColumn(title="年龄", field="age"), + ExportColumn(title="身份证号", field="idCardNo"), + ExportColumn(title="状态", field="statusLabel"), + ExportColumn(title="最近到期", field="nearestExpireDate"), + ExportColumn(title="备注", field="remark"), +] + +_AFFILIATION_LABEL = {"self": "自有", "carrier": "承运商"} +_PERSONNEL_TYPE_LABEL = {"driver": "司机", "escort": "押运员"} + + +class PersonnelExportBody(BaseModel): + q: Optional[str] = None + status: Optional[str] = None + personnel_affiliation: Optional[str] = Field(default=None, alias="personnelAffiliation") + personnel_type: Optional[str] = Field(default=None, alias="personnelType") + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + + model_config = {"populate_by_name": True} + + +@router.post("/personnel/export") +def export_personnel( + body: PersonnelExportBody, + 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(PlPersonnel).filter( + PlPersonnel.tenant_no == tenant_no, + PlPersonnel.del_flag == 0, + ) + if body.status: + query = query.filter(PlPersonnel.status == body.status) + if body.personnel_affiliation: + query = query.filter(PlPersonnel.personnel_affiliation == body.personnel_affiliation) + if body.personnel_type: + query = query.filter(PlPersonnel.personnel_type == body.personnel_type) + if body.q: + query = query.filter( + or_( + func.lower(PlPersonnel.personnel_name).contains(body.q.lower()), + PlPersonnel.phone_no.contains(body.q), + ) + ) + if body.ids: + query = query.filter(PlPersonnel.id.in_(body.ids)) + query = query.order_by( + case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(), + PlPersonnel.create_time.desc(), + ) + rows = query.limit(EXPORT_MAX_ROWS + 1).all() + items = [] + for p in rows: + data = _personnel_to_read(db, tenant_no, p).model_dump(by_alias=True) + af = str(data.get("personnelAffiliation") or "") + data["personnelAffiliationLabel"] = _AFFILIATION_LABEL.get(af, af) + pt = str(data.get("personnelType") or "") + data["personnelTypeLabel"] = _PERSONNEL_TYPE_LABEL.get(pt, pt) + st = str(data.get("status") or "") + data["statusLabel"] = "启用" if st == "1" else ("停用" if st == "0" else st) + items.append(data) + requested = None + if body.columns: + requested = [] + for key in body.columns: + if key in ("photoUrls", "certAttachments", "certList"): + continue + if key == "personnelAffiliation": + requested.append("personnelAffiliationLabel") + elif key == "personnelType": + requested.append("personnelTypeLabel") + elif key == "status": + requested.append("statusLabel") + else: + requested.append(key) + cols = filter_export_columns(PERSONNEL_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("/personnel", response_model=PersonnelDetailRead, status_code=status.HTTP_201_CREATED) def create_personnel( payload: PersonnelCreate, @@ -973,6 +1163,64 @@ def list_drivers( ) + +DRIVER_EXPORT_COLUMNS = [ + ExportColumn(title="司机姓名", field="driverName"), + ExportColumn(title="手机号", field="phoneNo"), + ExportColumn(title="身份证号", field="idCardNo"), + ExportColumn(title="备注", field="remark"), +] + + +class DriverExportBody(BaseModel): + q: Optional[str] = None + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + + +@router.post("/driver/export") +def export_drivers( + body: DriverExportBody, + 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(PlPersonnel).filter( + PlPersonnel.tenant_no == tenant_no, + PlPersonnel.del_flag == 0, + ) + if body.q: + query = query.filter( + or_( + func.lower(PlPersonnel.personnel_name).contains(body.q.lower()), + PlPersonnel.phone_no.contains(body.q), + ) + ) + if body.ids: + query = query.filter(PlPersonnel.id.in_(body.ids)) + query = query.order_by( + case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(), + PlPersonnel.create_time.desc(), + ) + rows = query.limit(EXPORT_MAX_ROWS + 1).all() + items = [_personnel_to_driver_read(p).model_dump(by_alias=True) for p in rows] + cols = filter_export_columns(DRIVER_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("/driver", response_model=DriverRead, status_code=status.HTTP_201_CREATED) def create_driver( payload: DriverCreate, diff --git a/backend/app/api/pl/transport_address.py b/backend/app/api/pl/transport_address.py index 5f79f286a..d1a5781b3 100644 --- a/backend/app/api/pl/transport_address.py +++ b/backend/app/api/pl/transport_address.py @@ -1,10 +1,20 @@ """运输通用地址库 API""" -from typing import Optional +from datetime import datetime +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel 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 @@ -119,6 +129,82 @@ def list_all_transport_addresses( return TransportAddressListResponse(total=len(rows), list=[_address_to_read(row) for row in rows]) + +ADDRESS_EXPORT_COLUMNS = [ + ExportColumn(title="地址名称", field="addressName"), + ExportColumn(title="详细地址", field="fullAddress"), + ExportColumn(title="联系人", field="contactName"), + ExportColumn(title="联系电话", field="contactPhone"), + ExportColumn(title="状态", field="statusLabel"), + ExportColumn(title="备注", field="remark"), +] + + +class TransportAddressExportBody(BaseModel): + q: Optional[str] = None + status: Optional[str] = None + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + + +@router.post("/address/export") +def export_transport_addresses( + body: TransportAddressExportBody, + 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 = _build_address_query(db, tenant_no, user) + if body.status: + query = query.filter(PlTransportAddress.status == body.status) + if body.q: + keyword = body.q.strip().lower() + query = query.filter( + or_( + func.lower(PlTransportAddress.address_name).contains(keyword), + func.lower(PlTransportAddress.full_address).contains(keyword), + func.lower(PlTransportAddress.contact_name).contains(keyword), + PlTransportAddress.contact_phone.contains(body.q.strip()), + ) + ) + if body.ids: + query = query.filter(PlTransportAddress.id.in_(body.ids)) + query = query.order_by( + case((PlTransportAddress.order_num.is_(None), 1), else_=0).asc(), + PlTransportAddress.create_time.desc(), + ) + rows = query.limit(EXPORT_MAX_ROWS + 1).all() + items = [] + for row in rows: + data = _address_to_read(row).model_dump(by_alias=True) + st = str(data.get("status") or "") + data["statusLabel"] = "启用" if st == "1" else ("停用" if st == "0" else st) + items.append(data) + requested = None + if body.columns: + requested = [] + for key in body.columns: + if key == "status": + requested.append("statusLabel") + else: + requested.append(key) + cols = filter_export_columns(ADDRESS_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("/address", response_model=TransportAddressRead, status_code=status.HTTP_201_CREATED) def create_transport_address( payload: TransportAddressCreate, diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index 396594c1c..ebb341313 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -138,6 +138,8 @@ export interface VehicleQuery { export const vehicleApi = { list: (params?: VehicleQuery) => + 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), @@ -192,6 +194,8 @@ export interface PersonnelQuery { export const personnelApi = { list: (params?: PersonnelQuery) => + 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), @@ -293,6 +297,8 @@ export interface TransportAddressPayload { export const transportAddressApi = { list: (params?: TransportAddressQuery) => request.get>('/transport/address', { params }), + export: (data: Record) => + request.post('/transport/address/export', data, { responseType: 'blob' }), listAll: (params?: { q?: string }) => /** 选择器:仅按地址名称、详细地址模糊检索(不含联系人/电话) */ request.get>('/transport/address/all', { params }), @@ -348,6 +354,8 @@ function driverToPersonnelPayload(data: Partial): PersonnelPayload { } export const driverApi = { + export: (data: Record) => + request.post('/transport/driver/export', data, { responseType: 'blob' }), list: async (params?: DriverQuery) => { const res = await personnelApi.list({ q: params?.q, diff --git a/web-admin/src/views/pl/driver/index.vue b/web-admin/src/views/pl/driver/index.vue index a90d4be89..13fcca38e 100644 --- a/web-admin/src/views/pl/driver/index.vue +++ b/web-admin/src/views/pl/driver/index.vue @@ -57,6 +57,15 @@ 删除 + + ([]) + +function getDriverExportFilterParams() { + return { + q: quickSearchKeyword.value.trim() || searchForm.q || undefined + } +} + +const driverExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('driverName')) cols.unshift('driverName') + return cols +}) + const dialogVisible = ref(false) const dialogTitle = ref('') const formRef = ref() diff --git a/web-admin/src/views/pl/personnel/index.vue b/web-admin/src/views/pl/personnel/index.vue index 394be8281..20a0e73ef 100644 --- a/web-admin/src/views/pl/personnel/index.vue +++ b/web-admin/src/views/pl/personnel/index.vue @@ -70,6 +70,15 @@ 删除 + + ([]) const selectedRows = ref([]) + +function getPersonnelExportFilterParams() { + return { + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + status: searchForm.status || undefined, + personnelAffiliation: affiliationMode.value + } +} + +const personnelExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('personnelName')) cols.unshift('personnelName') + return cols +}) + const dialogVisible = ref(false) const pasteVisible = ref(false) const dialogTitle = ref('') diff --git a/web-admin/src/views/pl/self-vehicle/index.vue b/web-admin/src/views/pl/self-vehicle/index.vue index 207b8609b..0b6704095 100644 --- a/web-admin/src/views/pl/self-vehicle/index.vue +++ b/web-admin/src/views/pl/self-vehicle/index.vue @@ -81,6 +81,15 @@ 删除 + + 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 AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' @@ -584,6 +594,22 @@ const { handlePersonnelFormSaved } = usePlVehiclePersonnelQuickCreate({ onSelect: handlePersonnelSelect }) const selectedRows = ref([]) + +function getSelfVehicleExportFilterParams() { + return { + vehicleType: VEHICLE_TYPE_SELF, + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + vehicleStatus: searchForm.vehicleStatus || undefined, + carrierId: searchForm.carrierId || undefined + } +} + +const selfVehicleExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('licensePlate')) cols.unshift('licensePlate') + return cols +}) + const dialogVisible = ref(false) const dialogTitle = ref('') const formRef = ref() diff --git a/web-admin/src/views/pl/transport-address/index.vue b/web-admin/src/views/pl/transport-address/index.vue index 51dcbb00b..0ca912497 100644 --- a/web-admin/src/views/pl/transport-address/index.vue +++ b/web-admin/src/views/pl/transport-address/index.vue @@ -61,6 +61,15 @@ 删除 + + ([]) const selectedRows = ref([]) + +function getAddressExportFilterParams() { + return { + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + status: searchForm.status || undefined + } +} + +const addressExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('addressName')) cols.unshift('addressName') + return cols.filter((c) => c !== 'coordinates') +}) + const dialogVisible = ref(false) const dialogTitle = ref('') const formRef = ref() diff --git a/web-admin/src/views/pl/vehicle/index.vue b/web-admin/src/views/pl/vehicle/index.vue index be5555a0a..5a3187185 100644 --- a/web-admin/src/views/pl/vehicle/index.vue +++ b/web-admin/src/views/pl/vehicle/index.vue @@ -86,6 +86,15 @@ 删除 + + 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 AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' @@ -601,6 +611,22 @@ const { handlePersonnelFormSaved } = usePlVehiclePersonnelQuickCreate({ onSelect: handlePersonnelSelect }) const selectedRows = ref([]) + +function getVehicleExportFilterParams() { + return { + q: quickSearchKeyword.value.trim() || searchForm.q || undefined, + vehicleType: VEHICLE_TYPE_LOCK, + carrierId: searchForm.carrierId || undefined, + vehicleStatus: searchForm.vehicleStatus || undefined + } +} + +const vehicleExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('licensePlate')) cols.unshift('licensePlate') + return cols +}) + const dialogVisible = ref(false) const pasteVisible = ref(false) const dialogTitle = ref('')