357 lines
19 KiB
JavaScript
357 lines
19 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import vm from 'node:vm'
|
|
|
|
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,
|
|
...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')
|
|
const script = page.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
|
|
const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context)
|
|
vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context)
|
|
const instance = Object.assign({}, component.methods, {
|
|
receiptResults: [], unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
|
|
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] },
|
|
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, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
|
|
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 ='), { 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)
|
|
assert.equal(instance.selectedItems.length, 2)
|
|
assert.equal(instance.selectedItems[1].skuCode, 'SKU-B')
|
|
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
|
|
instance.selectedSku = ''
|
|
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
|
|
instance.selectedSku = 'SKU-FROM-PREVIOUS-ORDER'
|
|
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
|
|
})
|
|
|
|
test('PALLET page submits the required palletCode through the actual confirmation method', async () => {
|
|
const calls = []
|
|
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); return { palletCode: payload.palletCode } })
|
|
await page.confirmReceipt()
|
|
assert.equal(calls.length, 1)
|
|
assert.equal(calls[0].palletCode, 'PLT-TEST')
|
|
assert.equal(calls[0].unitCode, undefined)
|
|
assert.deepEqual(Object.keys(calls[0]).sort(), ['expectedReceivedQuantity', 'height', 'idempotencyKey', 'items', 'length', 'palletCode', 'returnNo', 'startedBy', 'totalBoxCount', 'trackingNo', 'unitType', 'weight', 'width'].sort())
|
|
assert.equal(calls[0].unitType, 'PALLET')
|
|
assert.equal(calls[0].totalBoxCount, 1)
|
|
assert.deepEqual(Object.keys(calls[0].items[0]).sort(), ['boxCount', 'receivedQuantity', 'skuCode'].sort())
|
|
assert.equal(calls[0].returnNo, 'RT-TEST')
|
|
assert.equal(calls[0].trackingNo, 'TRACK-TEST')
|
|
assert.equal(calls[0].items[0].receivedQuantity, 2)
|
|
assert.ok(calls[0].idempotencyKey)
|
|
assert.equal(page.current, null)
|
|
})
|
|
|
|
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: [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.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)
|
|
assert.equal(calls[0].unitCode, undefined)
|
|
assert.equal(calls[0].palletCode, undefined)
|
|
})
|
|
|
|
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.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)
|
|
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.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')
|
|
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: [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)
|
|
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.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 }])
|
|
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 receipt keeps successful results and accepts edits to remaining rows', 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.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [physicalRow({ 'SKU-TEST': 1 }), physicalRow({ 'SKU-TEST': 2 })]
|
|
await page.confirmReceipt()
|
|
assert.equal(page.receiptResults[0].labels[0].cartonMark, 'RP-1'); assert.equal(page.boxRows.length, 1); assert.equal(page.busy, false)
|
|
failSecond = false
|
|
const restored = page; page.boxRows[0].items['SKU-TEST'] = 3
|
|
await restored.confirmReceipt()
|
|
assert.equal(calls.length, 3)
|
|
assert.notEqual(calls[1].idempotencyKey, calls[2].idempotencyKey)
|
|
assert.equal(calls[2].items[0].receivedQuantity, 3)
|
|
assert.deepEqual(calls.map(call => call.expectedReceivedQuantity), [0, 1, 1])
|
|
assert.equal(restored.result.labels.length, 2)
|
|
})
|
|
|
|
test('lost receipt response keeps the old quantity precondition and does not lock editing or refresh', async () => {
|
|
const calls = []
|
|
let received = 0
|
|
const page = setup(async request => {
|
|
calls.push(JSON.parse(JSON.stringify(request)))
|
|
if (request.expectedReceivedQuantity !== received) throw new Error('退货单实收数量已变化')
|
|
received += request.items[0].receivedQuantity
|
|
if (calls.length === 1) throw new Error('response lost after commit')
|
|
return { unitType: 'PARCEL', fullyMatched: false }
|
|
}, new Map(), 'operator:warehouse', {
|
|
startReturnReceipt: async () => ({ returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: received }], photos: [{ fileId: 'PHOTO' }] }),
|
|
downloadReturnPalletPhoto: async () => 'photo-url'
|
|
})
|
|
page.unitType = 'PARCEL'; page.unitCode = ''; page.photoFileIds = ['PHOTO']
|
|
page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
|
|
await page.confirmReceipt()
|
|
await page.confirmReceipt()
|
|
assert.equal(received, 2)
|
|
assert.equal(page.busy, false)
|
|
assert.equal(page.boxRows.length, 1)
|
|
assert.equal(page.error.message, '退货单实收数量已变化')
|
|
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
|
// 用户刷新后,按当前实收量登记下一份实物。
|
|
await page.reloadCurrent()
|
|
assert.equal(page.boxRows[0].confirmed, false)
|
|
assert.deepEqual(Object.keys(page.boxRows[0].items), [])
|
|
await page.confirmReceipt()
|
|
assert.equal(received, 2, '刷新后原包裹不能再次提交')
|
|
assert.equal(calls.length, 2)
|
|
page.boxRows[0] = physicalRow({ 'SKU-TEST': 3 })
|
|
await page.confirmReceipt()
|
|
assert.equal(received, 5)
|
|
assert.deepEqual(calls.map(call => call.expectedReceivedQuantity), [0, 0, 2])
|
|
assert.equal(page.busy, false)
|
|
})
|
|
|
|
test('refresh with unchanged receipt quantities preserves editable input after a rejected request', async () => {
|
|
const page = setup(async () => { throw new Error('not committed') }, new Map(), 'operator:warehouse', {
|
|
startReturnReceipt: async () => ({ returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photos: [{ fileId: 'PHOTO' }] }),
|
|
downloadReturnPalletPhoto: async () => 'photo-url'
|
|
})
|
|
page.unitType = 'PARCEL'; page.unitCode = ''; page.photoFileIds = ['PHOTO']
|
|
page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
|
|
await page.confirmReceipt(); await page.reloadCurrent()
|
|
assert.equal(page.boxRows[0].items['SKU-TEST'], 2)
|
|
assert.equal(page.busy, false)
|
|
})
|
|
|
|
test('partial receipt continues the same order with fresh pallet measurements', async () => {
|
|
const page = setup(async () => ({}))
|
|
page.finishReceipt([{ fullyMatched: false }])
|
|
assert.equal(page.result.continuationReturnNo, 'RT-TEST')
|
|
let reopened
|
|
page.openOrderValue = async returnNo => { reopened = returnNo; page.current = { returnNo } }
|
|
await page.nextReceipt()
|
|
assert.equal(reopened, 'RT-TEST')
|
|
assert.equal(page.result, null)
|
|
assert.equal(page.palletLength, '')
|
|
assert.equal(page.palletWeight, '')
|
|
})
|
|
|
|
test('PALLET response failure keeps editable input and generates a new key for the next click', async () => {
|
|
const calls = []
|
|
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') })
|
|
await page.confirmReceipt()
|
|
assert.equal(page.current.returnNo, 'RT-TEST')
|
|
assert.equal(page.busy, false)
|
|
assert.equal(page.error.message, 'Response lost')
|
|
await page.confirmReceipt()
|
|
assert.equal(calls.length, 2)
|
|
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
|
})
|
|
|
|
test('continue signing returns to SKU scanning and accepts a fresh scan', () => {
|
|
const page = setup(async () => ({}))
|
|
page.finishReceipt([{ fullyMatched: false }])
|
|
page.lookupCode = 'PREVIOUS-SKU'
|
|
page.selectedSku = 'PREVIOUS-SKU'
|
|
page.palletCheck = { palletCode: 'PLT-TEST' }
|
|
page.photoFileIds = ['previous-photo']
|
|
page.busy = true
|
|
page.continueScanning()
|
|
assert.ok(page.result)
|
|
page.busy = false
|
|
page.continueScanning()
|
|
assert.equal(page.result, null)
|
|
assert.equal(page.current, null)
|
|
assert.equal(page.candidates, null)
|
|
assert.equal(page.activeTarget, 'lookup')
|
|
assert.equal(page.entryMode, 'SKU')
|
|
assert.equal(page.lookupCode, '')
|
|
assert.equal(page.selectedSku, '')
|
|
assert.equal(page.palletCheck, null)
|
|
assert.equal(page.palletLength, '')
|
|
assert.equal(page.photoFileIds.length, 0)
|
|
let scanned
|
|
page.lookup = value => { scanned = value }
|
|
page.handleScan('NEXT-SKU')
|
|
assert.equal(scanned, 'NEXT-SKU')
|
|
assert.equal(page.lookupCode, 'NEXT-SKU')
|
|
})
|