Files
admin-web/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
T
2026-09-22 08:47:43 +08:00

259 lines
23 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 } 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 { 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, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.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 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 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: text(order?.overseasWarehouseName) },
{ 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) },
]
})
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
return [
{ label: '客户', value: text(order?.customerName) },
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
{ 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} 张(详见关联单据)` : lastMileStatusLabel(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[]>(() => (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: DataTableColumn[] = [
{ 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: 130 },
{ 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>
<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 name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细"></DataTable></section></el-tab-pane>
<el-tab-pane name="plan"><template #label><span class="density-tab-icon" aria-hidden="true">🗓️</span><span>计划信息</span></template>
<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>
.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>