204 lines
20 KiB
Vue
204 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { Eye, Printer } from '@lucide/vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { ElMessage } from 'element-plus'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, increaseWarehouseTransferForecast, type WarehouseTransferCartonDto, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||
|
||
const props = defineProps<{ customerId: string; transferNo: string }>()
|
||
const emit = defineEmits<{ summary: [value: { boxes: number; forecastBoxes: number; forecastSkus: number; forecastQuantity: number | null; skus: number; quantity: number; pallets: number; weight: number; volume: number; warehouseId?: string | null } | null] }>()
|
||
const detail = ref<WarehouseTransferDetailDto | null>(null)
|
||
const access = useAccessPreviewStore()
|
||
const increaseOpen = ref(false)
|
||
const increasing = ref(false)
|
||
const additionalCount = ref(1)
|
||
const forecastSnapshot = ref(0)
|
||
const increaseError = ref<ApiErrorDetails | null>(null)
|
||
const canIncreaseForecast = computed(() => access.can(permission.warehouseTransferApprove) && detail.value?.transferNo === detail.value?.inboundCode && ['PENDING_REVIEW', 'WAIT_PDA_RECEIVING'].includes(detail.value?.status || '') && Number(detail.value?.packageCount) < 5000)
|
||
const forecastInputValid = computed(() => Number.isSafeInteger(additionalCount.value) && additionalCount.value >= 1 && forecastSnapshot.value + additionalCount.value <= 5000)
|
||
/** 记录打开时箱数,提交时由后端核对并发修改。 */
|
||
function openIncreaseForecast() {
|
||
if (increasing.value || !canIncreaseForecast.value) return
|
||
forecastSnapshot.value = Number(detail.value?.packageCount); additionalCount.value = 1; increaseError.value = null; increaseOpen.value = true
|
||
}
|
||
/** 只补充箱号,不改变原SKU预报或仓库已录入的测量。 */
|
||
async function saveIncreaseForecast() {
|
||
if (increasing.value || !increaseOpen.value || !canIncreaseForecast.value || !forecastInputValid.value) return
|
||
const ticket = generation
|
||
increasing.value = true; increaseError.value = null
|
||
try {
|
||
const value = await increaseWarehouseTransferForecast(props.customerId, props.transferNo, { expectedPackageCount: forecastSnapshot.value, packageCount: forecastSnapshot.value + additionalCount.value })
|
||
if (ticket !== generation) return
|
||
detail.value = value; increaseOpen.value = false
|
||
ElMessage.success('预报箱数已增加,请通知PDA刷新后继续清点')
|
||
} catch (cause) { if (ticket === generation) increaseError.value = getApiErrorDetails(cause, '增加预报箱数失败') }
|
||
finally { increasing.value = false }
|
||
}
|
||
const printing = ref(false)
|
||
const printError = ref<ApiErrorDetails | null>(null)
|
||
const loading = ref(false)
|
||
const error = ref<ApiErrorDetails | null>(null)
|
||
const selectedBox = ref<WarehouseTransferCartonDto | null>(null)
|
||
const boxOpen = ref(false)
|
||
const cartonsOpen = ref(false)
|
||
let generation = 0
|
||
// 同一完成原因保存于箱记录,去重后在尾程详情直接展示。
|
||
const discrepancyReasons = computed(() => [...new Set((detail.value?.cartons || []).map(box => box.discrepancyReason?.trim()).filter((reason): reason is string => Boolean(reason)))])
|
||
// 已签收是历史事实,后续废弃占用、废弃或出库不能从实收汇总中移除。
|
||
const cartons = computed(() => (detail.value?.cartons || []).filter(box => ['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'].includes(box.status)))
|
||
function itemFor(box: WarehouseTransferCartonDto) { return detail.value?.items.find(item => box.transferItemId != null && String(item.id) === String(box.transferItemId)) }
|
||
function boxLines(box: WarehouseTransferCartonDto) {
|
||
if (box.items?.length) return box.items
|
||
return [{ transferItemId: box.transferItemId, skuCode: itemFor(box)?.skuCode || '—', quantity: box.quantity || 0 }]
|
||
}
|
||
function volume(box: WarehouseTransferCartonDto) { return box.length == null || box.width == null || box.height == null ? null : Number(box.length) * Number(box.width) * Number(box.height) / 1_000_000 }
|
||
function formatNumber(value: number | null, digits = 0) { return value == null || !Number.isFinite(value) ? '—' : value.toFixed(digits) }
|
||
const totals = computed(() => ({
|
||
quantity: cartons.value.reduce((sum, box) => sum + Number(box.quantity || 0), 0),
|
||
weight: cartons.value.reduce((sum, box) => sum + Number(box.weight || 0), 0),
|
||
volume: cartons.value.reduce((sum, box) => sum + Number(volume(box) || 0), 0),
|
||
skus: new Set(cartons.value.flatMap(box => boxLines(box).map(line => line.skuCode)).filter(Boolean)).size,
|
||
}))
|
||
watch([detail, totals], () => emit('summary', detail.value ? { ...totals.value, boxes: cartons.value.length, forecastBoxes: detail.value.packageCount, forecastQuantity: detail.value.items.some(item => item.quantity == null) ? null : detail.value.items.reduce((sum, item) => sum + Number(item.quantity), 0), forecastSkus: new Set(detail.value.items.map(item => item.skuCode).filter(Boolean)).size, pallets: detail.value.pallets.length, warehouseId: detail.value.operatingWarehouseId } : null))
|
||
const comparisonRows = computed(() => (detail.value?.items || []).map(item => {
|
||
const boxes = cartons.value.filter(box => boxLines(box).some(line => String(line.transferItemId) === String(item.id)))
|
||
const receivedQuantity = boxes.reduce((sum, box) => sum + boxLines(box).filter(line => String(line.transferItemId) === String(item.id)).reduce((total, line) => total + Number(line.quantity), 0), 0)
|
||
return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: item.quantity == null ? null : receivedQuantity - Number(item.quantity), boxDifference: boxes.length - Number(item.packageCount || 0) }
|
||
}))
|
||
function differenceText(value: unknown) {
|
||
if (value == null) return '未记录'
|
||
if (!cartons.value.length) return '待签收'
|
||
const difference = Number(value)
|
||
return difference > 0 ? `+${difference}` : String(difference)
|
||
}
|
||
const palletRows = computed(() => (detail.value?.pallets || []).map(pallet => {
|
||
const boxes = cartons.value.filter(box => box.transferPalletId != null && String(box.transferPalletId) === String(pallet.id))
|
||
const skus = new Map<string, number>()
|
||
for (const box of boxes) for (const line of boxLines(box)) skus.set(line.skuCode, (skus.get(line.skuCode) || 0) + Number(line.quantity))
|
||
return { ...pallet, receivedBoxes: boxes.length, skuCount: skus.size, quantity: boxes.reduce((sum, box) => sum + Number(box.quantity || 0), 0), skuSummary: [...skus].map(([sku, quantity]) => `${sku} × ${quantity} 件`).join(';') || '—' }
|
||
}))
|
||
const cartonRows = computed(() => cartons.value.map(box => ({ ...box, sku: boxLines(box).map(line => line.skuCode).join(';'), palletNo: detail.value?.pallets.find(pallet => box.transferPalletId != null && String(pallet.id) === String(box.transferPalletId))?.palletNo || '未关联托盘', dimensions: [box.length, box.width, box.height].every(value => value != null) ? `${box.length} × ${box.width} × ${box.height}` : '—', volumeText: formatNumber(volume(box), 6) })))
|
||
const boxItems = computed(() => selectedBox.value ? boxLines(selectedBox.value).map(line => ({ sku: line.skuCode, name: detail.value?.items.find(item => String(item.id) === String(line.transferItemId))?.productNameZh || '—', quantity: line.quantity })) : [])
|
||
const receiptLeadingColumns: DataTableColumn[] = [
|
||
{ key: 'receivedQuantity', label: '实收件数', width: 100, align: 'center', headerAlign: 'center' },
|
||
{ key: 'receivedBoxes', label: '实收箱数', width: 100, align: 'center', headerAlign: 'center' },
|
||
]
|
||
const forecastColumns: DataTableColumn[] = [
|
||
{ key: 'skuCode', label: 'SKU', width: 500, singleLine: true }, { key: 'productNameZh', label: '中文品名', width: 200, singleLine: false },
|
||
...receiptLeadingColumns,
|
||
{ key: 'quantity', label: '预报件数', width: 100, align: 'center', headerAlign: 'center' },
|
||
{ key: 'packageCount', label: '预计箱数', width: 120, align: 'center', headerAlign: 'center' },
|
||
{ key: 'quantityDifference', label: '件数差异', slotName: 'quantityDifference', width: 130, align: 'center', headerAlign: 'center' },
|
||
{ key: 'boxDifference', label: '箱数差异', slotName: 'boxDifference', width: 240, align: 'center', headerAlign: 'center' },
|
||
]
|
||
const cartonColumns: DataTableColumn[] = [
|
||
{ key: 'action', label: '操作', slotName: 'boxAction', width: 140, fixed: 'left' },
|
||
{ key: 'cartonMark', label: '实收箱唛', minWidth: 220 }, { key: 'palletNo', label: '所属托盘', minWidth: 210 },
|
||
{ key: 'sku', label: 'SKU', minWidth: 160 }, { key: 'quantity', label: '实收件数', width: 100 },
|
||
{ key: 'dimensions', label: '长 × 宽 × 高(cm)', minWidth: 150 }, { key: 'weight', label: '重量(kg)', width: 110 }, { key: 'volumeText', label: '体积(m³)', width: 120 },
|
||
]
|
||
const palletColumns: DataTableColumn[] = [
|
||
{ key: 'palletNo', label: '托盘号', width: 500, singleLine: false }, { key: 'warehouseLocationCode', label: '库位', width: 200, singleLine: false },
|
||
...receiptLeadingColumns.map(column => column.key === 'receivedQuantity' ? { ...column, key: 'quantity' } : column),
|
||
{ key: 'skuCount', label: 'SKU 种数', width: 100, align: 'center', headerAlign: 'center' }, { key: 'grossWeightKg', label: '总重量(kg)', width: 120, align: 'center', headerAlign: 'center' },
|
||
{ key: 'volumeCbm', label: '总体积(m³)', width: 130, align: 'center', headerAlign: 'center' }, { key: 'skuSummary', label: 'SKU / 件数汇总', width: 240, singleLine: false },
|
||
]
|
||
const boxItemColumns: DataTableColumn[] = [{ key: 'sku', label: 'SKU', minWidth: 180 }, { key: 'name', label: '品名', minWidth: 150 }, { key: 'quantity', label: '实际清点件数', width: 120 }]
|
||
function showBox(cartonMark: unknown) {
|
||
const box = cartons.value.find(value => value.cartonMark === cartonMark)
|
||
if (!box) return
|
||
selectedBox.value = box; boxOpen.value = true
|
||
}
|
||
async function printCartons() {
|
||
if (printing.value || !detail.value?.cartons.length) return
|
||
const printWindow = window.open('', '_blank')
|
||
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
|
||
printWindow.opener = null
|
||
printing.value = true; printError.value = null
|
||
const ticket = generation
|
||
try {
|
||
const label = await getWarehouseTransferCartonLabel(props.customerId, props.transferNo)
|
||
if (ticket !== generation) { printWindow.close(); return }
|
||
if (!label.cartons.length) { printWindow.close(); ElMessage.warning('暂无可打印的箱唛'); return }
|
||
printWindow.document.open(); printWindow.document.write(label.printableHtml); printWindow.document.close()
|
||
printWindow.focus(); printWindow.print()
|
||
} catch (cause) {
|
||
printWindow.close()
|
||
if (ticket === generation) printError.value = getApiErrorDetails(cause, '箱唛加载失败,请重试')
|
||
} finally { printing.value = false }
|
||
}
|
||
async function load() {
|
||
const ticket = ++generation
|
||
detail.value = null; selectedBox.value = null; boxOpen.value = false; cartonsOpen.value = false; increaseOpen.value = false; increaseError.value = null; error.value = null; printError.value = null
|
||
if (!props.customerId || !props.transferNo) { loading.value = false; error.value = { message: '缺少客户或来源转运单号,无法查询签收明细' }; return }
|
||
loading.value = true
|
||
try { const value = await getWarehouseTransfer(props.customerId, props.transferNo); if (ticket === generation) detail.value = value }
|
||
catch (cause) { if (ticket === generation) error.value = getApiErrorDetails(cause, '转运预报与签收明细加载失败') }
|
||
finally { if (ticket === generation) loading.value = false }
|
||
}
|
||
watch(() => [props.customerId, props.transferNo], load, { immediate: true })
|
||
onBeforeUnmount(() => { generation++ })
|
||
</script>
|
||
|
||
<template>
|
||
<section v-loading="loading" class="transfer-receipt-panel" aria-label="仓储转运预报与实际签收">
|
||
<LoadErrorState v-if="error" title="签收明细加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<template v-else-if="detail">
|
||
<p v-for="reason in discrepancyReasons" :key="reason" class="receipt-discrepancy-reason"><strong>实收差异原因:</strong>{{ reason }}</p>
|
||
<div class="receipt-box-heading"><el-button v-if="canIncreaseForecast" type="primary" size="small" :disabled="increasing" @click="openIncreaseForecast">增加预报箱数</el-button><span>预报箱数 {{ detail.packageCount }} 箱 · 预报SKU总件数 {{ detail.items.some(item => item.quantity == null) ? '未记录' : detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) + ' 件' }}</span></div>
|
||
<h3>预报与实际签收对比 <small>差异 = 实收 − 预报{{ cartons.length ? '(按当前已清点箱汇总)' : ',待签收后显示' }}</small></h3>
|
||
<div class="workbench-table"><DataTable class="receipt-aligned-table" :fit="false" :data="comparisonRows" :columns="forecastColumns" row-key="id" :show-pagination="false" empty-text="暂无预报货物明细">
|
||
<template #quantityDifference="{ row }"><span :class="{ 'receipt-difference': cartons.length && Number(row.quantityDifference) !== 0 }">{{ differenceText(row.quantityDifference) }}</span></template>
|
||
<template #boxDifference="{ row }"><span :class="{ 'receipt-difference': cartons.length && Number(row.boxDifference) !== 0 }">{{ differenceText(row.boxDifference) }}</span></template>
|
||
</DataTable></div>
|
||
<h3>托盘签收汇总</h3><div class="workbench-table"><DataTable class="receipt-aligned-table" :fit="false" :data="palletRows" :columns="palletColumns" row-key="id" :show-pagination="false" empty-text="尚未创建托盘" /></div>
|
||
<div class="receipt-box-heading"><h3>签收箱号明细({{ cartons.length }} 箱)</h3><el-button type="primary" size="small" :disabled="!cartons.length" @click="cartonsOpen = true">查看箱号明细</el-button><el-button v-if="detail.status !== 'DRAFT'" type="primary" size="small" :loading="printing" :disabled="!detail.cartons.length" @click="printCartons"><Printer :size="14" />打印箱唛</el-button></div>
|
||
<LoadErrorState v-if="printError" title="箱唛打印失败" :message="printError.message" :loading="printing" @retry="printCartons"><template #details><ApiErrorMeta :details="printError" /></template></LoadErrorState>
|
||
<AppDialog v-model="cartonsOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`签收箱号明细 · ${transferNo || '—'}`" width="min(1120px, calc(100vw - 28px))" fullscreenable :show-footer="false">
|
||
<div class="density-dialog-sections">
|
||
<section class="density-dialog-section" data-tone="summary" aria-label="实收箱数核对"><h3 class="density-dialog-section__title">实收汇总</h3><table class="density-table"><tbody><tr><th scope="row">转运单号</th><td>{{ transferNo || '—' }}</td><th scope="row">已清点箱数</th><td>{{ cartons.length }} 箱</td></tr></tbody></table></section>
|
||
<section class="density-dialog-section" data-tone="source" aria-label="已清点箱号"><h3 class="density-dialog-section__title">签收箱号</h3><div class="workbench-table"><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" max-height="" :show-pagination="false" empty-text="暂无实际签收箱,等待 PDA 清点">
|
||
<template #boxAction="{ row }"><el-button link type="primary" @click="showBox(row.cartonMark)"><Eye :size="14" />箱内明细</el-button></template>
|
||
</DataTable></div></section>
|
||
</div>
|
||
</AppDialog>
|
||
</template>
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="increaseOpen" :title="`增加预报箱数 · ${transferNo || '—'}`" width="min(520px, calc(100vw - 28px))" fullscreenable confirm-text="确认增加" :confirm-loading="increasing" :confirm-disabled="!canIncreaseForecast || !forecastInputValid" @confirm="saveIncreaseForecast">
|
||
<div class="density-dialog-sections">
|
||
<section class="density-dialog-section" data-tone="summary" aria-label="预报箱数核对"><h3 class="density-dialog-section__title">箱数核对</h3><table class="density-table"><tbody><tr><th scope="row">原预报箱数</th><td>{{ forecastSnapshot }} 箱</td><th scope="row">增加后</th><td>{{ forecastInputValid ? forecastSnapshot + additionalCount : '—' }} 箱</td></tr></tbody></table></section>
|
||
<LoadErrorState v-if="increaseError" title="预报箱数增加失败" :message="increaseError.message" compact :loading="increasing" retry-text="重试本次增加" @retry="saveIncreaseForecast"><template #details><ApiErrorMeta :details="increaseError" /></template></LoadErrorState>
|
||
<section class="density-dialog-section" data-tone="work" aria-label="新增箱数登记"><h3 class="density-dialog-section__title">增加预报 <InlineHelp title="增加预报说明">保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。</InlineHelp></h3>
|
||
<el-form class="density-form" label-position="left" label-width="100px" :disabled="increasing"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="新增箱数" required><el-input-number v-model="additionalCount" placeholder="请输入新增箱数" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" controls-position="right" /></el-form-item></td></tr></tbody></table></el-form>
|
||
</section>
|
||
</div>
|
||
</AppDialog>
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="boxOpen" :title="`箱内清点明细 · ${selectedBox?.cartonMark || '—'}`" width="min(820px, calc(100vw - 28px))" fullscreenable :show-footer="false">
|
||
<div v-if="selectedBox" class="density-dialog-sections">
|
||
<section class="density-dialog-section" data-tone="summary" aria-label="当前签收箱"><h3 class="density-dialog-section__title">签收箱</h3><table class="density-table"><tbody><tr><th scope="row">箱唛</th><td>{{ selectedBox.cartonMark }}</td><th scope="row">清点时间</th><td>{{ formatOverseasDateTime(selectedBox.countedAt) }}</td></tr></tbody></table></section>
|
||
<section class="density-dialog-section" data-tone="source" aria-label="箱内SKU实收"><h3 class="density-dialog-section__title">箱内实收</h3><div class="workbench-table"><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" max-height="" :show-pagination="false" /></div></section>
|
||
<el-alert v-if="selectedBox.discrepancyReason" :title="`差异说明:${selectedBox.discrepancyReason}`" type="warning" :closable="false" show-icon />
|
||
</div>
|
||
</AppDialog>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.receipt-box-heading{display:flex;align-items:center;justify-content:flex-start;flex-wrap:wrap;gap:12px}
|
||
.receipt-discrepancy-reason{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||
.transfer-receipt-panel{display:grid;gap:var(--admin-space-2)}
|
||
.receipt-aligned-table{width:100%;max-width:1490px;min-width:0}
|
||
.receipt-difference{color:var(--el-color-danger);font-weight:600}
|
||
h3 small{margin-left:8px;color:var(--workbench-muted);font-size:var(--admin-font-caption);font-weight:400}
|
||
h3{margin:0;font-size:var(--admin-font-section)}
|
||
</style>
|