387 lines
36 KiB
Vue
387 lines
36 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { Plus } from '@lucide/vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { getPlatformWarehouse } from '@/modules/warehouses'
|
||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||
import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
|
||
import { relabelApplicationStatusLabel, relabelBusinessStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
|
||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||
import { businessOrderLink } from '../businessOrderLink'
|
||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||
import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue'
|
||
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
|
||
import WarehouseTransferReceiptPanel from '../components/WarehouseTransferReceiptPanel.vue'
|
||
|
||
interface DetailField { label: string; value: string | number; wide?: boolean }
|
||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||
interface DocumentRow { status?: string; quantity?: number; type: string; number: string; createdAt: string; quote: string }
|
||
interface FeeRow { id: string; item: string; currency: string; amount: string; status: string; description: string }
|
||
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string; sortAt: string }
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const access = useAccessPreviewStore()
|
||
const orderId = computed(() => String(route.params.orderId || ''))
|
||
const detail = ref<LastMileOrderDetailDto | null>(null)
|
||
const receiptSummary = ref<{ boxes: number; forecastBoxes: number; forecastSkus: number; forecastQuantity: number | null; skus: number; quantity: number; pallets: number; weight: number; volume: number; warehouseId?: string | null } | null>(null)
|
||
const resolvedWarehouseName = ref('')
|
||
const warehouseLookupFailed = ref(false)
|
||
const warehouseDisplayName = computed(() => detail.value?.order.overseasWarehouseName || resolvedWarehouseName.value || detail.value?.order.overseasWarehouseCode || (warehouseLookupFailed.value ? '仓库名称加载失败' : '—'))
|
||
watch(() => [detail.value?.order.overseasWarehouseId || receiptSummary.value?.warehouseId, detail.value?.order.overseasWarehouseName] as const, async ([warehouseId, warehouseName], _, onCleanup) => {
|
||
let current = true
|
||
onCleanup(() => { current = false })
|
||
resolvedWarehouseName.value = ''; warehouseLookupFailed.value = false
|
||
if (!warehouseId || warehouseName) return
|
||
try {
|
||
const result = await getPlatformWarehouse(String(warehouseId))
|
||
if (current) resolvedWarehouseName.value = result.warehouse.warehouseName || result.warehouse.warehouseCode
|
||
} catch { if (current) warehouseLookupFailed.value = true }
|
||
})
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||
const activeTab = ref('basic')
|
||
const feeDialogOpen = ref(false)
|
||
const supplierOptions = ref<Supplier[]>([])
|
||
const feeForm = reactive({ itemCode: '', itemName: '', direction: 'RECEIVABLE' as 'RECEIVABLE' | 'PAYABLE', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined as number | undefined, quantityUnit: '', periodStart: '', periodEnd: '' })
|
||
const feeItemOptions = [{ code: 'STORAGE_FEE', name: '仓租' }, { code: 'INBOUND_OPERATION', name: '入库操作费' }, { code: 'OUTBOUND_OPERATION', name: '出库操作费' }, { code: 'RELABEL_FEE', name: '换标费' }, { code: 'REBOX_FEE', name: '换箱费' }, { code: 'PALLET_FEE', name: '托盘费' }, { code: 'DELIVERY_FEE', name: '派送费' }, { code: 'TRANSFER_FEE', name: '转仓费' }, { code: 'EXCEPTION_FEE', name: '异常处理费' }, { code: 'OTHER', name: '其他' }]
|
||
|
||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||
function containerNodeLabel(value: unknown) {
|
||
const code = String(value ?? '').trim()
|
||
return ({
|
||
DEPARTED_WAREHOUSE: '已离开国内仓库', AT_ORIGIN_PORT: '重箱已进入起运港',
|
||
LOADED_ON_VESSEL: '已装船', DEPARTED_ORIGIN_PORT: '船舶已离开起运港', IN_TRANSIT: '海运途中',
|
||
ARRIVED_DESTINATION_PORT: '船舶已到目的港', DISCHARGED_FROM_VESSEL: '已卸船、未清关',
|
||
PICKUP_READY: '已清关、可提柜', PICKED_UP: '已提柜', EMPTY_RETURNED: '已还柜',
|
||
} as Record<string, string>)[code] || text(code)
|
||
}
|
||
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
|
||
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
|
||
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
|
||
|
||
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
|
||
const isReturnOrder = computed(() => detail.value?.order.orderType === 'RETURN_RELABEL')
|
||
const returnReceiptUnitLabel = computed(() => detail.value?.returnReceipt?.receipts.some(receipt => receipt.unitType === 'PARCEL') ? '包裹' : '箱')
|
||
const signedReturnCartons = computed(() => (detail.value?.returnReceipt?.cartons || []).map(box => {
|
||
const parcel = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && receipt.unitType === 'PARCEL')
|
||
return { ...box, length: box.length ?? parcel?.length, width: box.width ?? parcel?.width, height: box.height ?? parcel?.height, weight: box.weight ?? parcel?.weight }
|
||
}))
|
||
function returnCartonVolume(box: { length?: number | null; width?: number | null; height?: number | null }) {
|
||
if (box.length == null || box.width == null || box.height == null) return '—'
|
||
const volume = box.length * box.width * box.height / 1_000_000
|
||
return Number.isFinite(volume) && volume >= 0 ? volume.toFixed(6).replace(/\.?0+$/, '') : '—'
|
||
}
|
||
const showReturnForecast = computed(() => isReturnOrder.value && (detail.value?.returnReceipt?.order.status
|
||
? detail.value.returnReceipt.order.status !== 'COMPLETED'
|
||
: ['PENDING_REVIEW', 'PENDING_RECEIPT', 'PARTIALLY_RECEIVED', 'REJECTED', 'CANCELLED'].includes(detail.value?.order.currentStatus || '')))
|
||
const orderFields = computed<DetailField[]>(() => {
|
||
const order = detail.value?.order
|
||
const fields: DetailField[] = [
|
||
{ label: '履约类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
|
||
{ label: '入仓码', value: text(order?.inboundCode) },
|
||
{ label: '货主', value: text(order?.customerName) },
|
||
{ label: '海外仓', value: warehouseDisplayName.value },
|
||
{ label: '转仓目的仓', value: text(order?.transferWarehouseName) },
|
||
{ label: '目的仓', value: text(order?.fbaWarehouseCode || order?.transferWarehouseName) },
|
||
{ label: '是否暂扣', value: yesNo(order?.detained) },
|
||
{ label: '是否异常', value: yesNo(order?.abnormal) },
|
||
{ label: '异常原因', wide: true, value: order?.abnormal ? text(order.subOrderStatus) : '—' },
|
||
{ label: '仓库异常作业备注', wide: true, value: text(order?.warehouseRemark) },
|
||
{ label: '备注(客户)', wide: true, value: text(order?.customerRemark) },
|
||
]
|
||
if (order?.orderType === 'WAREHOUSE_TRANSFER') fields.splice(6, 0,
|
||
{ label: '客户预报箱数', value: order.packageCount == null ? '—' : order.packageCount },
|
||
{ label: '实收箱数', value: order.receivedPackageCount == null ? '—' : order.receivedPackageCount },
|
||
{ label: '箱数差异', value: packageDifferenceText(order.packageDifference) },
|
||
)
|
||
return fields
|
||
})
|
||
function isNonzeroDifference(field: DetailField) { return field.label === '箱数差异' && field.value !== '—' && field.value !== '0' }
|
||
const containerFields = computed<DetailField[]>(() => {
|
||
const order = detail.value?.order
|
||
return [
|
||
{ label: '运输渠道', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
|
||
{ label: '集装箱号', value: text(order?.containerNo) },
|
||
{ label: '托数', value: Number(order?.palletCount || 0) },
|
||
{ label: '箱数', value: Number(order?.packageCount || 0) },
|
||
{ label: '货物毛重(kg)', value: Number(order?.grossWeightKg || 0) },
|
||
{ label: '货物净重(kg)', value: Number(order?.netWeightKg || 0) },
|
||
{ label: '体积(m³)', value: Number(order?.volumeCbm || 0) },
|
||
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
|
||
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
|
||
{ label: '预计到仓时间', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
|
||
{ label: '货柜节点', value: containerNodeLabel(order?.containerNode) },
|
||
].filter(field => !isReturnOrder.value || field.label !== '箱数')
|
||
})
|
||
const commercialLink = computed(() => businessOrderLink(detail.value?.order.businessSource?.businessType, detail.value?.order.businessSource?.businessOrderNo, access.can))
|
||
const basicFields = computed(() => [...orderFields.value, ...containerFields.value])
|
||
const overviewFields = computed(() => {
|
||
const order = detail.value?.order
|
||
const warehouseTransfer = order?.orderType === 'WAREHOUSE_TRANSFER'
|
||
const hasReceivedPackages = warehouseTransfer && order?.receivedPackageCount != null
|
||
if (warehouseTransfer && receiptSummary.value) {
|
||
const summary = receiptSummary.value
|
||
return [
|
||
{ label: '客户', value: text(order?.customerName) },
|
||
{ label: '海外仓', value: warehouseDisplayName.value },
|
||
{ label: '预报箱数', value: `${summary.forecastBoxes} 箱` },
|
||
{ label: '预报 SKU', value: `${summary.forecastSkus} 种` },
|
||
{ label: '预报 SKU 总件数', value: summary.forecastQuantity == null ? '未记录' : `${summary.forecastQuantity} 件` },
|
||
{ label: '实收箱数', value: `${summary.boxes} 箱` },
|
||
{ label: '实收 SKU', value: `${summary.skus} 种` },
|
||
{ label: '实收件数', value: `${summary.quantity} 件` },
|
||
{ label: '总托数', value: `${summary.pallets} 托` },
|
||
{ label: '总重量', value: `${summary.weight.toFixed(3)} kg` },
|
||
{ label: '总体积', value: `${summary.volume.toFixed(6)} m³` },
|
||
{ label: '最近更新', value: formatOverseasDateTime(order?.updatedAt) },
|
||
]
|
||
}
|
||
return [
|
||
{ label: '客户', value: text(order?.customerName) },
|
||
{ label: '海外仓', value: warehouseDisplayName.value },
|
||
...(isReturnOrder.value ? [{ label: showReturnForecast.value ? '预报总件数' : '实收总件数', value: `${showReturnForecast.value ? detail.value?.returnReceipt?.order.expectedQuantity ?? order?.productQuantity ?? 0 : detail.value?.returnReceipt?.order.receivedQuantity ?? order?.productQuantity ?? 0} 件` }]
|
||
: [{ label: warehouseTransfer ? (hasReceivedPackages ? '实收总箱数' : '客户预报总箱数') : '总箱数', value: (hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount) == null ? '—' : `${hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount} 箱` }]),
|
||
{ label: '总托数', value: order?.palletCount == null ? '—' : order.palletCount + ' 托' },
|
||
{ label: '货物毛重', value: order?.grossWeightKg == null ? '—' : order.grossWeightKg + ' kg' },
|
||
{ label: '货物净重', value: order?.netWeightKg == null ? '—' : order.netWeightKg + ' kg' },
|
||
{ label: '体积', value: order?.volumeCbm == null ? '—' : order.volumeCbm + ' m³' },
|
||
{ label: '最近更新', value: formatOverseasDateTime(order?.updatedAt) },
|
||
]
|
||
})
|
||
const planFields = computed<DetailField[]>(() => {
|
||
const order = detail.value?.order
|
||
return [
|
||
{ label: '入仓码', value: text(order?.inboundCode) },
|
||
{ label: '计划入库仓', value: text(order?.transferWarehouseName || order?.overseasWarehouseName) },
|
||
{ label: '合托批次', value: text(order?.palletMergeFlag) },
|
||
{ label: '计划托数', value: Number(order?.palletCount || 0) },
|
||
{ label: '入库单号', value: text(order?.inboundOrderNo) },
|
||
{ label: '换标子单', value: order?.relabelApplications?.length ? `${order.relabelApplications.length} 张(详见关联单据)` : relabelBusinessStatusLabel(order?.relabelStatus) },
|
||
]
|
||
})
|
||
const documents = computed<DocumentRow[]>(() => {
|
||
const order = detail.value?.order
|
||
if (!order) return []
|
||
return [['入库单', order.inboundOrderNo], ['出库单(只读关联)', order.outboundOrderNo], ['派送单(只读关联)', order.deliveryOrderNo], ['换标单', order.relabelApplications?.length ? undefined : order.relabelOrderNo]]
|
||
.filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' } as DocumentRow))
|
||
.concat((order.relabelApplications || []).map(application => ({ type: '换标单', number: application.relabelNo, createdAt: formatOverseasDateTime(application.createdAt), quote: '—', status: relabelApplicationStatusLabel(application.status), quantity: application.quantity })))
|
||
})
|
||
const cargo = computed<CargoRow[]>(() => isReturnOrder.value
|
||
? (detail.value?.returnReceipt?.items || []).map(item => ({ id: item.skuCode, sku: item.skuCode, productName: text(item.productNameZh),
|
||
quantity: item.expectedQuantity, pallets: 0, cartons: 0, weight: 0, volume: 0 }))
|
||
: (detail.value?.cargo || []).map((item) => ({
|
||
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
|
||
cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0),
|
||
})))
|
||
const fees = computed<FeeRow[]>(() => (detail.value?.fees || []).map((item) => ({
|
||
id: String(item.id), item: item.feeItemName, currency: item.originalCurrency, amount: `${item.originalCurrency} ${Number(item.originalAmount).toFixed(2)}`,
|
||
status: feeStatusLabel(item.reviewStatus === 'PENDING' ? 'PENDING_REVIEW' : item.reviewStatus), description: text(item.supplierName || (item.chargeableQuantity == null ? null : `${item.chargeableQuantity} ${item.quantityUnit || ''}`)),
|
||
})))
|
||
const logs = computed<LogRow[]>(() => {
|
||
const statusRows = (detail.value?.statusHistory || []).map((item) => ({
|
||
id: `status-${item.id}`, occurredAt: formatOverseasDateTime(item.createdAt), sortAt: item.createdAt || '', node: lastMileStatusLabel(item.toStatus), operator: '系统',
|
||
result: text(item.remark || `${lastMileStatusLabel(item.fromStatus)} → ${lastMileStatusLabel(item.toStatus)}`),
|
||
}))
|
||
const operationRows = (detail.value?.operationHistory || []).map((item) => ({
|
||
id: `operation-${item.id}`, occurredAt: formatOverseasDateTime(item.createdAt), sortAt: item.createdAt || '', node: item.operationNode, operator: text(item.operatorName), result: text(item.result),
|
||
}))
|
||
return [...statusRows, ...operationRows].sort((left, right) => right.sortAt.localeCompare(left.sortAt))
|
||
})
|
||
|
||
const cargoColumns = computed<DataTableColumn[]>(() => isReturnOrder.value ? [
|
||
{ key: 'sku', prop: 'sku', label: 'SKU', minWidth: 150 },
|
||
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
|
||
{ key: 'quantity', prop: 'quantity', label: '预报件数', width: 120, align: 'right' },
|
||
] : [
|
||
{ key: 'sku', prop: 'sku', label: 'SKU', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
|
||
{ key: 'pallets', prop: 'pallets', label: '托数', width: 100, align: 'right' }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 100, align: 'right' },
|
||
{ key: 'quantity', prop: 'quantity', label: '件数', width: 100, align: 'right' }, { key: 'weight', prop: 'weight', label: '重量(kg)', width: 120, align: 'right' },
|
||
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 120, align: 'right' },
|
||
])
|
||
const documentColumns: DataTableColumn[] = [
|
||
{ key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(箱/件)', minWidth: 150, formatter: (row: { quantity?: number; quantityUnit?: string }) => `${row.quantity ?? '—'}${row.quantityUnit || '件'}` },
|
||
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
|
||
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
|
||
]
|
||
const feeColumns: DataTableColumn[] = [
|
||
{ key: 'item', prop: 'item', label: '费用项目', minWidth: 180 }, { key: 'currency', prop: 'currency', label: '币种', width: 120 },
|
||
{ key: 'amount', prop: 'amount', label: '金额', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 140 },
|
||
{ key: 'description', prop: 'description', label: '供应商/说明', minWidth: 260 },
|
||
]
|
||
const logColumns: DataTableColumn[] = [
|
||
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 180 }, { key: 'node', prop: 'node', label: '操作节点', minWidth: 180 },
|
||
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 150 }, { key: 'result', prop: 'result', label: '处理结果', minWidth: 320 },
|
||
]
|
||
|
||
async function load() {
|
||
if (!orderId.value) { loadError.value = { message: '详情缺少尾程订单号' }; return }
|
||
loading.value = true; loadError.value = null
|
||
try { detail.value = await getLastMileOrder(orderId.value) }
|
||
catch (error) { loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌' ? { message: error.message } : getApiErrorDetails(error, '尾程订单详情加载失败,请重试') }
|
||
finally { loading.value = false }
|
||
}
|
||
async function openFeeDialog() {
|
||
Object.assign(feeForm, { itemCode: '', itemName: '', direction: 'RECEIVABLE', currency: 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined, quantityUnit: '', periodStart: '', periodEnd: '' })
|
||
mutationError.value = null; feeDialogOpen.value = true
|
||
if (access.can('platform:supplier:list') && supplierOptions.value.length === 0) {
|
||
try { supplierOptions.value = (await listSuppliers({ page: 1, size: 200, status: 'ACTIVE' })).records.filter((item) => item.status === 'ACTIVE') }
|
||
catch (error) { mutationError.value = getApiErrorDetails(error, '供应商列表加载失败') }
|
||
}
|
||
}
|
||
async function saveFee() {
|
||
if (!feeForm.itemCode || !feeForm.itemName.trim() || feeForm.amount <= 0 || !feeForm.businessDate) { ElMessage.warning('请填写费用项目、业务日期和大于 0 的金额'); return }
|
||
const supplier = supplierOptions.value.find((item) => String(item.id) === feeForm.supplierId)
|
||
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
|
||
saving.value = true; mutationError.value = null
|
||
try {
|
||
await createLastMileOrderFee(orderId.value, { feeItemCode: feeForm.itemCode, feeItemName: feeForm.itemName.trim(), direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: supplier ? 'ON_ACCOUNT' : undefined, chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined, periodStart: feeForm.periodStart || undefined, periodEnd: feeForm.periodEnd || undefined })
|
||
feeDialogOpen.value = false; ElMessage.success('费用已提交财务审核'); await load(); activeTab.value = 'fees'
|
||
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用新增失败,请重试') }
|
||
finally { saving.value = false }
|
||
}
|
||
function back() { void router.push({ name: 'overseas-last-mile-orders' }) }
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<template>
|
||
<OverseasDetailShell class="admin-density" dense title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
|
||
<template #reference><OrderReferenceText :value="referenceValue" copyable compact /></template>
|
||
<template #overview>
|
||
<dl v-if="detail" class="density-detail-overview" aria-label="尾程订单概览">
|
||
<div v-for="field in overviewFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div>
|
||
</dl>
|
||
</template>
|
||
<OperationErrorToast :details="mutationError" />
|
||
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
|
||
<el-tabs v-model="activeTab" class="detail-tabs density-element-tabs density-contained-tabs">
|
||
<el-tab-pane name="basic"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>基本信息</span></template>
|
||
<section aria-label="商业来源与关联单据">
|
||
<h2 class="source-heading">关联单据</h2>
|
||
<dl class="detail-fields density-fields">
|
||
<div class="wide"><dt>业务来源</dt><dd>{{ text(detail?.order.businessSource?.label) }}</dd></div>
|
||
<div class="wide"><dt>关联商业单号</dt><dd><el-button v-if="commercialLink" link type="primary" @click="router.push(commercialLink)">{{ detail?.order.businessSource?.businessOrderNo }}</el-button><span v-else>{{ text(detail?.order.businessSource?.businessOrderNo) }}</span></dd></div>
|
||
<div class="wide"><dt>外接预订号</dt><dd>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div>
|
||
<div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div>
|
||
</dl>
|
||
</section>
|
||
<dl class="detail-fields density-fields" aria-label="基本信息">
|
||
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
|
||
</dl>
|
||
</el-tab-pane>
|
||
<el-tab-pane v-if="access.can(permission.relabelOrderList) && (detail?.order.relabelApplications?.length || detail?.order.relabelOrderNo)" name="relabel-files" label="贴标文件">
|
||
<template v-if="activeTab === 'relabel-files'">
|
||
<RelabelDocumentsPanel v-for="application in detail?.order.relabelApplications || []" :key="application.relabelNo" :relabel-no="application.relabelNo" />
|
||
<RelabelDocumentsPanel v-if="!detail?.order.relabelApplications?.length && detail?.order.relabelOrderNo" :relabel-no="detail.order.relabelOrderNo" />
|
||
</template>
|
||
</el-tab-pane>
|
||
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><WarehouseTransferReceiptPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER' && activeTab === 'cargo'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" @summary="receiptSummary = $event" /><DataTable v-else-if="detail?.order.orderType !== 'WAREHOUSE_TRANSFER'" :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'"></DataTable></section></el-tab-pane>
|
||
<el-tab-pane v-if="detail?.order.orderType === 'RETURN_RELABEL'" name="return-receipt"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>退货签收与箱内清点</span></template>
|
||
<section v-if="detail.returnReceipt" class="table-panel" aria-label="退货签收与箱内清点">
|
||
<h3>{{ detail.returnReceipt.order.returnNo }} · 实收 {{ detail.returnReceipt.order.receivedQuantity }} / 预报 {{ detail.returnReceipt.order.expectedQuantity }} 件</h3>
|
||
<el-table :data="detail.returnReceipt.receipts" size="small" border>
|
||
<el-table-column prop="receiptNo" label="签收单号" min-width="190" />
|
||
<el-table-column label="签收方式" width="90"><template #default="{ row }">{{ row.unitType === 'PALLET' ? '托盘' : row.unitType === 'BOX' ? '箱子' : '包裹' }}</template></el-table-column>
|
||
<el-table-column prop="unitCode" label="签收载体" min-width="220" />
|
||
<el-table-column prop="boxCount" label="实收箱数" width="100" />
|
||
<el-table-column prop="receivedQuantity" label="实收数量" width="100" />
|
||
</el-table>
|
||
<h3>签收{{ returnReceiptUnitLabel }}明细({{ detail.returnReceipt.cartons?.length || 0 }} {{ returnReceiptUnitLabel }})</h3>
|
||
<el-collapse v-if="detail.returnReceipt.cartons?.length">
|
||
<el-collapse-item v-for="box in signedReturnCartons" :key="box.cartonMark" :name="box.cartonMark">
|
||
<template #title><span class="return-carton-title"><span>{{ box.cartonMark }} · {{ box.countStatus === 'CONFIRMED' ? `已确认 · ${box.items.length} 种 SKU · ${box.items.reduce((sum, item) => sum + Number(item.quantity), 0)} 件` : box.countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</span><span class="return-carton-measurements">长 {{ box.length ?? '—' }} cm · 宽 {{ box.width ?? '—' }} cm · 高 {{ box.height ?? '—' }} cm · 体积 {{ returnCartonVolume(box) }} m³ · 重量 {{ box.weight ?? '—' }} kg</span></span></template>
|
||
<p>签收单 {{ box.receiptNo }}<template v-if="box.palletCode"> · 托盘 {{ box.palletCode }}</template><template v-if="box.countedAt"> · 确认时间 {{ formatOverseasDateTime(box.countedAt) }}</template></p>
|
||
<p v-if="box.length != null || box.width != null || box.height != null || box.weight != null">测量:{{ box.length ?? '—' }} × {{ box.width ?? '—' }} × {{ box.height ?? '—' }} cm · 重量 {{ box.weight ?? '—' }} kg</p>
|
||
<el-table v-if="box.countStatus === 'CONFIRMED'" :data="box.items" size="small" border>
|
||
<el-table-column prop="skuCode" label="SKU" min-width="220" />
|
||
<el-table-column prop="productNameZh" label="中文品名" min-width="180" />
|
||
<el-table-column prop="quantity" label="实际签收数量" width="130" />
|
||
</el-table>
|
||
<p v-else>{{ box.countStatus === 'PENDING' ? '箱内数量待仓库清点确认' : '未留存原始箱内明细,不以当前库存代替签收数量' }}</p>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
<el-empty v-else description="暂无签收箱号明细" />
|
||
<h3>合箱记录</h3>
|
||
<el-table :data="detail.returnReceipt.packingDetails || []" size="small" border empty-text="暂无合箱记录">
|
||
<el-table-column prop="planNo" label="合箱计划" min-width="210" />
|
||
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
|
||
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
|
||
<el-table-column prop="skuCode" label="SKU" min-width="180" />
|
||
<el-table-column prop="quantity" label="本单合入数量" width="130" />
|
||
</el-table>
|
||
<h3>当前关联实物箱</h3>
|
||
<el-collapse v-if="detail.returnReceipt.currentCartons?.length">
|
||
<el-collapse-item v-for="current in detail.returnReceipt.currentCartons" :key="current.carton.cartonMark" :name="`current-${current.carton.cartonMark}`">
|
||
<template #title>{{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}</template>
|
||
<p>来源单:{{ current.carton.sourceReturnNo }} · 状态:{{ current.carton.status }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</p>
|
||
<el-table :data="current.ownership" size="small" border>
|
||
<el-table-column prop="returnNo" label="来源退货单" min-width="200" />
|
||
<el-table-column prop="skuCode" label="SKU" min-width="190" />
|
||
<el-table-column prop="quantity" label="来源贡献数量" width="130" />
|
||
</el-table>
|
||
<p>{{ current.canRelabel ? '后续贴标按此当前箱唛选择整箱;跨来源单同 SKU 的部分贴标须先现场分拣确认。' : current.blockReason }}</p>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
<el-empty v-else description="暂无当前关联实物箱" />
|
||
</section>
|
||
<el-empty v-else description="暂无退货签收记录" />
|
||
</el-tab-pane>
|
||
<el-tab-pane name="plan" lazy><template #label><span class="density-tab-icon" aria-hidden="true">🗓️</span><span>计划信息</span></template>
|
||
<WarehouseTransferFulfillmentPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER'" :customer-id="detail.order.customerId" :transfer-no="detail.order.sourceOrderNo || ''" />
|
||
<section class="info-panel plan-panel" aria-label="计划信息"><dl class="detail-fields plan-fields density-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
|
||
<section class="table-panel related-panel"><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据"><template #toolbar-start><h2>关联单据</h2></template></DataTable></section>
|
||
</el-tab-pane>
|
||
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template><section class="table-panel"><DataTable :data="fees" :columns="feeColumns" column-settings-mode="hide" row-key="id" empty-text="暂无费用明细"><template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" />新增费用</el-button></template></DataTable></section></el-tab-pane>
|
||
<el-tab-pane name="logs"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>操作记录</span></template><section class="table-panel"><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录"></DataTable></section></el-tab-pane>
|
||
</el-tabs>
|
||
</section>
|
||
<AppDialog class="admin-density-dialog" v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
|
||
<el-form class="fee-form density-form" label-position="top"><div class="fee-form-grid">
|
||
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
|
||
<el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item>
|
||
<el-form-item v-if="feeForm.direction === 'PAYABLE'" label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
|
||
<el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||
<el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||
<el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item>
|
||
<el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item>
|
||
<el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item>
|
||
</div></el-form>
|
||
</AppDialog>
|
||
</OverseasDetailShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.source-heading { margin: 0; padding: var(--admin-space-3); font-size: var(--admin-font-section); font-weight: 600; }
|
||
.return-carton-title { display:flex; align-items:center; flex-wrap:wrap; gap:8px 16px; padding:4px 0; line-height:1.5; }
|
||
.return-carton-measurements { color:var(--cc-text-secondary); font-weight:400; }
|
||
.detail-fields > div.wide { grid-column: span 2; }
|
||
.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
|
||
.plan-panel { margin-bottom: var(--admin-space-5); }
|
||
.related-panel { margin-top: var(--admin-space-5); }
|
||
.fee-form { min-width: 0; overflow-x: hidden; }
|
||
.fee-form-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); }
|
||
.fee-form-full { grid-column: 1 / -1; }
|
||
.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; }
|
||
@media (max-width: 1100px) { .plan-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||
@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields, .fee-form-grid { grid-template-columns: 1fr; } .fee-form-full, .detail-fields > div.wide { grid-column: auto; } }
|
||
|
||
.detail-surface { min-width: 0; min-height: 0; margin-top: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; }
|
||
.info-panel, .table-panel { min-width: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; }
|
||
.info-panel h2, .table-panel h2 { margin: 0; padding: 0 0 var(--admin-space-3); border-bottom: 0; color: var(--cc-text); font-size: var(--admin-font-section); }
|
||
|
||
</style>
|