Files
admin-web/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue
T

84 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, RotateCcw } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn, type DeliveryOrderRow } from '../api/deliveryOrders'
import { updateDeliveryOrderStatus } from '../api/customerVisits'
import type { ExceptionRelation } from '../api/exceptionOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<DeliveryOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const warehousesLoading = ref(false)
const filters = ref<QueryModel>({ documentNo: '', dispatchNo: '', waybillNo: '', warehouseId: '', status: '', vehiclePlate: '', driverName: '', deliveryMethod: '', plannedAt: [] })
const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '待派送', value: 'WAIT_DELIVERY' }, { label: '派送中', value: 'DELIVERING' }, { label: '派送完成', value: 'DELIVERED' }, { label: '已签收(历史)', value: 'SIGNED' }, { label: '已拒签', value: 'REJECTED' }, { label: '退货处理中', value: 'RETURN_PENDING' }, { label: '已取消', value: 'CANCELLED' }] },
{ key: 'documentNo', type: 'input', label: '派送单号', width: 190, placeholder: '请输入派送单号' },
{ key: 'dispatchNo', type: 'input', label: '排车单号', width: 190, placeholder: '请输入排车单号' },
{ key: 'waybillNo', type: 'input', label: '运单号', width: 180, placeholder: '请输入运单号' },
{ key: 'warehouseId', type: 'select', label: '海外仓', width: 220, placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, loading: warehousesLoading.value } },
{ key: 'deliveryMethod', type: 'select', label: '派送方式', width: 170, placeholder: '全部方式', options: [{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' }, { label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' }, { label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' }, { label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' }] },
{ key: 'vehiclePlate', type: 'input', label: '车牌号', width: 160, placeholder: '请输入车牌号' },
{ key: 'driverName', type: 'input', label: '司机', width: 180, placeholder: '请输入司机或车队' },
{ key: 'plannedAt', type: 'daterange', label: '预计派送时间', width: 280 },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 180, fixed: 'left' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
{ key: 'overseasWarehouseName', prop: 'overseasWarehouseName', label: '仓库', minWidth: 150 },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170 },
{ key: 'waybillNo', prop: 'waybillNo', label: '运单号', minWidth: 150 },
{ key: 'status', prop: 'status', label: '订单状态', width: 110, slotName: 'status' },
{ key: 'deliveryMethod', prop: 'deliveryMethod', label: '派送方式', width: 120, formatter: (row: DeliveryOrderRow) => deliveryMethodLabel(row.deliveryMethod) },
{ key: 'carrierName', prop: 'carrierName', label: '派送人', minWidth: 150 },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌', minWidth: 120 },
{ key: 'driverName', prop: 'driverName', label: '司机/车队', minWidth: 140 },
{ key: 'plannedDeliveryAt', prop: 'plannedDeliveryAt', label: '期望派送时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.plannedDeliveryAt) },
{ key: 'dispatchedAt', prop: 'dispatchedAt', label: '派送时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.dispatchedAt) },
{ key: 'pickupMethod', prop: 'pickupMethod', label: '提货方式', width: 110, formatter: (row: DeliveryOrderRow) => pickupMethodLabel(row.pickupMethod) },
{ key: 'loadingType', prop: 'loadingType', label: '装车类型', width: 110, formatter: (row: DeliveryOrderRow) => loadingTypeLabel(row.loadingType) },
{ key: 'packageCount', prop: 'packageCount', label: '总箱数', width: 90, align: 'right' },
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 120, align: 'right' },
{ key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right' },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到达时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.expectedArrivalAt) },
{ key: 'signedAt', prop: 'signedAt', label: '签收时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.signedAt) },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 160 },
]
const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', SIGNED: '已签收', REJECTED: '已拒签', RETURN_PENDING: '退货处理中', CANCELLED: '已取消' }
function deliveryMethodLabel(value?: string | null) { return ({ FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' } as Record<string, string>)[String(value)] || value || '—' }
function pickupMethodLabel(value?: string | null) { return ({ ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' } as Record<string, string>)[String(value)] || value || '—' }
function loadingTypeLabel(value?: string | null) { return ({ PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' } as Record<string, string>)[String(value)] || value || '—' }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const range = Array.isArray(filters.value.plannedAt) ? filters.value.plannedAt.map(String) : []; const result = await listDeliveryOrders({ page: page.value, size: pageSize.value, documentNo: text('documentNo'), dispatchNo: text('dispatchNo'), waybillNo: text('waybillNo'), warehouseId: text('warehouseId'), status: text('status'), vehiclePlate: text('vehiclePlate'), driverName: text('driverName'), deliveryMethod: text('deliveryMethod'), plannedFrom: range[0] ? `${range[0]}T00:00:00` : undefined, plannedTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records; total.value = result.total } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '派送单列表加载失败') } finally { loading.value = false } }
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
function open(row: DeliveryOrderRow) { void router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: row.deliveryNo }, query: { customerId: row.customerId } }) }
const returnOpen = ref(false); const returnLoading = ref(false); const returnSaving = ref(false); const returnRow = ref<DeliveryOrderRow | null>(null); const returnOptions = ref<ExceptionRelation[]>([])
const returnForm = reactive({ inboundOrderId: '', reason: '', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: '', packageCount: undefined as number | undefined, skuCode: '' })
async function openReturn(row: DeliveryOrderRow) { returnRow.value = row; Object.assign(returnForm, { inboundOrderId: '', reason: '', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: '', packageCount: undefined, skuCode: '' }); returnOptions.value = []; returnOpen.value = true; returnLoading.value = true; try { returnOptions.value = await listDeliveryReturnInboundOptions(row.deliveryNo, row.customerId); if (returnOptions.value.length === 1) returnForm.inboundOrderId = String(returnOptions.value[0].inboundOrderId || '') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '退货入仓单加载失败').message) } finally { returnLoading.value = false } }
async function submitReturn() { if (!returnRow.value || !returnForm.inboundOrderId || !returnForm.reason.trim()) { ElMessage.warning('请选择入仓单并填写退货原因'); return }; returnSaving.value = true; try { const result = await markDeliveryReturn(returnRow.value.deliveryNo, returnRow.value.customerId, { inboundOrderId: returnForm.inboundOrderId, reason: returnForm.reason.trim(), returnDirection: returnForm.returnDirection, returnLogisticsNo: returnForm.returnLogisticsNo.trim() || undefined, packageCount: returnForm.packageCount, skuCode: returnForm.skuCode.trim() || undefined }); returnOpen.value = false; ElMessage.success(`已生成异常退货单 ${result.exceptionNo}`); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '标记退货失败').message) } finally { returnSaving.value = false } }
const statusOpen = ref(false); const statusSaving = ref(false); const statusRow = ref<DeliveryOrderRow | null>(null); const statusForm = reactive({ status: '', remark: '' })
function openStatus(row: DeliveryOrderRow) { statusRow.value = row; Object.assign(statusForm, { status: row.status, remark: '' }); statusOpen.value = true }
async function submitStatus() { if (!statusRow.value || !statusForm.status) return; statusSaving.value = true; try { const result = await updateDeliveryOrderStatus(statusRow.value.deliveryNo, statusRow.value.customerId, { status: statusForm.status, remark: statusForm.remark.trim() || undefined }); statusOpen.value = false; ElMessage.success(result.deliveryStatus === 'DELIVERED' ? '派送已完成,待回访记录已生成' : '派送状态已更新'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '派送状态更新失败').message) } finally { statusSaving.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
async function loadWarehouses() { if (!access.can(permission.warehouseList)) return; warehousesLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } finally { warehousesLoading.value = false } }
onMounted(() => { void load(); void loadWarehouses() })
</script>
<template><AdminPage><ListPageLayout aria-label="派送单列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template><template v-if="error" #state><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()"><template #actions="{ row }"><el-button size="small" type="primary" plain @click="open(row as DeliveryOrderRow)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(permission.deliveryStatus) && row.status !== 'RETURN_PENDING'" size="small" plain @click="openStatus(row as DeliveryOrderRow)"><Pencil :size="15" />编辑状态</el-button><el-button v-if="access.can(permission.deliveryReturn) && !['RETURN_PENDING','CANCELLED'].includes(String(row.status))" size="small" type="warning" plain @click="openReturn(row as DeliveryOrderRow)"><RotateCcw :size="15" />标记退货</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog v-model="statusOpen" title="编辑派送状态" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving" @confirm="submitStatus"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="statusRow?.deliveryNo" readonly /></el-form-item><el-form-item label="派送状态" required><el-select v-model="statusForm.status" placeholder="请选择派送状态"><el-option label="待派送" value="WAIT_DELIVERY" /><el-option label="派送中" value="DELIVERING" /><el-option label="派送完成" value="DELIVERED" /><el-option label="已拒签" value="REJECTED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入状态备注(选填)" /></el-form-item><el-alert v-if="statusForm.status === 'DELIVERED'" title="保存为派送完成后,将自动生成一条待回访记录。" type="info" :closable="false" /></el-form></AppDialog><AppDialog v-model="returnOpen" title="标记退货" width="min(720px, calc(100vw - 28px))" confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving" @confirm="submitReturn"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="returnRow?.deliveryNo" readonly /></el-form-item><el-form-item label="对应入仓单" required><el-select v-model="returnForm.inboundOrderId" placeholder="请选择该派送单对应的入仓单"><el-option v-for="option in returnOptions" :key="String(option.inboundOrderId)" :value="String(option.inboundOrderId)" :label="`${option.inboundCode} · ${option.lastMileOrderNo || '无尾程单号'}`" /></el-select></el-form-item><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入退货原因" /></el-form-item><div class="return-grid"><el-form-item label="退货去向" required><el-select v-model="returnForm.returnDirection" placeholder="请选择退货去向"><el-option label="退回海外仓" value="OVERSEAS_WAREHOUSE" /><el-option label="退回客户" value="CUSTOMER" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item><el-form-item label="箱数"><el-input-number v-model="returnForm.packageCount" :min="1" placeholder="请输入退货箱数(选填)" /></el-form-item><el-form-item label="SKU(可自由填写)"><el-input v-model="returnForm.skuCode" placeholder="请输入SKU(选填)" /></el-form-item></div><el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" /></el-form></AppDialog></template>
<style scoped>.return-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }.return-grid :deep(.el-input),.return-grid :deep(.el-select),.return-grid :deep(.el-input-number){width:100%} @media (max-width: 720px) { .return-grid { grid-template-columns: 1fr; } }</style>