This commit is contained in:
2026-10-20 10:36:04 +08:00
parent e0bd039052
commit b76b7e4d8f
6 changed files with 144 additions and 76 deletions
+52 -1
View File
@@ -4,7 +4,7 @@ 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', 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: 2, 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' }] })] })))
@@ -101,3 +101,54 @@ test('position picking is bounded by this dispatch allocation and skips already
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('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 }] })]
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')
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.deepEqual(state.sourceChoices.map(row => [row.sourceBusinessNo, row.customerId, state.pickablePackages(row)]), [['RL-1', 'customer-1', 2], ['RL-2', 'customer-2', 5]])
state.chooseSource(state.sourceChoices[1]); state.updatePackages({ detail: { value: '5' } }); await state.submitPick()
assert.equal(sent[1], 'item-2'); assert.equal(sent[2].positionId, 'position-2'); assert.equal(sent[2].packages, 5); assert.equal(state.canComplete, false)
})
test('an ambiguous source carton requires explicit source selection and never picks the first matching item', async () => {
storage(); let sent
const rows = [item({ returnCartons: [{ returnCartonId: 'carton-1', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 2 }] }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', returnCartons: [{ returnCartonId: 'carton-2', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 7 }] })]
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows }) })
state.current = task({ items: rows }); state.handleScan('SHARED-MARK')
assert.equal(state.sourceChoices.length, 2); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
state.chooseSource(state.sourceChoices[1]); await state.submitPick()
assert.equal(sent[1], 'item-2'); assert.equal(sent[2].cartonMark, 'SHARED-MARK'); assert.equal(sent[2].productQuantity, 7); assert.equal(sent[2].positionId, null)
})
test('selection restoration cannot reuse the same item ID for a different logical source', () => {
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 () => {
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 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')
assert.equal('customerId' in sent[2], false)
})
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, /来源业务身份/)
state.selectPallet(item({ sourceKey: '', sourceBusinessType: null, sourceBusinessNo: null })); assert.equal(fulfillment.dispatch.dispatchItemId, '')
})
test('duplicate matching carton facts inside one source fail closed rather than taking its first carton', () => {
storage(); const state = page()
state.current = task({ items: [item({ returnCartons: ['one', 'two'].map(returnCartonId => ({ returnCartonId, cartonMark: 'DUPLICATE', status: 'OUTBOUND_PENDING', productQuantity: 1 })) })] })
state.handleScan('DUPLICATE'); assert.match(state.error.message, /重复待拣箱唛/); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
})
test('two picked logical sources use the single authoritative physical loading row without duplicating measurements', async () => {
storage(); const rows = [item({ pickedPackages: 2 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', pickedPackages: 2 })]
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'one-physical-pallet', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: 1, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: true }, { field: 'volumeCbm', actual: true }] }] }
let sent
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
await state.completeLoading(); assert.deepEqual(sent[1].measurements, [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }])
})
+3 -3
View File
@@ -240,10 +240,10 @@ test('native action gates reject strings and retain server block reasons', () =>
async function dispatchScanHarness(items) {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const draft={palletCode:'OLD',dispatchItemId:'old',cartonCode:''};let sounds=0
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},updateFulfillmentDraft:(_,value)=>Object.assign(draft,value),playSuccessSound:()=>{sounds++}}
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},fulfillment:{dispatch:draft},updateFulfillmentDraft:(_,value)=>Object.assign(draft,value),playSuccessSound:()=>{sounds++}}
const definition=new Function(...Object.keys(deps),source.replace(/^\s*import .*$/gm,'').replace('export default','return'))(...Object.values(deps))
const state={current:{dispatchNo:'DS-TEST',items:items.map(item=>({...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
state.selectPallet=definition.methods.selectPallet.bind(state)
const state={current:{dispatchNo:'DS-TEST',items:items.map(item=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
state.selectPallet=definition.methods.selectPallet.bind(state);state.chooseSource=definition.methods.chooseSource.bind(state)
return {state,draft,scan:definition.methods.handleScan.bind(state),sounds:()=>sounds}
}