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

222 lines
28 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 '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from '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 LineClampText from '@/shared/components/LineClampText.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 { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn, type DeliveryOrderRow, type DeliveryReturnRelation, type DeliveryReturnSource, type MarkDeliveryReturnPayload } from '../api/deliveryOrders'
import { updateDeliveryOrderStatus } from '../api/customerVisits'
import { overseasWarehousePermissions as permission } from '../permissions'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import DeliveryDocumentsDialog from '../components/DeliveryDocumentsDialog.vue'
import { deliveryDocumentCategories } from '../api/deliveryDocuments'
import { returnSourceKey, returnSourceReference } from '../returnSource'
const documentsOpen = ref(false)
const documentsRow = ref<DeliveryOrderRow | null>(null)
function openDocuments(row: DeliveryOrderRow) { if (!access.can(permission.deliveryList)) return; documentsRow.value = row; documentsOpen.value = true }
function openOutbound(row: DeliveryOrderRow) {
if (!row.outboundNo || !access.can(permission.outboundList)) return
void router.push({ name: 'overseas-outbounds', query: { documentNo: row.outboundNo } })
}
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: '普通自提', value: 'PICKUP' }, { label: '亚马逊自提', value: 'AMAZON_PICKUP' }, { label: '平台卡车', value: 'TRUCK' }, { 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: 230, fixed: 'left' },
{ key: 'documents', label: '所需资料', minWidth: 380, slotName: 'documents' },
{ key: 'bolNo', prop: 'bolNo', label: 'BOL号', minWidth: 170 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
{ key: 'destinationWarehouse', label: '目的 FBA 仓', minWidth: 180, formatter: (row: DeliveryOrderRow) => destinationWarehouseLabel(row) },
{ 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 ({ PICKUP: '普通自提', AMAZON_PICKUP: '亚马逊自提', TRUCK: '平台卡车', 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 || '—' }
function destinationWarehouseLabel(row: DeliveryOrderRow) {
const code = row.destinationWarehouseCode?.trim()
const name = row.destinationWarehouseName?.trim()
return code && name && code !== name ? `${code} · ${name}` : code || name || '—'
}
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<DeliveryReturnRelation[]>([])
const returnError = ref<ApiErrorDetails | null>(null)
const returnForm = reactive({ lastMileOrderId: '', reason: '', returnDirection: 'OVERSEAS_WAREHOUSE' as MarkDeliveryReturnPayload['returnDirection'], returnLogisticsNo: '' })
const returnItems = ref<Array<DeliveryReturnSource & { selected: boolean; packageCount: number | undefined }>>([])
let returnSequence = 0
let returnAttempt: { body: string; key: string } | null = null
function canReturn(row: DeliveryOrderRow) { return access.can(permission.deliveryReturn) && row.allowedActions?.includes('MARK_RETURN') }
watch(() => returnForm.lastMileOrderId, () => {
returnItems.value = (returnOptions.value.find(option => option.lastMileOrderId === returnForm.lastMileOrderId)?.returnSources || []).map(source => ({ ...source, selected: false, packageCount: source.returnCartonId ? 1 : undefined }))
})
function returnRelationLabel(option: DeliveryReturnRelation) {
return `${option.lastMileOrderNo || '—'} · ${option.inboundCode || option.inboundOrderId ? `入仓码 ${option.inboundCode || '—'}` : '无普通入仓关联'}`
}
watch(returnOpen, visible => { if (!visible) returnSequence++ })
async function openReturn(row: DeliveryOrderRow) {
if (!canReturn(row) || returnSaving.value) return
const sequence = ++returnSequence
returnRow.value = row; Object.assign(returnForm, { lastMileOrderId: '', reason: '', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: '' })
returnOptions.value = []; returnItems.value = []; returnError.value = null; returnAttempt = null; returnOpen.value = true; returnLoading.value = true
try {
const options = await listDeliveryReturnInboundOptions(row.deliveryNo, row.customerId)
if (sequence !== returnSequence || !returnOpen.value) return
returnOptions.value = options
if (options.length === 1) returnForm.lastMileOrderId = options[0]!.lastMileOrderId
} catch (cause) { if (sequence === returnSequence) returnError.value = getApiErrorDetails(cause, '退回来源主单加载失败') }
finally { if (sequence === returnSequence) returnLoading.value = false }
}
async function submitReturn() {
if (!returnOpen.value || !returnRow.value || !canReturn(returnRow.value) || returnLoading.value || returnSaving.value) return
const relation = returnOptions.value.find(option => option.lastMileOrderId === returnForm.lastMileOrderId)
if (!relation || !returnForm.reason.trim()) { ElMessage.warning('请选择尾程主单并填写退货原因'); return }
const selected = returnItems.value.filter(item => item.selected)
if (!selected.length || selected.some(item => !Number.isSafeInteger(item.packageCount) || !item.packageCount || item.packageCount < 1 || item.packageCount > item.maxReturnPackages)) { ElMessage.warning('请选择原出库明细,并填写不超过可退上限的整数箱数'); return }
if (selected.some(item => !relation.returnSources.some(source => returnSourceKey(source) === returnSourceKey(item) && item.packageCount! <= source.maxReturnPackages))) { ElMessage.warning('所选来源已不属于当前尾程主单,请重新选择原出库明细'); return }
if (selected.some(item => item.returnCartonId && (item.packageCount !== 1 || item.maxReturnPackages !== 1))) { ElMessage.warning('原退货箱必须按原箱整箱退回,请重新核对来源'); return }
let items: MarkDeliveryReturnPayload['items']
try { items = selected.map(item => ({ outboundPalletItemId: item.outboundPalletItemId, ...returnSourceReference(item), packageCount: item.packageCount! })) }
catch { ElMessage.warning('退回来源标识不完整或冲突,请重新加载原出库明细'); return }
const body = { lastMileOrderId: relation.lastMileOrderId, reason: returnForm.reason.trim(), returnDirection: returnForm.returnDirection, returnLogisticsNo: returnForm.returnLogisticsNo.trim() || undefined, items }
const serialized = JSON.stringify(body)
if (returnAttempt?.body !== serialized) returnAttempt = { body: serialized, key: crypto.randomUUID() }
returnSaving.value = true; returnError.value = null
try {
const result = await markDeliveryReturn(returnRow.value.deliveryNo, returnRow.value.customerId, { ...body, idempotencyKey: returnAttempt.key })
returnOpen.value = false; ElMessage.success(`已生成异常退货单 ${result.exceptionNo}`); await load()
} catch (cause) { returnError.value = getApiErrorDetails(cause, '标记退货失败') }
finally { returnSaving.value = false }
}
const statusOpen = ref(false)
const statusSaving = ref(false)
const statusLoading = ref(false)
const statusError = ref<ApiErrorDetails | null>(null)
const statusConflict = ref(false)
const statusRow = ref<DeliveryOrderRow | null>(null)
const statusForm = reactive({ status: '', remark: '' })
let statusSequence = 0
const statusTargets = [
{ action: 'START_DELIVERY', value: 'DELIVERING', label: '派送中' },
{ action: 'REJECT_DELIVERY', value: 'REJECTED', label: '已拒签' },
]
function availableStatuses(row: DeliveryOrderRow) { return statusTargets.filter(option => row.allowedActions?.includes(option.action)) }
const statusOptions = computed(() => statusRow.value ? availableStatuses(statusRow.value) : [])
async function refreshStatus() {
const row = statusRow.value
if (!row || statusSaving.value) return
const sequence = ++statusSequence
statusLoading.value = true; statusError.value = null
try {
const latest = await getDeliveryOrder(row.deliveryNo, row.customerId)
if (sequence !== statusSequence || !statusOpen.value) return
if (!Number.isSafeInteger(latest.order.version) || latest.order.version < 0) throw new Error('派送单缺少有效版本,请刷新后重试')
statusRow.value = latest.order
if (!availableStatuses(latest.order).some(option => option.value === statusForm.status)) statusForm.status = ''
statusConflict.value = false
} catch (cause) { if (sequence === statusSequence) { statusError.value = getApiErrorDetails(cause, '派送单最新状态加载失败'); statusConflict.value = true } }
finally { if (sequence === statusSequence) statusLoading.value = false }
}
async function openStatus(row: DeliveryOrderRow) {
if (!access.can(permission.deliveryStatus) || statusSaving.value || !availableStatuses(row).length) return
statusRow.value = row; Object.assign(statusForm, { status: '', remark: '' }); statusConflict.value = true; statusOpen.value = true
await refreshStatus()
}
async function submitStatus() {
if (!statusOpen.value || !statusRow.value || statusLoading.value || statusSaving.value || statusConflict.value || !access.can(permission.deliveryStatus) || !statusOptions.value.some(option => option.value === statusForm.status)) return
statusSaving.value = true; statusError.value = null
try {
await updateDeliveryOrderStatus(statusRow.value.deliveryNo, statusRow.value.customerId, { status: statusForm.status, expectedVersion: statusRow.value.version, remark: statusForm.remark.trim() || undefined })
statusOpen.value = false
ElMessage.success('派送状态已更新')
await load()
} catch (cause) {
const details = getApiErrorDetails(cause, '派送状态更新失败')
statusConflict.value = details.code === 'A0409'
statusError.value = statusConflict.value ? { ...details, message: '派送单已被更新,请刷新最新状态并重新确认后提交。' } : details
} 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 class="admin-density"><ListPageLayout aria-label="派送单列表"><section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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 #toolbar-start><h1>派送单列表</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="error"><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as DeliveryOrderRow)">详情</el-button><el-button v-if="row.outboundNo && access.can(permission.outboundList)" class="density-action" data-tone="neutral" size="small" @click="openOutbound(row as DeliveryOrderRow)">前往出库</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryStatus) && availableStatuses(row as DeliveryOrderRow).length > 0" size="small" @click="openStatus(row as DeliveryOrderRow)">编辑状态</el-button><el-button class="density-action" data-tone="warning" v-if="canReturn(row as DeliveryOrderRow)" size="small" @click="openReturn(row as DeliveryOrderRow)">标记退货</el-button></div></template><template #documents="{ row }"><div class="delivery-document-summary"><div><strong>{{ row.documentSummary ? `${row.documentSummary.uploadedCategoryCount}/3 已上传` : '资料摘要暂无' }}</strong><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryList)" size="small" @click="openDocuments(row as DeliveryOrderRow)">管理资料</el-button></div><div class="delivery-document-categories"><span v-for="category in deliveryDocumentCategories" :key="category.category" :class="{ uploaded: row.documentSummary?.categories.some((item: { category: string; status: string }) => item.category === category.category && item.status === 'UPLOADED') }">{{ category.label }} · {{ row.documentSummary?.categories.find((item: { category: string }) => item.category === category.category)?.status === 'UPLOADED' ? '已上传' : '未上传' }}</span></div></div></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="statusOpen" :title="'编辑派送状态 · ' + (statusRow?.deliveryNo || '—')" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving || statusLoading" :loading="statusLoading" :confirm-disabled="!statusForm.status || statusConflict || statusLoading || !access.can(permission.deliveryStatus)" @confirm="submitStatus">
<div class="density-dialog-sections">
<el-alert title="取消派送请先解除未执行车次关联,再前往关联出库单取消出库;已出库或已发车不能直接取消。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="source" aria-label="派送状态依据"><h3 class="density-dialog-section__title">当前资料</h3><table class="density-table"><tbody><tr><th scope="row">当前状态</th><td>{{ statusLabels[String(statusRow?.status)] || '—' }}</td></tr><tr><th scope="row">关联出库</th><td>{{ statusRow?.outboundNo || '—' }}</td></tr></tbody></table></section>
<el-alert v-if="statusRow && !statusLoading && !statusError && !statusOptions.length" title="当前状态已无可操作的派送状态,请核对最新资料。" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="派送状态登记"><h3 class="density-dialog-section__title">状态登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="statusLoading || statusSaving || statusConflict || !statusOptions.length || !access.can(permission.deliveryStatus)"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="派送状态" required><el-select v-model="statusForm.status" placeholder="请选择派送状态"><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入状态备注(选填)" /></el-form-item></td></tr></tbody></table></el-form></section>
<LoadErrorState v-if="statusError" compact title="派送状态操作失败" :message="statusError.message" :loading="statusLoading || statusSaving" :show-retry="statusConflict" @retry="refreshStatus"><template #details><ApiErrorMeta :details="statusError" /></template></LoadErrorState>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="returnOpen" :title="'标记退货 · ' + (returnRow?.deliveryNo || '—')" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving || returnLoading" :confirm-disabled="returnLoading || !returnRow || !canReturn(returnRow)" @confirm="submitReturn">
<div class="density-dialog-sections">
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="returnLoading || returnSaving || !returnRow || !canReturn(returnRow)">
<section class="density-dialog-section" data-tone="work" aria-label="退货登记"><h3 class="density-dialog-section__title">退货资料</h3><table class="density-table density-table--editable delivery-return-editor"><tbody>
<tr><td colspan="2"><el-form-item label="对应尾程主单" required><el-select v-model="returnForm.lastMileOrderId" placeholder="请选择该派送单对应的尾程主单"><el-option v-for="option in returnOptions" :key="option.lastMileOrderId" :value="option.lastMileOrderId" :label="returnRelationLabel(option)" /></el-select></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入退货原因" /></el-form-item></td></tr>
<tr><td><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></td><td><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item></td></tr>
</tbody></table></section>
<section class="density-dialog-section" data-tone="source" aria-label="原出库可退明细"><h3 class="density-dialog-section__title">原出库明细</h3><p v-if="!returnItems.length" class="density-dialog-note">暂无可退原出库明细</p>
<table v-else class="density-table density-table--editable delivery-return-sources"><thead><tr><th class="delivery-return-select">操作</th><th>原出库来源与可退上限</th><th class="delivery-return-quantity">退货箱数</th></tr></thead><tbody><tr v-for="item in returnItems" :key="returnSourceKey(item)" class="return-source"><td><el-checkbox v-model="item.selected" :aria-label="'选择 ' + (item.palletNo || item.cartonMark || '原出库来源')" :disabled="returnLoading || returnSaving || !returnRow || !canReturn(returnRow) || item.maxReturnPackages < 1">选择</el-checkbox></td><td><LineClampText :content="(item.palletNo || '—') + ' · ' + (item.returnCartonId ? '原退货箱 ' + (item.cartonMark || '—') + '(整箱退回)' : item.skuCode || '—') + '(最多 ' + item.maxReturnPackages + ' 箱)'" /></td><td><el-input-number v-model="item.packageCount" placeholder="请输入退货箱数" :disabled="!item.selected || returnLoading || returnSaving || !returnRow || !canReturn(returnRow) || Boolean(item.returnCartonId)" :min="1" :max="item.maxReturnPackages" :precision="0" controls-position="right" :aria-label="'退货箱数 ' + (item.palletNo || '—') + ' ' + (item.skuCode || '—')" /></td></tr></tbody></table>
</section>
<LoadErrorState v-if="returnError" compact title="标记退货失败" :message="returnError.message" :loading="returnLoading || returnSaving" :show-retry="!returnOptions.length" @retry="returnRow && openReturn(returnRow)"><template #details><ApiErrorMeta :details="returnError" /></template></LoadErrorState>
<el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" show-icon />
</el-form>
</div>
</AppDialog><DeliveryDocumentsDialog v-if="documentsRow" v-model="documentsOpen" :delivery-no="documentsRow.deliveryNo" :customer-id="documentsRow.customerId" @saved="load()" /></template>
<style scoped>.delivery-document-summary{display:grid;gap:var(--admin-space-3);padding:var(--admin-space-2) 0}.delivery-document-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.delivery-document-categories{display:flex;flex-wrap:wrap;gap:var(--admin-space-2) var(--admin-space-5);font-size:var(--admin-font-caption);color:var(--cc-muted)}.delivery-document-categories .uploaded{color:var(--el-color-success)}.delivery-return-editor { table-layout: fixed; }.delivery-return-editor td:not([colspan]) { width: 50%; }.delivery-return-sources { table-layout: fixed; }.delivery-return-select { width: 68px; }.delivery-return-quantity { width: 160px; } @media (max-width: 720px) { .delivery-return-editor, .delivery-return-editor tbody { display: block; }.delivery-return-editor tr { display: grid; grid-template-columns: 1fr; }.delivery-return-editor td:not([colspan]) { width: auto; } }</style>