This commit is contained in:
2026-10-15 15:45:16 +08:00
parent 2ade6bfa3c
commit 092690791d
10 changed files with 348 additions and 16 deletions
@@ -0,0 +1,106 @@
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WarehouseTransferReceiptPanel from './WarehouseTransferReceiptPanel.vue'
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), getWarehouseTransferCartonLabel: vi.fn() }))
enableAutoUnmount(afterEach)
const DataTableStub = defineComponent({
props: ['data', 'columns'],
setup(props, { slots }) { return () => h('div', { class: 'test-table' }, [JSON.stringify(props.data), ...(props.data || []).flatMap((row: object) => [slots.boxAction?.({ row }), slots.quantityDifference?.({ row }), slots.boxDifference?.({ row })])]) },
})
const DialogStub = defineComponent({ props: ['modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'box-dialog' }, slots.default?.()) : null } })
const render = (transferNo = 'WT-SOURCE') => mount(WarehouseTransferReceiptPanel, { props: { customerId: 'CUSTOMER', transferNo }, global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, AppDialog: DialogStub } } })
function fixture(): WarehouseTransferDetailDto {
return {
id: '1', transferNo: 'WT-SOURCE', status: 'WAIT_CUSTOMER_CONFIRMATION', needRelabel: false, packageCount: 4,
items: [{ id: '10', skuCode: 'SKU-A', productNameZh: '产品 A', quantity: '120', packageCount: 3 }, { id: '20', skuCode: 'SKU-B', productNameZh: '产品 B', quantity: 30, packageCount: 1 }],
cartons: [
{ cartonMark: 'BOX-1', cartonIndex: 1, totalCartons: 4, status: 'COUNTED', transferItemId: '10', transferPalletId: '100', quantity: '8', length: 100, width: 20, height: 50, weight: '2.5', countedAt: '2026-10-15T12:00:00' },
{ cartonMark: 'BOX-2', cartonIndex: 2, totalCartons: 4, status: 'COUNTED', transferItemId: '10', transferPalletId: '100', quantity: 12, length: 100, width: 20, height: 50, weight: 3.5 },
{ cartonMark: 'BOX-3', cartonIndex: 3, totalCartons: 4, status: 'COUNTED', transferItemId: '20', transferPalletId: '200', quantity: 7, length: 100, width: 20, height: 50, weight: 4 },
{ cartonMark: 'BOX-MISSING', cartonIndex: 4, totalCartons: 4, status: 'NOT_RECEIVED', transferItemId: '10', quantity: 999, weight: 999 },
],
pallets: [{ id: '100', sequenceNo: 1, palletNo: 'PALLET-A', cartonCount: 2, grossWeightKg: 6, volumeCbm: .2, status: 'COMPLETED' }, { id: '200', sequenceNo: 2, palletNo: 'PALLET-B', cartonCount: 1, grossWeightKg: 4, volumeCbm: .1, status: 'COMPLETED' }], relabelBindings: [],
}
}
beforeEach(() => { vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset() })
afterEach(() => vi.restoreAllMocks())
describe('转运预报与实际签收明细', () => {
it('keeps declarations separate and totals only counted boxes across pallets', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture())
const wrapper = render(); await flushPromises()
expect(getWarehouseTransfer).toHaveBeenCalledWith('CUSTOMER', 'WT-SOURCE')
const summary = wrapper.emitted('summary')!.at(-1)![0]
expect(summary).toMatchObject({ forecastBoxes: 4, boxes: 3, quantity: 27, weight: 10 })
expect((summary as { volume: number }).volume).toBeCloseTo(.3)
const rows = wrapper.findAllComponents(DataTableStub)[0]!.props('data')
expect(rows[0]).toMatchObject({ quantity: '120', receivedQuantity: 20, receivedBoxes: 2, quantityDifference: -100, boxDifference: -1 })
expect(rows[1]).toMatchObject({ receivedQuantity: 7, quantityDifference: -23, boxDifference: 0 })
const tables = wrapper.findAll('.test-table')
expect(tables[0]!.text()).toContain('120')
expect(tables[1]!.text()).toContain('SKU-A × 20 件'); expect(tables[1]!.text()).toContain('SKU-B × 7 件')
expect(tables).toHaveLength(2)
await wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.trigger('click')
expect(wrapper.findAll('.test-table')[2]!.text()).not.toContain('BOX-MISSING')
await wrapper.findAll('button').find(button => button.text() === '箱内明细')!.trigger('click')
const box = wrapper.findAll('[data-test="box-dialog"]')[1]!
expect(box.text()).toContain('BOX-1'); expect(box.text()).toContain('产品 A'); expect(box.text()).toContain('"quantity":"8"')
expect(box.text()).not.toContain('120')
})
it('shows zero receipts before signing without treating forecast cartons as actual receipts', async () => {
const value = fixture(); value.pallets = []; value.cartons.forEach(box => { box.status = 'EXPECTED' })
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
const wrapper = render(); await flushPromises()
expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ boxes: 0, quantity: 0 })
expect(wrapper.findAll('.test-table')[0]!.text()).toContain('待签收')
expect(wrapper.findAll('.test-table')).toHaveLength(2)
expect(wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.attributes('disabled')).toBeDefined()
})
it('shows signed positive and zero differences against each forecast row', async () => {
const value = fixture(); value.items[0]!.quantity = 10; value.items[0]!.packageCount = 2
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
const wrapper = render(); await flushPromises()
const table = wrapper.findAll('.test-table')[0]!
expect(table.text()).toContain('+10')
expect(table.findAll('.receipt-difference').map(cell => cell.text())).toEqual(['+10', '-23'])
expect(wrapper.findAllComponents(DataTableStub)[0]!.props('data')[0]).toMatchObject({ quantityDifference: 10, boxDifference: 0 })
})
it('prints server label HTML with the selected customer and source reference', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture())
vi.mocked(getWarehouseTransferCartonLabel).mockResolvedValue({ transferNo: 'WT-SOURCE', cartons: fixture().cartons, printableHtml: '<html>SERVER-BOX-LABEL</html>' })
const popup = { opener: window, document: { open: vi.fn(), write: vi.fn(), close: vi.fn() }, focus: vi.fn(), print: vi.fn(), close: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '打印箱唛')!.trigger('click'); await flushPromises()
expect(getWarehouseTransferCartonLabel).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE')
expect(popup.document.write).toHaveBeenCalledWith('<html>SERVER-BOX-LABEL</html>')
expect(popup.print).toHaveBeenCalledOnce()
expect(popup.opener).toBeNull()
})
it('closes the print window and leaves receipt details available when labels fail', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture())
vi.mocked(getWarehouseTransferCartonLabel).mockRejectedValue(new Error('标签暂不可用'))
const popup = { close: vi.fn(), print: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '打印箱唛')!.trigger('click'); await flushPromises()
expect(popup.close).toHaveBeenCalledOnce(); expect(popup.print).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('箱唛加载失败,请重试')
expect(wrapper.text()).toContain('签收箱号明细(3 箱)')
})
it('does not request another order when the source reference is missing', async () => {
const wrapper = render(''); await flushPromises()
expect(getWarehouseTransfer).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少客户或来源转运单号')
})
it('discards a stale response after switching transfers', async () => {
let resolveOld!: (value: WarehouseTransferDetailDto) => void
vi.mocked(getWarehouseTransfer).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })).mockResolvedValueOnce({ ...fixture(), transferNo: 'WT-NEW', packageCount: 9 })
const wrapper = render(); await wrapper.setProps({ transferNo: 'WT-NEW' }); await flushPromises()
resolveOld(fixture()); await flushPromises()
expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ forecastBoxes: 9 })
})
})
@@ -0,0 +1,143 @@
<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 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.map(box => itemFor(box)?.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 => box.transferItemId != null && String(box.transferItemId) === String(item.id))
const receivedQuantity = boxes.reduce((sum, box) => sum + Number(box.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) { const sku = itemFor(box)?.skuCode || 'SKU 未留存'; skus.set(sku, (skus.get(sku) || 0) + Number(box.quantity || 0)) }
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: itemFor(box)?.skuCode || '—', 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 ? [{ sku: itemFor(selectedBox.value)?.skuCode || '—', name: itemFor(selectedBox.value)?.productNameZh || '—', quantity: selectedBox.value.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>