118 lines
15 KiB
Vue
118 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
|
||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||
|
||
const route = useRoute(); const router = useRouter()
|
||
const planNo = computed(() => String(route.params.orderId || ''))
|
||
const customerId = computed(() => String(route.query.customerId || ''))
|
||
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||
const moveOpen = ref(false); const moveSaving = ref(false); const moveRow = ref<{ palletNo: string; palletVersion: number } | null>(null); const targetLocationCode = ref(''); const moveReason = ref('入库托盘上架'); const locationOptions = ref<OverseasLocationDto[]>([])
|
||
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
|
||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||
function receivingWarehouse(code: unknown, name: unknown) {
|
||
const normalizedCode = String(code ?? '').trim()
|
||
const normalizedName = String(name ?? '').trim()
|
||
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
|
||
}
|
||
function status(value: unknown) { return inboundPlanStatusLabel(value) }
|
||
const firstLine = computed(() => detail.value?.lines[0])
|
||
const fieldValues = computed<Record<string, string | number>>(() => ({
|
||
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: lastMileOrderTypeLabel(detail.value?.orderType), 货主: text(detail.value?.customerName), 关联订单: text(detail.value?.sourceOrderNo),
|
||
海外仓: receivingWarehouse(detail.value?.overseasWarehouseCode, detail.value?.overseasWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0,
|
||
应拣货数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 实际拣货数: detail.value?.pickedPackageCount ?? detail.value?.allocations.reduce((sum, item) => sum + item.pickedPackageCount, 0) ?? 0,
|
||
'货物毛重(kg)': Number(detail.value?.totalGrossWeightKg || 0), '货物净重(kg)': Number(detail.value?.totalNetWeightKg || 0),
|
||
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0),
|
||
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: text(detail.value?.remark),
|
||
}))
|
||
const summaryGroups = [
|
||
{ title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] },
|
||
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '货物毛重(kg)', '货物净重(kg)', '计费体积(m³)', '总托盘数'] },
|
||
]
|
||
const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注']
|
||
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
|
||
const palletColumns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 },
|
||
{ key: 'palletOwner', prop: 'palletOwner', label: '托盘所属计划', minWidth: 190 },
|
||
{ key: 'packageCount', prop: 'packageCount', label: '对应箱数', width: 110, align: 'right' },
|
||
{ key: 'location', label: '当前库位', minWidth: 150, slotName: 'location' },
|
||
{ key: 'locationType', label: '库位类型', width: 120, prop: 'locationType' },
|
||
{ key: 'putawayStatus', label: '上架状态', width: 110, prop: 'putawayStatus' },
|
||
]
|
||
const palletRows = computed(() => (detail.value?.allocations || []).map((allocation, index) => {
|
||
const line = detail.value?.lines.find(item => item.id === allocation.lineId)
|
||
const pallet = detail.value?.pallets.find(item => item.id === allocation.palletId || item.warehousePalletId === allocation.palletId)
|
||
const locationCode = pallet?.warehouseLocationCode || null
|
||
const locationType = pallet?.sharedOverflow ? 'SHARED_OVERFLOW' : (pallet?.warehouseLocationType || null)
|
||
return {
|
||
id: `${allocation.lineId}-${allocation.palletId}-${index}`,
|
||
inboundCode: text(line?.inboundCode),
|
||
palletNo: text(allocation.palletNo),
|
||
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
|
||
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0),
|
||
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? 0, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
|
||
sourceLocationVersion: null, location: locationCode || (pallet?.warehouseLocationId ? '—' : '虚拟收货位'), locationType: locationType === 'SHARED_OVERFLOW' ? 'A0共享溢出位' : locationType === 'NORMAL' ? '普通库位' : '虚拟收货位', putawayStatus: locationType === 'NORMAL' || locationType === 'SHARED_OVERFLOW' ? '已上架' : '待上架',
|
||
}
|
||
}))
|
||
const availableLocations = computed(() => locationOptions.value.filter(item => item.status !== 'DISABLED'))
|
||
async function openMove(row: typeof palletRows.value[number]) {
|
||
if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return }
|
||
moveRow.value = { palletNo: row.palletNo, palletVersion: row.palletVersion }; targetLocationCode.value = ''; moveReason.value = '入库托盘上架'; moveOpen.value = true
|
||
try { const result = await listOverseasLocations(String((detail.value as InboundPlanDetailDto & { overseasWarehouseId?: string }).overseasWarehouseId || ''), { page: 1, size: 200 }); locationOptions.value = result.records.filter(item => item.locationType !== 'VIRTUAL' && item.status !== 'DISABLED') } catch { locationOptions.value = []; ElMessage.error('可用库位加载失败,请重试') }
|
||
}
|
||
async function submitMove() {
|
||
if (!moveRow.value || !targetLocationCode.value || !moveReason.value.trim()) { ElMessage.warning('请选择目标库位并填写操作原因'); return }
|
||
moveSaving.value = true
|
||
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { ElMessage.error('入库单未返回海外仓 ID,暂不能移库'); return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { ElMessage.error(result.blockReason || '当前托盘不可移库'); return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { ElMessage.error(getApiErrorDetails(error, '移库失败').message) } finally { moveSaving.value = false }
|
||
}
|
||
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
|
||
function back() { void router.push(appointmentReturnTarget(route.query) || { name: 'overseas-inbound-orders' }) }
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
|
||
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
|
||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!(row as typeof palletRows[number]).palletNo || !(row as typeof palletRows[number]).palletVersion" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
|
||
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
|
||
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
|
||
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" title="托盘移库" width="min(560px, calc(100vw - 28px))" confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="!targetLocationCode" @confirm="submitMove"><el-form class="density-form" label-position="top"><el-form-item label="托盘号"><el-input :model-value="moveRow?.palletNo" readonly /></el-form-item><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ''}`" /></el-select></el-form-item><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" placeholder="请输入移库原因" /></el-form-item></el-form></AppDialog></template>
|
||
|
||
<style scoped>
|
||
.detail-section { min-width: 0; padding: 0; border: 0; background: #fff; }
|
||
.detail-group h2 { margin: 0 0 var(--admin-space-3); color: var(--cc-text); font-size: var(--admin-font-section); }
|
||
.compact-section-heading { display:flex; min-height:42px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-2) var(--admin-space-5); border-bottom:1px solid var(--cc-border); }
|
||
.compact-section-heading h2 { margin:0; color:var(--cc-text); font-size:var(--admin-font-section); }
|
||
.compact-section-heading span { color:var(--cc-muted); font-size:var(--admin-font-caption); }
|
||
.fee-section { overflow:hidden; }.fee-total { display:flex; min-height:44px; align-items:center; justify-content:flex-end; gap:40px; margin:0 var(--admin-space-5) var(--admin-space-4); padding:var(--admin-space-3) var(--admin-space-5); border:1px solid var(--cc-border); border-top:0; }.fee-total strong { min-width:120px; font-size:var(--admin-font-section); }
|
||
.pallet-section { overflow: hidden; }.section-heading { display:flex; min-height:50px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-3) var(--admin-space-5); border-bottom:1px solid var(--cc-border); }.pallet-section h2 { margin: 0 0 var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-section); }.pallet-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }.section-count { flex:0 0 auto; color:var(--cc-muted); font-size:var(--admin-font-caption); font-variant-numeric:tabular-nums; }
|
||
.appointment-section,.fee-section { border-top:1px solid var(--cc-border); }
|
||
.appointment-section { padding:0 var(--admin-space-5) var(--admin-space-4); }
|
||
.appointment-section .compact-section-heading { margin:0 calc(0px - var(--admin-space-5)) var(--admin-space-3); }
|
||
.appointment-fields { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||
.appointment-fields > .is-wide { grid-column:span 2; }
|
||
@media(max-width:1400px){.detail-fields{grid-template-columns:repeat(4,minmax(0,1fr))}}
|
||
@media(max-width:1200px){.detail-fields,.appointment-fields{grid-template-columns:repeat(2,minmax(0,1fr))}.appointment-fields > .is-wide{grid-column:span 2}}
|
||
|
||
.detail-groups { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||
.detail-group { min-width: 0; padding: var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
|
||
.detail-fields { --admin-field-label-width: 108px; grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||
:deep(.density-detail-header) { border-bottom: 1px solid var(--cc-border); }
|
||
.detail-table-wrap { padding:var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); }
|
||
.fee-section .detail-table-wrap { padding-bottom:0; }
|
||
|
||
</style>
|