This commit is contained in:
2026-10-20 14:00:49 +08:00
parent 3879a47673
commit 5679b156ca
28 changed files with 803 additions and 515 deletions
@@ -0,0 +1,27 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { listRelabelInventoryCandidates } from '../api/relabelOrders'
import RelabelInventoryItemsEditor from './RelabelInventoryItemsEditor.vue'
vi.mock('../api/relabelOrders', () => ({ listRelabelInventoryCandidates: vi.fn() }))
describe('formal relabel source tasks', () => {
it('submits one carton with long source IDs and independently editable SKU pieces; warehouse change clears it', async () => {
vi.mocked(listRelabelInventoryCandidates).mockResolvedValue([{ inventoryBalanceId: '9007199254740993', inboundOrderId: '9007199254740994', inboundOrderNo: 'IN-1', skuCode: 'A', productNameZh: '商品', availablePackages: 1, sourceType: 'INBOUND', positions: [{ positionId: '9007199254740995', palletNo: 'P1', availablePackages: 1 }] }])
const wrapper = mount(RelabelInventoryItemsEditor, { props: { customerId:'C', warehouseId:'W',operationType:'CARTON_AND_SKU',shipmentNo:'S',destination:'SCK8',requirement:'要求' }, global:{plugins:[ElementPlus]} })
await flushPromises()
wrapper.findComponent({name:'ElSelect'}).vm.$emit('update:modelValue','inventory:9007199254740995')
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '添加箱任务')!.trigger('click'); await flushPromises()
await wrapper.findAll('input').find(input => input.attributes('maxlength') === '255')!.setValue('BOX-1')
await wrapper.get('input[aria-label="新 SKU"]').setValue('B')
wrapper.findComponent({name:'ElInputNumber'}).vm.$emit('update:modelValue',8)
await flushPromises()
const items = wrapper.emitted('update:items')!.at(-1)![0] as Array<Record<string, unknown>>
expect(items).toMatchObject([{sourceInventoryBalanceId:'9007199254740993',sourceInventoryPositionId:'9007199254740995',sourceInboundOrderId:'9007199254740994',cartonMark:'BOX-1',totalPackageCount:1,notifiedPackageCount:1,destinationFbaWarehouseCode:'SCK8',skuItems:[{skuCode:'A',newSkuCode:'B',quantity:8}]}])
expect(items[0]).not.toHaveProperty('sourceLabel')
await wrapper.setProps({warehouseId:'OTHER'}); await flushPromises()
expect(wrapper.emitted('update:items')!.at(-1)![0]).toEqual([])
expect(listRelabelInventoryCandidates).toHaveBeenLastCalledWith('C','OTHER')
wrapper.unmount()
})
})
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { listRelabelInventoryCandidates, type CreateRelabelItemPayload, type RelabelInventoryCandidate } from '../api/relabelOrders'
const props = defineProps<{ customerId: string; warehouseId: string; operationType: CreateRelabelItemPayload['operationType']; shipmentNo: string; destination: string; requirement: string }>()
const emit = defineEmits<{ 'update:items': [CreateRelabelItemPayload[]] }>()
const candidates = ref<RelabelInventoryCandidate[]>([]), selected = ref(''), loading = ref(false), error = ref('')
const tasks = ref<Array<CreateRelabelItemPayload & { sourceLabel: string; sourceKey: string; available: number }>>([])
let generation = 0
type SourceOption = { key: string; label: string; candidate: RelabelInventoryCandidate; position: RelabelInventoryCandidate['positions'][number] | undefined; available: number }
const options = computed(() => candidates.value.flatMap<SourceOption>(candidate => candidate.returnCartonId
? Number(candidate.cartonAvailablePackages) > 0 ? [{ key: `return:${candidate.returnCartonId}`, label: `${candidate.inboundOrderNo} · ${candidate.cartonMark}`, candidate, position: undefined, available: 1 }] : []
: candidate.positions.map(position => ({ key: `inventory:${position.positionId}`, label: `${candidate.inboundOrderNo} · ${candidate.skuCode} · 记账托盘 ${position.palletNo || '未提供'} · 可用 ${position.availablePackages} 箱`, candidate, position, available: Number(position.availablePackages) }))))
/** 客户或仓库切换立即清空旧来源,异步返回只接收最后一次请求。 */
async function load() {
const current = ++generation
candidates.value = []; selected.value = ''; tasks.value = []; error.value = ''
if (!props.customerId || !props.warehouseId) { loading.value = false; return }
loading.value = true
try { const result = await listRelabelInventoryCandidates(props.customerId, props.warehouseId); if (current === generation) candidates.value = result }
catch { if (current === generation) error.value = '库存来源加载失败,请重试' }
finally { if (current === generation) loading.value = false }
}
watch(() => [props.customerId, props.warehouseId], load, { immediate: true })
watch(() => props.operationType, () => { tasks.value = [] })
function add() {
const option = options.value.find(item => item.key === selected.value)
if (!option) return
if (tasks.value.filter(item => item.sourceKey === option.key).length >= option.available) { error.value = '已达到该来源可用箱数'; return }
const source = option.candidate
tasks.value.push({ sourceLabel: option.label, sourceKey: option.key, available: option.available,
sourceInventoryBalanceId: source.inventoryBalanceId || undefined, sourceInventoryPositionId: option.position?.positionId,
sourceInboundOrderId: source.inboundOrderId || undefined, returnCartonId: source.returnCartonId || undefined,
skuCode: source.skuCode, productNameZh: source.productNameZh || source.skuCode,
totalPackageCount: 1, notifiedPackageCount: 1, cartonMark: source.cartonMark || '', fbaCartonMark: '',
operationType: props.operationType, shipmentNo: props.shipmentNo, newSkuCode: '',
skuItems: props.operationType === 'CARTON_LABEL' ? [] : (source.cartonSkuItems?.length ? source.cartonSkuItems : [{ skuCode: source.skuCode, quantity: 1 }]).map(item => ({ skuCode: item.skuCode, newSkuCode: '', quantity: item.quantity })) })
error.value = ''
}
/** 每箱任务数量固定为一,SKU子项单独按件提交,额外展示字段不进入正式请求。 */
watch(() => [tasks.value, props.shipmentNo, props.destination, props.requirement], () => {
emit('update:items', tasks.value.map(({ sourceLabel: _label, sourceKey: _key, available: _available, ...item }) => ({ ...item,
shipmentNo: props.shipmentNo.trim(), labelRequirement: props.requirement.trim() || undefined,
destinationFbaWarehouseCode: props.operationType === 'SKU_LABEL' ? undefined : props.destination.trim(),
newSkuCode: item.skuItems.map(sku => sku.newSkuCode).join(','),
skuItems: props.operationType === 'CARTON_LABEL' ? [] : item.skuItems })))
}, { deep: true, immediate: true })
</script>
<template>
<section class="density-dialog-section" data-tone="source" aria-label="库存与箱任务">
<h3 class="density-dialog-section__title">库存与箱任务({{ tasks.length }} 箱)</h3>
<p class="density-dialog-note">普通入库托盘仅为冻结记账位置;每箱填写一个我方箱唛,SKU 数量按件填写,实际来源托盘由 PDA 现场确认。</p>
<el-select v-model="selected" filterable :loading="loading" :disabled="!customerId || !warehouseId" placeholder="请选择正式库存来源">
<el-option v-for="option in options" :key="option.key" :label="option.label" :value="option.key" />
</el-select>
<el-button :disabled="!selected || loading" @click="add">添加箱任务</el-button>
<p v-if="error" role="alert">{{ error }} <el-button link @click="load">重试加载</el-button></p>
<section v-for="(item, index) in tasks" :key="index" class="density-dialog-section">
<p>{{ index + 1 }}. {{ item.sourceLabel }} <el-button link type="danger" @click="tasks.splice(index, 1)">移除</el-button></p>
<el-form-item label="我方箱唛" required><el-input v-model="item.cartonMark" :readonly="Boolean(item.returnCartonId)" maxlength="255" placeholder="请输入来源箱的我方箱唛" /></el-form-item>
<el-form-item label="中文品名" required><el-input v-model="item.productNameZh" maxlength="255" placeholder="请输入中文品名" /></el-form-item>
<el-form-item v-if="operationType !== 'SKU_LABEL'" label="新 FBA 箱唛" required><el-input v-model="item.fbaCartonMark" maxlength="255" placeholder="请输入新 FBA 箱唛" /></el-form-item>
<table v-if="operationType !== 'CARTON_LABEL'" class="density-table density-table--editable"><thead><tr><th>旧 SKU</th><th>新 SKU</th><th>件数</th><th>操作</th></tr></thead><tbody>
<tr v-for="(sku, skuIndex) in item.skuItems" :key="skuIndex"><td><el-input v-model="sku.skuCode" aria-label="旧 SKU" placeholder="请输入旧 SKU" /></td><td><el-input v-model="sku.newSkuCode" aria-label="新 SKU" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="sku.quantity" :min="1" :precision="0" aria-label="SKU 件数" placeholder="请输入件数" /></td><td><el-button link @click="item.skuItems.splice(skuIndex, 1)">移除 SKU</el-button></td></tr>
</tbody></table>
<el-button v-if="operationType !== 'CARTON_LABEL'" @click="item.skuItems.push({ skuCode: '', newSkuCode: '', quantity: 1 })">添加 SKU</el-button>
</section>
<p v-if="!loading && !error && customerId && warehouseId && !options.length" role="status">当前客户及仓库没有可用库存来源。</p>
</section>
</template>
@@ -0,0 +1,28 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import TransferQuantitySummary from './TransferQuantitySummary.vue'
import { getTransferQuantityTotals } from '../api/transferQuantityFacts'
vi.mock('../api/transferQuantityFacts', () => ({ getTransferQuantityTotals: vi.fn() }))
describe('转运完整筛选数量', () => {
it('keeps unknown pieces explicit and renders overlapping pallet facts after paging', async () => {
vi.mocked(getTransferQuantityTotals).mockResolvedValue({ orderCount: '2', declaredPieceCount: null, knownDeclaredPieceCount: '16', unknownDeclaredOrderCount: '1', receivedPieceCount: '20', knownReceivedPieceCount: '20', unknownReceivedOrderCount: '0', declaredCartonCount: '4', receivedCartonCount: '4', receivedPalletCount: '3', availableCartonCount: '2', shippedCartonCount: '2', remainingPalletCount: '2', shippedPalletCount: '2' })
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1, orderType: 'WAREHOUSE_TRANSFER' } } })
await flushPromises()
expect(wrapper.text()).toContain('未记录(已知 16 件,1 票未知)')
expect(wrapper.text()).toContain('20 件 / 4 箱 / 3 托')
expect(wrapper.text()).toContain('剩余 2 托')
expect(wrapper.text()).toContain('2 箱 / 2 托(托盘可重叠)')
await wrapper.setProps({ query: { page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' } })
await flushPromises()
expect(wrapper.text()).toContain('全筛选 2 票')
expect(getTransferQuantityTotals).toHaveBeenLastCalledWith({ page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' })
})
it('shows a recoverable error instead of zero totals', async () => {
vi.mocked(getTransferQuantityTotals).mockRejectedValue(new Error('offline'))
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1 } } })
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('加载失败')
expect(wrapper.text()).not.toContain('全筛选 0')
})
})
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getTransferQuantityTotals, type TransferQuantityTotals } from '../api/transferQuantityFacts'
import type { LastMileOrderQuery } from '../api/lastMileOrders'
const props = defineProps<{ query: LastMileOrderQuery }>()
const summary = ref<TransferQuantityTotals>()
const error = ref('')
let sequence = 0
watch(() => JSON.stringify(props.query), async () => {
const current = ++sequence
summary.value = undefined; error.value = ''
try { const value = await getTransferQuantityTotals(props.query); if (current === sequence) summary.value = value }
catch { if (current === sequence) error.value = '转运全筛选数量加载失败,请重新查询' }
}, { immediate: true })
</script>
<template>
<div v-if="summary" role="status" aria-label="仓储转运全筛选汇总">
<strong>仓储转运 · 全筛选 {{ summary.orderCount }} 票</strong> ·
预报 {{ summary.declaredPieceCount ?? `未记录(已知 ${summary.knownDeclaredPieceCount} 件,${summary.unknownDeclaredOrderCount} 票未知)` }}{{ summary.declaredPieceCount == null ? '' : ' 件' }} / {{ summary.declaredCartonCount }} 箱 ·
历史实收 {{ summary.receivedPieceCount ?? `未记录(已知 ${summary.knownReceivedPieceCount} 件,${summary.unknownReceivedOrderCount} 票未知)` }}{{ summary.receivedPieceCount == null ? '' : ' 件' }} / {{ summary.receivedCartonCount }} 箱 / {{ summary.receivedPalletCount }} 托 ·
当前已清点 {{ summary.availableCartonCount }} 箱 · 剩余 {{ summary.remainingPalletCount }} 托 · 已出库 {{ summary.shippedCartonCount }} 箱 / {{ summary.shippedPalletCount }} 托(托盘可重叠)
</div>
<p v-else-if="error" role="alert">{{ error }}</p>
</template>