This commit is contained in:
2026-10-12 13:43:37 +08:00
parent 9c3855cec2
commit aebe4f9528
21 changed files with 482 additions and 141 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*impo
function setup(confirm) {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm }) }, redirectTo(options) { calls.push(options) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false, stage: 'receive', taskCode: 'PREVIOUS', error: null, candidates: [{ relabelNo: 'RL-ACTIVE', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
context.promptActiveTask = component.methods.promptActiveTask.bind(context)
return { calls, context, open: () => component.methods.openExecution.call(context, { relabelNo: 'RL-OTHER', taskStatus: 'AVAILABLE' }) }
@@ -34,7 +34,7 @@ test('cancel stays on list and preserves existing task selection', async () => {
test('new relabel task asks whether to start before it can be claimed', async () => {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm: false }) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false }
const confirmed = await component.methods.confirmStartTask.call(context, { relabelNo: 'RL-NEW' })
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', body)({}, {}, {})
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', body)({}, {}, {}, {})
function context() {
const value = { current: { reviewedAt: null, history: [], items: [{ itemId: '1', operationType: 'CARTON_AND_SKU', expectedPackages: 1, skippedPackages: 0, exceptionPackages: 0, skuProcessedPackages: 0, cartonProcessedPackages: 1, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 3 }, { skuCode: 'OTHER', newSkuCode: 'NEW2', quantity: 2 }] }] }, taskTab: 'sku' }
for (const name of ['skuDone', 'skuDetails', 'remaining']) value[name] = component.methods[name].bind(value)
+69 -43
View File
@@ -1,49 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const api = await readFile(new URL('../src/api/relabel.js', import.meta.url), 'utf8')
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const palletWork = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
test('PDA exposes the approved return bulk SKU completion endpoint', () => {
assert.match(api, /completeReturnRelabelSkuTotal/)
assert.match(api, /\/return-skus\/complete-total/)
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const helperSource = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const helpers = await import('data:text/javascript;base64,' + Buffer.from(helperSource).toString('base64'))
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
function setup(confirm = false) {
const calls = [], errors = []
const uni = { showModal(options) { options.success({ confirm }) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', 'completeReturnRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)({}, {}, {}, {}, helpers.requiresReturnPack, helpers.returnPackFacts, async (no, payload) => { calls.push(payload); return value.current }, () => ({ value: 'key', signature: 'signature' }), () => {}, uni)
const item = (id, mark) => ({ itemId: id, returnCartonId: id, cartonMark: mark, returnQuantity: 10, operationType: 'RETURN_BULK_SKU_LABEL', skuCode: 'SKU', newSkuCode: 'TARGET', expectedPackages: 4, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0 })
const value = { current: { relabelNo: 'RL', version: 1, items: [item('1', 'BOX-A'), item('2', 'BOX-B')], history: [] }, busy: false, allProcessed: true, remark: '', photoFileIds: ['photo'], returnCartonCode: '', verifiedReturnCarton: '', returnSkuCode: '', bulkQuantities: {}, returnTargetLabels: {}, setError(error) { errors.push(error.message) }, applyCurrent(result) { this.current = result }, async run(action, work) { await work() } }
for (const [name, fn] of Object.entries(component.methods)) if (!value[name]) value[name] = fn.bind(value)
for (const name of ['isReturnTask', 'isReturnBulk', 'returnBulkSources', 'returnBulkSkuGroups', 'requiresPalletBinding', 'palletTask', 'completionBlockReason']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
return { value, calls, errors }
}
test('SKU alone cannot register or allocate unscanned sources', async () => {
const { value, calls, errors } = setup()
value.verifyReturnSku('SKU'); assert.match(errors.pop(), /先确认来源/)
value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
assert.deepEqual(value.returnBulkSources.map(item => item.cartonMark), ['BOX-A'])
value.returnTargetLabels['2'] = 'TARGET'; value.bulkQuantities['2'] = '4'
await value.completeBulkSku(value.current.items[1]); assert.equal(calls.length, 0)
})
test('quantity is explicit, integral and source bounded; wrong target cannot write', async () => {
const { value, calls } = setup(); value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
const item = value.current.items[0]; value.returnTargetLabels['1'] = 'TARGET'
for (const quantity of ['', '1.5', '5', '0']) { value.bulkQuantities['1'] = quantity; await value.completeBulkSku(item) }
assert.equal(calls.length, 0)
value.bulkQuantities['1'] = '2'; value.returnTargetLabels['1'] = 'WRONG'; await value.completeBulkSku(item); assert.equal(calls.length, 0)
value.returnTargetLabels['1'] = 'TARGET'; await value.completeBulkSku(item)
assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', skuCode: 'SKU', quantity: 2, expectedVersion: 1, idempotencyKey: 'key' }])
})
test('all action needs confirmation and rescanning clears quantity and target', async () => {
for (const confirm of [false, true]) { const { value } = setup(confirm); value.verifyReturnCarton('BOX-A'); await value.fillAllReturn(value.current.items[0]); assert.equal(value.bulkQuantities['1'], confirm ? '4' : undefined); value.verifyReturnCarton('BOX-B'); assert.deepEqual(value.bulkQuantities, {}); assert.deepEqual(value.returnTargetLabels, {}) }
})
test('normal return SKU requires source and explicit SKU pieces before server scan', async () => {
const { value, errors } = setup(); const item = { ...value.current.items[0], operationType: 'SKU_LABEL', expectedPackages: 1, skuItems: [{ skuCode: 'SKU', newSkuCode: 'TARGET', quantity: 3 }] }
value.current.items = [item]; value.pendingSkuRows = [{ item, sku: item.skuItems[0], index: 0 }]
await value.submitSku('SKU'); assert.match(errors.pop(), /先扫描/)
value.verifyReturnCarton('BOX-A'); value.quickSku = 'SKU'; await value.commitSku(value.pendingSkuRows[0]); assert.match(errors.pop(), /实际件数/)
})
test('packing uses SKU pieces instead of box count and preserves both sources', () => {
const task = { items: [{ returnCartonId: '1', cartonMark: 'A', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 8, skuItems: [{ skuCode: 'SKU', quantity: 3 }] }, { returnCartonId: '2', cartonMark: 'B', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 2, skuItems: [{ skuCode: 'SKU', quantity: 2 }] }] }
assert.deepEqual(helpers.returnDeductions(task), [{ cartonMark: 'A', skuCode: 'SKU', quantity: 3 }, { cartonMark: 'B', skuCode: 'SKU', quantity: 2 }]); assert.equal(helpers.requiresReturnPack(task), true)
task.items[0].returnQuantity = 3; assert.equal(helpers.requiresReturnPack(task), false)
})
test('only truck requires return pallet binding and counts physical new boxes', () => {
const { value } = setup(); for (const method of [null, 'EXPRESS', 'EXPRESS_PICKUP', 'TRUCK']) { value.current.deliveryMethod = method; assert.equal(value.requiresPalletBinding, method === 'TRUCK') }
value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.relabelledPackages = item.expectedPackages })
assert.equal(value.palletTask.items.length, 1); assert.equal(value.palletTask.items[0].expectedPackages, 1)
})
test('pack recovery ignores previous rework round', () => {
const task = { reviewedAt: 'ROUND2', history: [{ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'OLD' }) }, { action: 'RETURN_PACK', detail: JSON.stringify({ round: 'ROUND2', cartonMark: 'NEW' }) }] }
assert.deepEqual(helpers.returnPackFacts(task).map(row => row.cartonMark), ['NEW'])
})
test('return relabel pallet binding validates pieces while binding physical carton marks', () => {
assert.match(palletWork, /isReturnBulk\(\).*RETURN_BULK_SKU_LABEL/)
assert.match(palletWork, /count === packages && this\.completedItems\(\)\.length === this\.eligibleItems\.length && this\.marks\.length === this\.eligibleItems\.length/)
assert.match(palletWork, /this\.isReturnBulk \? this\.eligibleItems : this\.eligibleItems\.slice\(0, count\)/)
assert.match(page, /requiredMarks[\s\S]*some\(\(mark\) => !marks\.has\(mark\)\)/)
})
test('return relabel mode works by SKU remaining quantity without carton-mark scanning', () => {
assert.match(page, /current\?\.operationType === 'RETURN_BULK_SKU_LABEL'/)
assert.match(page, /退货贴标 SKU 明细/)
assert.match(page, /name="returnSku"[\s\S]*@submit="verifyReturnSku"/)
assert.match(page, /activeTarget === 'return-sku'[\s\S]*this\.verifyReturnSku\(value\)/)
assert.match(page, /verifiedReturnSkuKey !== group\.key/)
assert.match(page, /Number\.isSafeInteger\(quantity\)[\s\S]*quantity > group\.remaining/)
assert.match(page, /returnBulkSkuGroups/)
assert.match(page, /来源托盘拆箱数量/)
assert.match(page, /登记本次贴标.*group\.remaining/)
assert.match(page, /completeReturnRelabelSkuTotal/)
assert.match(page, /taskTab === 'carton' && !isReturnBulk/)
})
test('new relabel pallet is immediately copied into the target scan field', () => {
assert.match(palletWork, /this\.targetCode = this\.newPallet\.palletCode \|\| ''/)
assert.match(palletWork, /退货换标无需扫描箱唛/)
})
test('return relabel distinguishes a whole pallet from a partial unpack', () => {
assert.match(palletWork, /wholeSource\(\).*plannedPackages === Number\(this\.selectedSource\.stockPackages/)
assert.match(palletWork, /eligibleItems\(\).*this\.isReturnBulk \? this\.sourceItems\(\) : this\.completedItems\(\)/)
assert.match(palletWork, /this\.selectedDestination = this\.sourceItems\(\)\[0\]/)
assert.match(palletWork, /this\.completedItems\(\)\.length !== this\.eligibleItems\.length/)
assert.match(palletWork, /贴标数量等于本托盘全部/)
assert.match(palletWork, /贴标数量少于本托盘库存/)
assert.match(palletWork, /v-if="!isReturnBulk"[\s\S]*name="relabel-source"/)
assert.match(palletWork, /await this\.run\(async \(\) => \{ const value = await resolvePallet\(row\.palletCode\)/)
test('partial return completion requires current-round independent box then truck binding and photo', () => {
const { value } = setup()
value.current.items.forEach(item => { item.relabelledPackages = item.expectedPackages })
value.current.relabelledPackages = 8
value.current.deliveryMethod = 'EXPRESS'
assert.match(value.completionBlockReason, /独立新箱/)
value.current.history.push({ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'NEW' }) })
value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.returnQuantity = 8 })
assert.equal(value.completionBlockReason, '')
value.current.deliveryMethod = 'TRUCK'
assert.match(value.completionBlockReason, /组托/)
value.current.history.push({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['NEW'] }) })
assert.equal(value.completionBlockReason, '')
value.photoFileIds = []
assert.match(value.completionBlockReason, /照片/)
})
@@ -0,0 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const { returnDeductions } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
test('return carton label path requires source confirmation before carton scan', () => {
assert.match(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
})
test('mixed return carton keeps one physical package while SKU deductions use pieces', () => {
const task={items:[{returnCartonId:'1',cartonMark:'P1',operationType:'CARTON_AND_SKU',returnQuantity:5,skuItems:[{skuCode:'S',quantity:5}]}]}
assert.deepEqual(returnDeductions(task),[{cartonMark:'P1',skuCode:'S',quantity:5}])
})
+31
View File
@@ -0,0 +1,31 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
const source = await readFile(new URL('../src/components/ReturnRelabelPackWork.vue', import.meta.url), 'utf8')
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
function setup() {
const calls = [], events = []
const component = new Function('requiresReturnPack','returnDeductions','returnPackFacts','returnSelections','packReturnRelabelCartons','warehouseIdempotencyKey','releaseWarehouseKey',body)(requiresReturnPack,returnDeductions,returnPackFacts,returnSelections,async(no,payload)=>{calls.push(payload);return {relabelNo:no}},()=>({value:'stable',signature:'s'}),()=>{})
const item = (mark,done) => ({returnCartonId:mark,cartonMark:mark,operationType:'SKU_LABEL',expectedPackages:1,relabelledPackages:done,skippedPackages:0,exceptionPackages:0,skuItems:[{skuCode:'SKU',quantity:3}]})
const value = {...component.data(),task:{relabelNo:'RL',items:[item('A',1),item('B',0)],history:[]},$emit:(...args)=>events.push(args)}
for(const[name,fn]of Object.entries(component.methods))value[name]=fn.bind(value)
for(const[name,fn]of Object.entries(component.computed))Object.defineProperty(value,name,{get:fn.bind(value)})
return {value,calls,events}
}
test('packing cannot run with an unfinished source, missing measurement, skipped or exception fact',async()=>{
const {value,calls}=setup(); await value.pack();assert.equal(calls.length,0)
value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
assert.equal(value.ready,false);value.task.items[1].relabelledPackages=1;assert.equal(value.ready,true)
value.task.items[0].skippedPackages=1;assert.equal(value.ready,false);value.task.items[0].skippedPackages=0
value.task.items[0].exceptionPackages=1;assert.equal(value.ready,false)
})
test('pack sends every source with exact SKU piece deductions and generated target; saved history prevents repeat',async()=>{
const {value,calls,events}=setup();value.task.items[1].relabelledPackages=1;value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
await value.pack()
assert.deepEqual(calls,[{sourceCartonMarks:['A','B'],targetCartonMark:null,sourceDeductions:[{cartonMark:'A',skuCode:'SKU',quantity:3},{cartonMark:'B',skuCode:'SKU',quantity:3}],length:20,width:30,height:40,weight:2,idempotencyKey:'stable'}])
assert.ok(events.some(row=>row[0]==='saved'))
value.task.history=[{action:'RETURN_PACK',detail:JSON.stringify({round:'INITIAL',cartonMark:'NEW',quantity:6,label:{printableHtml:'<html/>'}})}]
assert.equal(value.ready,false);assert.equal(value.facts[0].cartonMark,'NEW')
})
+6 -6
View File
@@ -12,17 +12,17 @@ test('box merge component compiles both its script and template', () => {
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
})
function instance() {
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, component: null })
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null })
const work = { ...component.data(), plan: {customerId:'C1',sources:[{cartonMark:'A',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',items:[{skuCode:'SKU1',quantity:2}]}]}, $emit(){} }
for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
work.save = () => {}
return {work,component}
}
test('new plan box displays all sources without scanning and keeps independent SKU deductions', async () => {
test('new plan requires scanning each source and keeps independent SKU deductions', async () => {
const {work,component}=instance(); work.choose('NEW')
assert.equal(work.sources.length,2)
assert.equal(work.active,'')
await work.acceptScan('A')
assert.equal(work.sources.length,0)
assert.equal(work.active,'source')
await work.acceptScan('A'); await work.acceptScan('B')
work.setDeduction('A','SKU1',1); work.setDeduction('A','SKU2',2); work.setDeduction('B','SKU1',2)
work.dimensions={length:10,width:20,height:30,weight:1}
assert.equal(work.sources.length,2)
@@ -36,7 +36,7 @@ test('existing box uses another box as target and never removes target inventory
assert.equal(work.plannedItems().length,1)
assert.equal(work.target.items[0].quantity,2)
assert.equal(component.computed.canComplete.call(work),true)
work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,2)
work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,0)
assert.equal(work.plannedItems().length,0)
})
+30
View File
@@ -44,6 +44,7 @@ function scanState() {
open(row) { this.task = row }, $nextTick(callback) { callback() } }
value.run = scope.page.methods.run.bind(value)
value.enterTask = scope.page.methods.enterTask.bind(value)
value.offerNextPalletTask = scope.page.methods.offerNextPalletTask.bind(value)
return value
}
@@ -63,6 +64,35 @@ test('box and single-batch pallet scans claim and open counting directly without
}
})
test('completed pallet offers remaining batches and starts only after confirmation', async () => {
for (const confirm of [false, true]) {
const value = scanState(); value.task = null
const next = { receiptNo: 'RR-NEXT', palletCode: 'PLT-1', status: 'PENDING' }
scope.scanReturnCountTasks = async code => { assert.equal(code, 'PLT-1'); return [next, { ...next, receiptNo: 'RR-DONE', status: 'CONFIRMED' }, { ...next, palletCode: 'PLT-2' }] }
scope.getCurrentReturnCountTask = async () => null
let claimed = 0
scope.beginReturnCountTask = async receiptNo => { claimed++; assert.equal(receiptNo, 'RR-NEXT'); return { ...next, status: 'COUNTING' } }
value.ask = async options => { assert.match(options.content, /还有 1 项/); assert.equal(options.cancelText, '暂不清点'); return { confirm } }
await value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' })
assert.equal(claimed, confirm ? 1 : 0)
assert.equal(value.task?.status || null, confirm ? 'COUNTING' : null)
}
})
test('no remaining pallet task skips prompt and claim conflicts remain visible', async () => {
const value = scanState(); value.task = null
value.ask = async () => assert.fail('no remaining task should not prompt')
scope.scanReturnCountTasks = async () => []
await value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' })
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEXT', palletCode: 'PLT-1', status: 'COUNTING' }]
value.ask = async () => ({ confirm: true })
scope.getCurrentReturnCountTask = async () => null
scope.beginReturnCountTask = async () => { throw Object.assign(new Error('该任务正在由其他人员清点'), { code: 'A0409' }) }
await value.run(() => value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' }))
assert.equal(value.error.code, 'A0409')
assert.equal(value.task, null)
})
test('direct scan cannot claim a different task while the operator has unfinished work', async () => {
const value = scanState()
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEW', returnNo: 'RT-NEW' }]
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
const filename = 'src/components/ReturnLabelConfirmation.vue'
const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.url), 'utf8'))
test('physical label confirmation compiles and requires its own barcode before calling the service', async () => {
compileScript(descriptor, { id: 'return-label' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'return-label' }).errors, [])
const calls = []
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), {
component: null, ScanTargetInput: {}, confirmReturnLabel: async mark => calls.push(mark)
})
const label = { ...component.data(), cartonMark: 'RP-1' }
for (const [name, method] of Object.entries(component.methods)) label[name] = method.bind(label)
await label.confirm('RP-OTHER'); assert.equal(calls.length, 0); assert.equal(label.confirmed, false)
await label.acceptScan('RP-1'); assert.deepEqual(calls, ['RP-1']); assert.equal(label.confirmed, true)
await label.confirm('RP-1'); assert.equal(calls.length, 1)
})
+8 -6
View File
@@ -9,7 +9,7 @@ const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.
function instance(api = {}) {
let saved = {}, reset = false
const component = vm.runInNewContext(descriptor.script.content.replace(/^\s*import .*$/gm, '').replace('export default', 'component ='), {
component: null, PortalHeader: {}, ScanTargetInput: {}, ReturnBoxMergeWork: {}, session: {currentUser:{}}, playSuccessSound() {},
component: null, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ReturnBoxMergeWork: {}, session: {currentUser:{}}, playSuccessSound() {},
updateWarehouseWork(domain, patch) { saved = {...saved,...patch} }, resetWarehouseDomain() { reset = true }, ...api
})
const page = {...component.data(), activePlan:{planNo:'P1'}, busy:true}
@@ -32,17 +32,19 @@ test('unified scan resolves an actual carton and passes its SKU contents into bo
assert.equal(page.mode,'BOX');assert.equal(page.initialSource,carton)
assert.equal(saved().initialSource.items[0].quantity,2);assert.equal(skuCalls,0)
})
test('only carton not-found falls back to customer SKU stock; conflicts do not fall back', async () => {
test('unknown SKU cannot silently choose parcel sources; occupied sources retain the conflict', async () => {
let skuCalls=0, queryError={code:'A0404'}
const {page}=instance({scanReturnCarton:async()=>{throw queryError},findPackingOrders:async()=>{skuCalls++;return {customers:[{customerId:'C1',orders:[{returnNo:'R1'}],skus:[{skuCode:'SKU1',availableQuantity:3}]}]}}})
const {page}=instance({scanReturnCarton:async()=>{throw queryError},findPackingOrders:async()=>{skuCalls++;return {}}})
page.activePlan=null;page.busy=false;page.selectedOrders=[];page.searchCode=''
page.setError=error=>{page.error=error}
await page.search('SKU1')
assert.equal(page.mode,'SKU');assert.equal(page.selectedCustomer.skus[0].availableQuantity,3)
assert.match(page.error.message,/扫描来源包裹/)
assert.equal(skuCalls,0)
queryError={code:'A0409',message:'箱库存被占用'}
await page.search('LOCKEDBOX')
assert.equal(skuCalls,1);assert.equal(page.error.code,'A0409')
await page.search('BOX1')
assert.equal(page.error.message,'箱库存被占用');assert.equal(skuCalls,0)
})
test('existing target immediately clears work and returns to refreshed plans even during completion', () => {
const {page,reset}=instance()
page.packingCompleted({targetMode:'EXISTING',labels:[{cartonMark:'B'}]})
+67 -7
View File
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
import vm from 'node:vm'
function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
const context = vm.createContext({ reactive: value => value, PortalHeader: {}, ScanTargetInput: {},
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {},
playSuccessSound() {}, playErrorSound() {}, confirmReturnReceipt: confirm,
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key) } })
const store = readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8')
@@ -20,13 +20,14 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
quantity: () => 2, boxCount: () => 1
})
Object.defineProperty(instance, 'receiptBatch', { get: () => vm.runInContext('warehouseWork.returnReceipt.pendingBatch', context) })
return instance
}
test('SKU lookup displays all pending order lines while retaining scanned SKU for pallet generation', () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { PortalHeader: {}, ScanTargetInput: {} })
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const lines = [{ skuCode: 'SKU-A', expectedQuantity: 6 }, { skuCode: 'SKU-B', expectedQuantity: 4 }]
const instance = { current: { expectedItems: lines }, selectedSku: 'SKU-A' }
instance.selectedItems = component.computed.selectedItems.call(instance)
@@ -59,14 +60,14 @@ test('PALLET page submits the required palletCode through the actual confirmatio
test('parcel signing allows an empty code but still rejects quantities above the remaining amount', async () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { PortalHeader: {}, ScanTargetInput: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', unitCode: '', quantity: () => 2, remaining: () => 3 }
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', boxRows: [{ items: { A: 2 } }], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), true)
state.quantity = () => 4
state.boxRows[0].items.A = 4
assert.equal(component.computed.canConfirm.call(state), false)
const calls = []
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
page.unitType = 'PARCEL'; page.unitCode = ''; page.trackingNo = ''
page.unitType = 'PARCEL'; page.unitCode = ''; page.trackingNo = ''; page.boxRows = [{ items: { 'SKU-TEST': 2 } }]
await page.confirmReceipt()
assert.equal(calls.length, 1)
assert.equal(calls[0].trackingNo, undefined)
@@ -77,13 +78,72 @@ test('parcel signing allows an empty code but still rejects quantities above the
test('parcel waybill is submitted as trackingNo and leaves unitCode for the server', async () => {
const calls = []
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
page.unitType = 'PARCEL'; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'
page.unitType = 'PARCEL'; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'; page.boxRows = [{ items: { 'SKU-TEST': 2 } }]
await page.confirmReceipt()
assert.equal(calls[0].trackingNo, 'sf561626')
assert.equal(calls[0].unitCode, undefined)
assert.equal(calls[0].palletCode, undefined)
})
test('batch waybill scans stay on the single parcel waybill input', () => {
const page = setup(async () => ({}))
page.unitType = 'PARCEL'; page.activeTarget = 'unit'; page.palletCode = ''
page.handleScan('WAYBILL-ONE')
assert.equal(page.unitCode, 'WAYBILL-ONE'); assert.equal(page.palletCode, '')
assert.equal(page.activeTarget, 'unit')
page.handleScan('WAYBILL-CORRECTED')
assert.equal(page.unitCode, 'WAYBILL-CORRECTED')
})
test('parcel rows must be nonempty and summed quantities cannot exceed the order remainder', () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 4, unitType: 'PARCEL', boxRows: [{ items: { A: 2 } }, { items: { A: 2 } }], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), false)
state.remaining = () => 5
assert.equal(component.computed.canConfirm.call(state), true)
state.boxRows[1].items = {}
assert.equal(component.computed.canConfirm.call(state), false)
})
test('one submission signs multiple mixed-SKU parcels sharing one waybill and independent labels', async () => {
const calls = []
const page = setup(async request => {
calls.push(JSON.parse(JSON.stringify(request)))
return { unitType: 'PARCEL', labels: [{ cartonMark: `RP-${calls.length}`, printableHtml: 'label' }], fullyMatched: false }
})
page.unitType = 'PARCEL'; page.unitCode = ' WAYBILL-BATCH '; page.trackingNo = 'stale'; page.selectedItems = [{ skuCode: 'SKU-TEST' }, { skuCode: 'SKU-B' }]
page.boxRows = [{ items: { 'SKU-TEST': 1, 'SKU-B': 2 } }, { items: { 'SKU-TEST': 3 } }]
await page.confirmReceipt()
assert.equal(calls.length, 2)
assert.deepEqual(calls[0].items, [{ skuCode: 'SKU-TEST', receivedQuantity: 1 }, { skuCode: 'SKU-B', receivedQuantity: 2 }])
assert.deepEqual(calls[1].items, [{ skuCode: 'SKU-TEST', receivedQuantity: 3 }])
assert.equal(calls[0].trackingNo, 'WAYBILL-BATCH'); assert.equal(calls[1].trackingNo, 'WAYBILL-BATCH')
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.deepEqual(Array.from(page.result.labels, row => row.cartonMark), ['RP-1', 'RP-2'])
})
test('failed multi-parcel batch resumes immutable requests without re-signing a successful parcel', async () => {
const saved = new Map(), calls = []
let failSecond = true
const confirm = async request => {
calls.push(JSON.parse(JSON.stringify(request)))
if (request.items[0].receivedQuantity === 2 && failSecond) throw new Error('response lost')
return { unitType: 'PARCEL', labels: [{ cartonMark: `RP-${request.items[0].receivedQuantity}`, printableHtml: 'label' }], fullyMatched: false }
}
const page = setup(confirm, saved)
page.unitType = 'PARCEL'; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [{ items: { 'SKU-TEST': 1 } }, { items: { 'SKU-TEST': 2 } }]
await page.confirmReceipt()
assert.equal(page.receiptBatch.entries[0].result.labels[0].cartonMark, 'RP-1')
failSecond = false
const restored = setup(confirm, saved)
await restored.confirmReceipt()
assert.equal(calls.length, 3)
assert.equal(calls[1].idempotencyKey, calls[2].idempotencyKey)
assert.deepEqual(calls[1], calls[2])
assert.equal(restored.result.labels.length, 2)
})
test('partial receipt continues the same order with fresh pallet measurements', async () => {
const page = setup(async () => ({}))
page.finishReceipt([{ fullyMatched: false }])
+2 -2
View File
@@ -91,7 +91,7 @@ test('carton relabel uses one scan input and shows the confirmed FBA mapping aft
assert.match(scan, /confirmedItem\?\.fbaCartonMark/)
})
test('mixed relabel keeps SKU work open and return bulk also mounts pallet work', async () => {
test('mixed relabel keeps SKU work open and return transport gates pallet work', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
const body = page.match(/remaining\(item\) \{([^\n]+)\},/)[1]
const remaining = new Function('item', body)
@@ -99,7 +99,7 @@ test('mixed relabel keeps SKU work open and return bulk also mounts pallet work'
cartonProcessedPackages: 1, skuProcessedPackages: 0, skippedPackages: 0, exceptionPackages: 0 }
assert.equal(remaining.call({ taskTab: 'sku' }, mixed), 1)
assert.equal(remaining.call({ taskTab: 'carton' }, mixed), 0)
assert.match(page, /<RelabelPalletWork v-if="taskTab === 'carton' \|\| isReturnBulk"/)
assert.match(page, /<RelabelPalletWork v-if="requiresPalletBinding && \(taskTab === 'carton' \|\| isReturnTask\)"/)
})
test('relabel list confirms before starting a new task and completion returns to the list', async () => {