Files
admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue
T
2026-10-15 18:25:23 +08:00

148 lines
13 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 { 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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, type WarehouseTransferCartonDto, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
import { formatOverseasDateTime } from '../lastMilePresentation'
const props = defineProps<{ customerId: string; transferNo: string }>()
const emit = defineEmits<{ summary: [value: { boxes: number; forecastBoxes: number; forecastSkus: number; skus: number; quantity: number; pallets: number; weight: number; volume: number; warehouseId?: string | null } | null] }>()
const detail = ref<WarehouseTransferDetailDto | null>(null)
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 cartons = computed(() => (detail.value?.cartons || []).filter(box => box.status === 'COUNTED'))
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, 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: receivedQuantity - Number(item.quantity || 0), boxDifference: boxes.length - Number(item.packageCount || 0) }
}))
function differenceText(value: unknown) {
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; 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">
<h3>预报与实际签收对比 <small>差异 = 实收 − 预报{{ cartons.length ? '(按当前已清点箱汇总)' : ',待签收后显示' }}</small></h3>
<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>
<h3>托盘签收汇总</h3><DataTable class="receipt-aligned-table" :fit="false" :data="palletRows" :columns="palletColumns" row-key="id" :show-pagination="false" empty-text="尚未创建托盘" />
<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" title="签收箱号明细" width="1120px" :show-footer="false"><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" :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></AppDialog>
</template>
<AppDialog class="admin-density-dialog" v-model="boxOpen" title="箱内清点明细" width="820px" :show-footer="false">
<template v-if="selectedBox"><p>{{ selectedBox.cartonMark }} · 清点时间 {{ formatOverseasDateTime(selectedBox.countedAt) }}</p><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" :show-pagination="false" /><p v-if="selectedBox.discrepancyReason">差异说明:{{ selectedBox.discrepancyReason }}</p></template>
</AppDialog>
</section>
</template>
<style scoped>
.receipt-box-heading{display:flex;align-items:center;justify-content:flex-start;flex-wrap:wrap;gap:12px}
.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(--cc-muted);font-size:var(--admin-font-caption);font-weight:400}
h3{margin:0;font-size:var(--admin-font-section)}
</style>