This commit is contained in:
2026-10-12 18:01:55 +08:00
parent 785b4bca51
commit 298681e121
8 changed files with 237 additions and 46 deletions
+29
View File
@@ -24,6 +24,35 @@ test('normal SKU input is always visible in SKU tab and never requests the new l
assert.match(source, /matches.length > 1/)
assert.match(source, /scanRelabelSku/)
})
test('return SKU follows ordinary single scan and requires explicit choice when the same SKU spans boxes', async () => {
const calls = []
let value
const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
{}, {}, {}, {}, async (no, payload) => { calls.push(payload); return value.current },
() => ({ value: 'KEY', signature: 'SIG' }), () => {}, { showModal: options => options.complete() }
)
value = context()
Object.assign(value, { busy: false, quickSku: '', skuChoices: [], verifiedReturnCarton: '', returnCartonCode: '',
async run(action, work) { await work() }, applyCurrent(task) { this.current = task },
setError(error) { throw error }
})
for (const name of ['submitSku', 'commitSku']) value[name] = subject.methods[name].bind(value)
value.current.relabelNo = 'RL'; value.current.version = 3
value.current.items = [{ itemId: '1', returnCartonId: 'R1', cartonMark: 'BOX-A', operationType: 'SKU_LABEL', expectedPackages: 1, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 20 }] }]
await value.submitSku('OLD')
assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', newSkuCode: 'NEW', quantity: 20, skuIndex: 0, oldSku: 'OLD', expectedVersion: 3, idempotencyKey: 'KEY' }])
calls.length = 0
value.current.items.push({ ...value.current.items[0], itemId: '2', returnCartonId: 'R2', cartonMark: 'BOX-B' })
await value.submitSku('OLD')
assert.equal(calls.length, 0)
assert.equal(value.skuChoices.length, 2)
await value.commitSku(value.skuChoices[1])
assert.equal(calls.length, 1)
assert.equal(calls[0].itemId, '2')
assert.equal(calls[0].cartonMark, 'BOX-B')
assert.doesNotMatch(source.split('<script>')[0], /name="quickTarget"|placeholder="必填:本来源该 SKU 实际件数"/)
})
test('pending list removes one confirmed row at a time without persistent empty or last-scan banners', () => {
const value = context()
assert.equal(value.pendingSkuRows.length, 2)
+2 -3
View File
@@ -36,11 +36,10 @@ test('quantity is explicit, integral and source bounded; wrong target cannot wri
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 () => {
test('normal return SKU rejects incomplete approved target 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(), /实际件数/)
item.skuItems[0].newSkuCode = ''; 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 }] }] }
+3 -2
View File
@@ -6,9 +6,10 @@ const helper = await readFile(new URL('../src/utils/return-relabel.js', import.m
const { returnDeductions, requiresReturnPack, returnPackFacts } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
const body = page.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', body)({}, {}, {}, {}, requiresReturnPack, returnPackFacts)
test('return carton label identifies source with one carton scan and retains source check for SKU tab', () => {
test('return carton and normal SKU use single scans; explicit source scan is reserved for bulk quantities', () => {
assert.doesNotMatch(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
assert.match(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
assert.doesNotMatch(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
assert.match(page, /v-if="isReturnBulk" class="section-gap"><text class="hint">先扫描来源/)
assert.match(page, /this.activeTarget = value === 'carton' \? 'quick-carton'/)
assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
})
+120 -11
View File
@@ -3,10 +3,118 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
const physicalRow = items => ({ items, length: 10, width: 10, height: 10, weight: 1, originalPackaging: true, confirmed: true })
test('box and parcel confirmation opens the camera only after every valid row is confirmed', () => {
for (const type of ['BOX', 'PARCEL']) {
const cameras = []
const page = setup(async () => ({}), new Map(), 'operator:warehouse', { uni: { chooseImage: options => cameras.push(options) } })
page.unitType = type
page.boxRows = [physicalRow({ 'SKU-TEST': 2 }), physicalRow({ 'SKU-TEST': 3 })]
page.boxRows.forEach(row => { row.confirmed = false })
page.confirmBoxRow(0)
assert.equal(page.boxRows[0].confirmed, true)
assert.equal(cameras.length, 0)
page.confirmBoxRow(1)
assert.equal(cameras.length, 1)
assert.deepEqual(Array.from(cameras[0].sourceType), ['camera'])
page.editBoxRow(0)
assert.equal(page.allBoxRowsConfirmed, false)
assert.equal(page.boxRows[0].confirmed, false)
}
})
test('remaining quantity accounts for other drafts and rejects fractional negative or excessive input', () => {
const page = setup(async () => ({}))
page.unitType = 'BOX'
page.boxRows = [physicalRow({ 'SKU-TEST': 2 }), physicalRow({ 'SKU-TEST': 3 })]
assert.equal(page.boxRemaining(0, page.selectedItems[0]), 7)
for (const quantity of ['7.5', '-1', '8']) {
page.setBoxQuantity(0, 'SKU-TEST', quantity)
assert.equal(page.boxRows[0].items['SKU-TEST'], quantity)
assert.equal(page.boxRowValid(0), false)
page.confirmBoxRow(0)
assert.equal(page.boxRows[0].confirmed, false)
}
page.setBoxQuantity(0, 'SKU-TEST', '7')
assert.equal(page.boxRowValid(0), true)
page.resizeBoxRows(3)
assert.equal(page.boxRows[2].originalPackaging, true)
assert.equal(page.boxRows[2].confirmed, false)
})
test('final photo upload waits for explicit confirmation and failed uploads cannot submit', async () => {
for (const failUpload of [false, true]) {
let camera, requests = []
const page = setup(async request => { requests.push(request); return { fullyMatched: true } }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => { if (failUpload) throw new Error('upload failed'); return { fileId: 'PHOTO-1' } }
})
page.unitType = 'BOX'; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
page.boxRows[0].confirmed = false
page.confirmBoxRow(0)
assert.equal(requests.length, 0)
await camera.success({ tempFilePaths: ['photo.jpg'], tempFiles: [{ size: 100 }] })
assert.equal(requests.length, 0)
await page.confirmReceipt()
assert.equal(requests.length, failUpload ? 0 : 1)
if (failUpload) assert.match(page.error.message, /照片/)
}
})
test('parcel photos accumulate without submitting until explicit receipt confirmation', async () => {
let camera, uploads = 0
const requests = []
const page = setup(async request => { requests.push(request); return { fullyMatched: true } }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => ({ fileId: `PHOTO-${++uploads}` })
})
page.unitType = 'PARCEL'; page.unitCode = ''; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
page.boxRows[0].confirmed = false
await page.confirmReceipt()
assert.equal(requests.length, 0)
page.confirmBoxRow(0)
await camera.success({ tempFilePaths: ['first.jpg'] })
assert.equal(requests.length, 0)
assert.ok(page.current)
page.choosePhoto()
assert.equal(camera.count, 9)
await camera.success({ tempFilePaths: ['second.jpg', 'third.jpg'] })
assert.equal(page.photoFileIds.length, 3)
assert.equal(requests.length, 0)
await page.confirmReceipt()
assert.equal(requests.length, 1)
assert.equal(page.current, null)
})
test('parcel signing requires a photo even for a partial receipt', async () => {
let requests = 0
const page = setup(async () => { requests++; return {} })
page.unitType = 'PARCEL'; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(requests, 0)
assert.match(page.error.message, /照片/)
})
test('editing the draft before the camera returns prevents late photo submission', async () => {
let camera, uploads = 0, requests = 0
const page = setup(async () => { requests++; return {} }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => { uploads++; return { fileId: 'PHOTO-1' } }
})
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
page.choosePhoto(true)
page.editBoxRow(0)
await camera.success({ tempFilePaths: ['late.jpg'] })
assert.equal(uploads, 0)
assert.equal(requests, 0)
})
function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime = {}) {
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) } })
...runtime,
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key), ...runtime.uni } })
const store = readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8')
vm.runInContext(store.replace(/^import .*$/mg, '').replace(/export /g, ''), context)
const page = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
@@ -16,11 +124,12 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
const instance = Object.assign({}, component.methods, {
unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] },
selectedItems: [{ skuCode: 'SKU-TEST' }], photoFileIds: [], trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
selectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photoFileIds: [], photoNames: [], photoUrls: {}, trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
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) })
Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
return instance
}
@@ -61,13 +170,13 @@ 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 ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', boxRows: [{ items: { A: 2 } }], remaining: () => 3 }
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', boxRows: [physicalRow({ A: 2 })], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), true)
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.boxRows = [{ items: { 'SKU-TEST': 2 } }]
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ''; page.trackingNo = ''; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(calls.length, 1)
assert.equal(calls[0].trackingNo, undefined)
@@ -78,7 +187,7 @@ 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.boxRows = [{ items: { 'SKU-TEST': 2 } }]
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(calls[0].trackingNo, 'sf561626')
assert.equal(calls[0].unitCode, undefined)
@@ -87,7 +196,7 @@ test('parcel waybill is submitted as trackingNo and leaves unitCode for the serv
test('batch waybill scans stay on the single parcel waybill input', () => {
const page = setup(async () => ({}))
page.unitType = 'PARCEL'; page.activeTarget = 'unit'; page.palletCode = ''
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.activeTarget = 'unit'; page.palletCode = ''
page.handleScan('WAYBILL-ONE')
assert.equal(page.unitCode, 'WAYBILL-ONE'); assert.equal(page.palletCode, '')
assert.equal(page.activeTarget, 'unit')
@@ -98,7 +207,7 @@ test('batch waybill scans stay on the single parcel waybill input', () => {
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 }
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 4, unitType: 'PARCEL', boxRows: [physicalRow({ A: 2 }), physicalRow({ A: 2 })], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), false)
state.remaining = () => 5
assert.equal(component.computed.canConfirm.call(state), true)
@@ -112,8 +221,8 @@ test('one submission signs multiple mixed-SKU parcels sharing one waybill and in
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 } }]
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ' WAYBILL-BATCH '; page.trackingNo = 'stale'; page.selectedItems = [{ skuCode: 'SKU-TEST', expectedQuantity: 10 }, { skuCode: 'SKU-B', expectedQuantity: 10 }]
page.boxRows = [physicalRow({ 'SKU-TEST': 1, 'SKU-B': 2 }), physicalRow({ '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 }])
@@ -132,7 +241,7 @@ test('failed multi-parcel batch resumes immutable requests without re-signing a
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 } }]
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [physicalRow({ 'SKU-TEST': 1 }), physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(page.receiptBatch.entries[0].result.labels[0].cartonMark, 'RP-1')
failSecond = false