This commit is contained in:
2026-10-20 14:24:59 +08:00
parent 79482b4de8
commit e12b5b8763
2 changed files with 52 additions and 16 deletions
+3 -3
View File
@@ -39,8 +39,8 @@ export default {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, pendingCommand() { return fulfillment.dispatch.pendingCommand }, packages() { return fulfillment.dispatch.packages },
selectedItem() { return this.current?.items.find(row => row.dispatchItemId === fulfillment.dispatch.dispatchItemId && row.sourceKey === fulfillment.dispatch.sourceKey) || null },
selectedReturnCarton() { const matches = this.selectedItem?.returnCartons.filter(row => row.cartonMark === fulfillment.dispatch.cartonCode && row.status === 'OUTBOUND_PENDING') || []; return matches.length === 1 ? matches[0] : null },
selectedPosition() { return this.selectedItem?.positions.find(row => row.availablePackages > 0 && row.allocatedPackages > row.pickedPackages) || null },
selectedRemaining() { const remaining = Math.max(0, (this.selectedItem?.scheduledPackages || 0) - (this.selectedItem?.pickedPackages || 0)); return this.selectedItem?.returnCartons.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0, Math.max(0, (this.selectedPosition?.allocatedPackages || 0) - (this.selectedPosition?.pickedPackages || 0))) },
selectedPosition() { return this.selectedItem && this.selectedItem.scheduledPackages > this.selectedItem.pickedPackages ? this.selectedItem.positions.find(row => row.availablePackages > 0) || null : null },
selectedRemaining() { const remaining = Math.max(0, (this.selectedItem?.scheduledPackages || 0) - (this.selectedItem?.pickedPackages || 0)); return this.selectedItem?.returnCartons.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0) },
plannedPackages() { return (this.current?.items || []).reduce((sum, row) => sum + row.scheduledPackages, 0) }, pickedPackages() { return (this.current?.items || []).reduce((sum, row) => sum + row.pickedPackages, 0) }, remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
departed() { return ['DELIVERING', 'DELIVERED'].includes(this.current?.stage) },
canPick() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'LOADING' && this.gate('PICK') && this.selectedRemaining > 0 && (this.selectedItem?.returnCartons.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number.isInteger(Number(this.packages)) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) },
@@ -82,7 +82,7 @@ export default {
},
pickablePackages(item) {
const remaining = Math.max(0, item.scheduledPackages - item.pickedPackages)
return Math.min(remaining, item.returnCartons.length ? item.returnCartons.filter(row => row.status === 'OUTBOUND_PENDING').length : item.positions.reduce((sum, row) => sum + Math.min(row.availablePackages, Math.max(0, row.allocatedPackages - row.pickedPackages)), 0))
return Math.min(remaining, item.returnCartons.length ? item.returnCartons.filter(row => row.status === 'OUTBOUND_PENDING').length : item.positions.reduce((sum, row) => sum + row.availablePackages, 0))
},
selectPallet(item) {
if (this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
+49 -13
View File
@@ -4,13 +4,13 @@ import { readFile } from 'node:fs/promises'
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
const item = (overrides = {}) => ({ dispatchItemId: 'item-1', sourceKey: 'source-1', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'RL-1', customerId: 'customer-1', customerName: '客户甲', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 0, positions: [{ positionId: 'position-1', availablePackages: 2, allocatedPackages: 2, pickedPackages: 0 }], returnCartons: [], ...overrides })
const item = (overrides = {}) => ({ dispatchItemId: 'item-1', sourceKey: 'source-1', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'RL-1', customerId: 'customer-1', customerName: '客户甲', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 0, positions: [{ positionId: 'position-1', availablePackages: 2, allocatedPackages: 0, pickedPackages: 0 }], returnCartons: [], ...overrides })
test('native string quantities normalize without changing Long identity or version', () => {
const id = '9007199254740993'
const value = normalizeDispatchTask(JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', recordVersion: id, items: [item({ dispatchItemId: id, scheduledPackages: '12', pickedPackages: '0', positions: [{ positionId: id, balanceId: id, availablePackages: '12', allocatedPackages: '12', pickedPackages: '0' }], returnCartons: [{ returnCartonId: id, productQuantity: '24' }] })] })))
const value = normalizeDispatchTask(JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', recordVersion: id, items: [item({ dispatchItemId: id, scheduledPackages: '12', pickedPackages: '0', positions: [{ positionId: id, balanceId: id, availablePackages: '12', allocatedPackages: '0', pickedPackages: '0' }], returnCartons: [{ returnCartonId: id, productQuantity: '24' }] })] })))
assert.equal(value.recordVersion, id); assert.equal(value.items[0].dispatchItemId, id)
assert.equal(value.items[0].scheduledPackages, 12); assert.equal(value.items[0].pickedPackages, 0)
assert.deepEqual(value.items[0].positions[0], { positionId: id, balanceId: id, availablePackages: 12, allocatedPackages: 12, pickedPackages: 0 })
assert.deepEqual(value.items[0].positions[0], { positionId: id, balanceId: id, availablePackages: 12, allocatedPackages: 0, pickedPackages: 0 })
assert.equal(value.items[0].returnCartons[0].returnCartonId, id); assert.equal(value.items[0].returnCartons[0].productQuantity, 24)
assert.equal(normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ scheduledPackages: String(Number.MAX_SAFE_INTEGER) })] }).items[0].scheduledPackages, Number.MAX_SAFE_INTEGER)
})
@@ -96,13 +96,29 @@ test('a first server error remains unknown and cannot be discarded as a definite
const state = page({ pickDispatchItem: async () => { throw { code: 'A0500', message: 'server failure' } } })
await state.recoverCommand(); state.discardRejected(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
})
test('position picking is bounded by this dispatch allocation and skips already picked allocations', () => {
storage(); const state = page(); state.current.items[0].positions = [{ positionId: 'already-picked', availablePackages: 20, allocatedPackages: 1, pickedPackages: 1 }, { positionId: 'actual-position', availablePackages: 20, allocatedPackages: 1, pickedPackages: 0 }]
state.selectPallet(state.current.items[0]); assert.equal(state.selectedPosition.positionId, 'actual-position'); assert.equal(state.selectedRemaining, 1)
updateFulfillmentDraft('dispatch', { packages: 2 }); assert.equal(state.canPick, false)
test('position picking uses available stock and this source remaining instead of warehouse allocation or picked buckets', () => {
storage(); const state = page(); state.current = task({ items: [item({ scheduledPackages: '3', pickedPackages: '1', positions: [{ positionId: 'empty-position', availablePackages: '0', allocatedPackages: '20', pickedPackages: '0' }, { positionId: 'actual-position', availablePackages: '20', allocatedPackages: '0', pickedPackages: '7' }] })] })
state.selectPallet(state.current.items[0]); assert.equal(state.selectedPosition.positionId, 'actual-position'); assert.equal(state.selectedRemaining, 2)
assert.equal(state.pickablePackages(state.current.items[0]), 2); assert.equal(state.canPick, true)
updateFulfillmentDraft('dispatch', { packages: 2 }); assert.equal(state.canPick, true)
updateFulfillmentDraft('dispatch', { packages: 3 }); assert.equal(state.canPick, false)
})
for (const scenario of [
{ name: 'zero available stock', scheduled: '2', picked: '0', available: '0', allocated: '20', stockPicked: '0', packages: '1', maximum: 0, position: null },
{ name: 'source already reconciled', scheduled: '1', picked: '1', available: '8', allocated: '0', stockPicked: '0', packages: '1', maximum: 0, position: null },
{ name: 'quantity above this dispatch source remaining', scheduled: '3', picked: '2', available: '9', allocated: '0', stockPicked: '8', packages: '2', maximum: 1, position: 'position-1' },
{ name: 'quantity above this position available stock', scheduled: '5', picked: '0', available: '2', allocated: '0', stockPicked: '0', packages: '3', maximum: 2, position: 'position-1' },
]) test(`real stock buckets prevent a pick for ${scenario.name}`, async () => {
storage(); let posts = 0
const state = page({ pickDispatchItem: async () => { posts++ } })
state.current = task({ pickingReconciled: scenario.scheduled === scenario.picked, items: [item({ scheduledPackages: scenario.scheduled, pickedPackages: scenario.picked, positions: [{ positionId: 'position-1', availablePackages: scenario.available, allocatedPackages: scenario.allocated, pickedPackages: scenario.stockPicked }] })] })
state.selectPallet(state.current.items[0]); state.updatePackages({ detail: { value: scenario.packages } })
assert.equal(state.selectedPosition?.positionId ?? null, scenario.position); assert.equal(state.selectedRemaining, scenario.maximum)
assert.equal(state.pickablePackages(state.current.items[0]), scenario.maximum); assert.equal(state.canPick, false)
await state.submitPick(); assert.equal(posts, 0); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('a shared pallet exposes both relabel sources and picking the second preserves its exact position and quantity', async () => {
storage(); const rows = [item({ scheduledPackages: 3 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', customerName: '客户乙', scheduledPackages: 5, positions: [{ positionId: 'position-2', availablePackages: 5, allocatedPackages: 5, pickedPackages: 0 }] })]
storage(); const rows = [item({ scheduledPackages: 3 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', customerName: '客户乙', scheduledPackages: 5, positions: [{ positionId: 'position-2', availablePackages: 5, allocatedPackages: 0, pickedPackages: 0 }] })]
let sent
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows.map((row, index) => index ? { ...row, pickedPackages: 5 } : row) }) })
state.current = task({ items: rows }); state.handleScan('PALLET-1')
@@ -123,17 +139,37 @@ test('selection restoration cannot reuse the same item ID for a different logica
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
state.current.items[0].sourceKey = 'different-source'; assert.equal(state.selectedItem, null); assert.equal(state.canPick, false)
})
for (const sourceType of ['INBOUND_PLAN', 'RETURN_RECEIPT']) test(`ordinary ${sourceType} null business fields survive real wire normalization and exact picking`, async () => {
for (const sourceType of ['INBOUND_PLAN', 'RETURN_RECEIPT']) test(`ordinary ${sourceType} real 1/0/0 stock and null business fields allow one exact pick`, async () => {
storage(); let sent
const raw = JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', taskNo: 'TASK-1', recordVersion: '0', status: 'ACTIVE', stage: 'LOADING', actions: { PICK: { allowed: true } }, pickingReconciled: false, items: [item({ sourceKey: `${sourceType}:source-1`, sourceBusinessType: null, sourceBusinessNo: null, scheduledPackages: '4', pickedPackages: '0', positions: [{ positionId: '9007199254740993', balanceId: '9007199254740994', availablePackages: '4', allocatedPackages: '4', pickedPackages: '0' }] })] }))
const raw = JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', taskNo: 'TASK-1', recordVersion: '0', status: 'ACTIVE', stage: 'LOADING', actions: { PICK: { allowed: true } }, pickingReconciled: false, items: [item({ sourceKey: `${sourceType}:source-1`, sourceBusinessType: null, sourceBusinessNo: null, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: '9007199254740993', balanceId: '9007199254740994', availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] })] }))
const normalized = normalizeDispatchTask(raw)
assert.equal(normalized.items[0].sourceBusinessType, null); assert.equal(normalized.items[0].sourceBusinessNo, null)
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => normalizeDispatchTask({ ...raw, recordVersion: 1 }) })
state.current = normalized; state.handleScan('PALLET-1'); assert.equal(state.canPick, true)
state.updatePackages({ detail: { value: '4' } }); await state.submitPick()
assert.equal(sent[1], 'item-1'); assert.equal(sent[2].positionId, '9007199254740993'); assert.equal(sent[2].packages, 4); assert.equal(sent[2].recordVersion, '0')
state.current = normalized; state.handleScan('PALLET-1'); assert.equal(state.canPick, true); assert.equal(state.selectedRemaining, 1)
assert.equal(state.pickablePackages(state.current.items[0]), 1)
state.updatePackages({ detail: { value: '1' } }); await state.submitPick()
assert.equal(sent[1], 'item-1'); assert.equal(sent[2].positionId, '9007199254740993'); assert.equal(sent[2].packages, 1); assert.equal(sent[2].recordVersion, '0')
assert.equal('customerId' in sent[2], false)
})
test('shared relabel pallet with real string 1/0/0 positions requires a source choice and picks each exact source once', async () => {
storage(); const sent = []; let version = 5
let rows = ['1', '2'].map(number => item({ dispatchItemId: `item-${number}`, sourceKey: `source-${number}`, sourceBusinessNo: `RL-${number}`, customerId: `customer-${number}`, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: `position-${number}`, availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] }))
const state = page({ pickDispatchItem: async (no, itemId, body) => {
sent.push({ no, itemId, body }); rows = rows.map(row => row.dispatchItemId === itemId ? { ...row, pickedPackages: '1', positions: row.positions.map(position => ({ ...position, availablePackages: '0' })) } : row)
return proof(fulfillment.dispatch.pendingCommand, ++version)
}, getDispatchTask: async () => task({ recordVersion: version, items: rows, pickingReconciled: rows.every(row => row.pickedPackages === '1') }) })
state.current = task({ recordVersion: version, items: rows }); state.handleScan('PALLET-1')
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
assert.deepEqual(state.sourceChoices.map(row => [row.sourceKey, state.pickablePackages(row)]), [['source-1', 1], ['source-2', 1]])
state.chooseSource(state.sourceChoices[1]); assert.equal(state.selectedRemaining, 1); assert.equal(state.canPick, true); await state.submitPick()
assert.equal(state.current.stage, 'LOADING'); assert.equal(state.canComplete, false)
assert.equal(state.current.items[0].pickedPackages, 0); assert.equal(state.current.items[1].pickedPackages, 1)
state.handleScan('PALLET-1'); assert.equal(state.selectedItem.sourceKey, 'source-1'); assert.equal(state.canPick, true); await state.submitPick()
assert.deepEqual(sent.map(entry => [entry.no, entry.itemId, entry.body.positionId, entry.body.packages, entry.body.recordVersion]), [['PC/1', 'item-2', 'position-2', 1, 5], ['PC/1', 'item-1', 'position-1', 1, 6]])
assert.notEqual(sent[0].body.idempotencyKey, sent[1].body.idempotencyKey)
assert.ok(sent.every(entry => !('customerId' in entry.body) && !('warehouseId' in entry.body)))
assert.equal(fulfillment.dispatch.pendingCommand, null); assert.equal(state.canPick, false); assert.equal(state.canComplete, true)
})
test('RELABEL_ORDER still requires its true business number and all sources require sourceKey', () => {
storage(); const state = page()
state.selectPallet(item({ sourceBusinessNo: null })); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.match(state.error.message, /来源业务身份/)