Merge origin/dev into release/test preserving current workflows

This commit is contained in:
2026-09-14 11:05:48 +08:00
17 changed files with 328 additions and 24 deletions
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
const page = await read('../pages/dropship-palletization/dropship-palletization.vue')
const api = await read('../api/pda-dropship.js')
test('pending cartons bind to pregenerated pallets with stable retry identity', async () => {
const { stableOperationId } = await import('../utils/idempotency.js')
const payload = { warehouseId: '9007199254740993', palletNo: 'DP-260914-0001', taskVersion: 7 }
assert.equal(stableOperationId('dropship-bind', 'u-task', payload), stableOperationId('dropship-bind', 'u-task', { ...payload }))
assert.notEqual(stableOperationId('dropship-bind', 'u-task', payload), stableOperationId('dropship-bind', 'u-task', { ...payload, taskVersion: 8 }))
assert.match(api, /pallets\/bind-pending/)
assert.doesNotMatch(api, /create-and-bind/)
assert.match(page, /bindDropshipPendingCartons\(this.taskId, this.warehouseId, code, this.task.version\)/)
})
test('palletization uses the test workbench and canonical routes', async () => {
const pages = await read('../pages.json')
assert.match(pages, /pages\/dropship-tasks\/dropship-tasks/)
assert.match(pages, /pages\/dropship-palletization\/dropship-palletization/)
assert.doesNotMatch(pages, /dropship-palletizing/)
assert.match(await read('../pages/home/home.vue'), /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/)
})
test('formal palletization preserves both scan orders, moves and sealing guards', () => {
for (const symbol of ['resolveDropshipCarton', 'countDropshipCarton', 'bindDropshipPendingCartons', 'moveDropshipCarton', 'sealDropshipPallet', 'planDropshipScan', 'restoreDropshipPalletizingDraft']) assert.ok(page.includes(symbol))
assert.match(page, /移回待绑托/)
assert.match(page, /palletSetupReady/)
assert.match(page, /pallet.status !== 'OPEN'/)
assert.match(page, /currentPalletDetails\?\.cartons/)
assert.doesNotMatch(page, /模拟成功|actualSkuQuantity/)
})
test('palletization preserves compact multi-SKU input and printing', () => {
assert.match(page, /v-for="\(line, index\) in skuCounts"/)
assert.match(page, /quantity-stepper/)
assert.match(page, /printAllPallets/)
assert.match(page, /printPallet/)
})
test('count and move use the formal multi-SKU contract with versioned retries', () => {
for (const path of ['cartons/count', 'pallets/bind-pending', '/move', '/seal']) assert.ok(api.includes(path))
for (const field of ['skuLines', 'actualQuantity', 'discrepancyAction', 'discrepancyRemark']) assert.ok(page.includes(field))
assert.match(api, /expectedVersion: pallet.version/)
assert.match(api, /stableOperationId/)
assert.doesNotMatch(api, /actualSkuQuantity|create-and-bind/)
})
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../utils/dropship-palletizing-state.js', import.meta.url), 'utf8')
const state = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)
test('pallet first selects the pallet and later carton resolution carries it', () => {
let scanState = state.createDropshipScanState()
let plan = state.planDropshipScan(scanState, 'dp-260908-0001')
assert.equal(plan.intent, state.DROPSHIP_SCAN_INTENT.SELECT_PALLET)
scanState = state.releaseDropshipScan(plan.state)
plan = state.planDropshipScan(scanState, 'box-001')
assert.deepEqual({ intent: plan.intent, boxCode: plan.boxCode, palletNo: plan.palletNo }, { intent: state.DROPSHIP_SCAN_INTENT.RESOLVE_CARTON, boxCode: 'BOX-001', palletNo: 'DP-260908-0001' })
})
test('boxes first requests batch binding when a pallet is scanned with pending cartons', () => {
const plan = state.planDropshipScan(state.createDropshipScanState(), 'DP-260908-0002', { unboundCartons: 3 })
assert.equal(plan.intent, state.DROPSHIP_SCAN_INTENT.BIND_PENDING)
assert.equal(plan.palletNo, 'DP-260908-0002')
})
test('offline, empty, and immediate repeated scans are blocked before API intent', () => {
assert.equal(state.planDropshipScan(state.createDropshipScanState({ online: false }), 'BOX-001').intent, state.DROPSHIP_SCAN_INTENT.BLOCKED)
assert.equal(state.planDropshipScan(state.createDropshipScanState(), ' ').intent, state.DROPSHIP_SCAN_INTENT.INVALID)
const first = state.planDropshipScan(state.createDropshipScanState(), 'BOX-001')
assert.equal(state.planDropshipScan(first.state, 'BOX-001').intent, state.DROPSHIP_SCAN_INTENT.DUPLICATE)
})
+2 -2
View File
@@ -77,8 +77,8 @@ test('picking task list uses remote loading orders and never falls back to demo
assert.match(tasks, /task\.taskId[\s\S]*?task\.loadingPlanId[\s\S]*?task\.containerBookingId[\s\S]*?task\.currentContainerNo[\s\S]*?task\.version[\s\S]*?task\.assignmentVersion/)
assert.doesNotMatch(tasks, /task\.taskStatus === 'LOADING'/)
assert.doesNotMatch(tasks, /demo-data|DEMO_/)
assert.match(home, /pages\/tasks\/tasks/)
assert.match(modulePage, /pages\/tasks\/tasks/)
assert.match(home, /routes\[operation.name\]/)
assert.match(modulePage, /routes\[operation.name\]/)
})
test('workbench, module, history and profile are independent sources', async () => {
+24
View File
@@ -6,6 +6,30 @@ const source = await readFile(new URL('../api/pda-loading.js', import.meta.url),
const http = await readFile(new URL('../api/http.js', import.meta.url), 'utf8')
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
test('sealed pallets use a single authoritative pallet command and prohibit partial picking', () => {
assert.match(source, /pallets\/pick/)
assert.match(page, /submitImmediateItemAction\(item, 'PICK_PALLET', \{ palletNo: item.palletNo \}\)/)
assert.match(page, /整托货物不能跳拣/)
assert.match(page, /整托货物不能部分拣货/)
assert.match(page, /expectedTaskVersion: Number\(this.pickingVersion\)/)
})
test('DP scan records success only after the atomic server command succeeds', async () => {
const method = page.match(/async pickScannedPallet\(item, palletNo\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
assert.ok(method)
const behavior = new Function('ITEM_STATUS', 'return ({' + method + '})')({ PICKED: 'picked' })
const item = { id: '1', palletNo: 'DP-260914-0001', qty: 5 }
const calls = []
let succeeds = false
const context = { itemStates: {}, partialQuantities: {}, scannedBoxCodes: {}, async submitImmediateItemAction(...args) { calls.push(args); return succeeds }, recordScanResult() {}, showToast() {}, persistSession() {} }
assert.equal(await behavior.pickScannedPallet.call(context, item, item.palletNo), false)
assert.deepEqual(context.itemStates, {})
succeeds = true
assert.equal(await behavior.pickScannedPallet.call(context, item, item.palletNo), true)
assert.deepEqual(context.itemStates, { 1: 'picked' })
assert.deepEqual(calls[0], [item, 'PICK_PALLET', { palletNo: item.palletNo }])
assert.deepEqual(calls[0], calls[1])
})
test('PDA loading adapter uses only task-scoped cabinet evidence paths', () => {
for (const path of [
'/first-leg/pda-loading/tasks/pending'
+64
View File
@@ -4,6 +4,70 @@ import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('opening another task clears prior scan display without deleting scoped draft or submission identity', async () => {
const page = await read('../pages/picking/picking.vue')
const load = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
const record = page.match(/recordScanResult\([^\n]+\) \{[\s\S]*?\r?\n\t\t\t\},/)?.[0]
assert.ok(load && record)
const hooks = new Function('requireSession', 'session', `return ({${load}${record}})`)(() => true, { currentUser: { id: '7' } })
const savedDraft = { 0: { scannedBoxCodes: { 'DP-OLD': 'old-item' }, itemStates: { 'old-item': 'picked' } } }
const context = { scanValue: 'DP-OLD', scanHistory: [{ id: 'old', boxCode: 'DP-OLD', title: '整托已记入草稿' }],
boxDrafts: savedDraft, submitId: 'existing-operation', itemStates: { 'old-item': 'picked' } }
hooks.onLoad.call(context, { taskId: 'new-task', warehouseId: 'warehouse' })
assert.equal(context.scanValue, '')
assert.deepEqual(context.scanHistory, [])
assert.equal(context.remoteTaskId, 'new-task')
assert.equal(context.boxDrafts, savedDraft)
assert.equal(context.submitId, 'existing-operation')
assert.deepEqual(context.itemStates, { 'old-item': 'picked' })
hooks.recordScanResult.call(context, 'success', '整托已记入草稿', 'DP-NEW', '整托 1 箱,提交后保存')
assert.equal(context.scanHistory.length, 1)
assert.equal(context.scanHistory[0].boxCode, 'DP-NEW')
// A same-task route reopen also clears input; canonical scoped restoration still owns draft history.
hooks.onLoad.call(context, { taskId: 'new-task', warehouseId: 'warehouse' })
assert.deepEqual(context.scanHistory, [])
assert.equal(context.boxDrafts, savedDraft)
const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\r?\n\t\t\t\},/)?.[0] || ''
assert.match(restore, /restorePickingSession\(this\.draftIdentity\(\)\)/)
assert.match(restore, /this\.scanHistory = this\.normalizeScanHistory\(restored\.scanHistory\)/)
})
test('duplicate onShow shares one load chain while background return refreshes again', async () => {
const page = await read('../pages/picking/picking.vue')
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
assert.ok(show && hide)
const hooks = new Function('requireSession', 'session', `return ({${show}${hide}})`)(() => true, { currentUser: { id: '7' } })
const calls = []
let release
const pending = new Promise((resolve) => { release = resolve })
const context = { startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
const first = hooks.onShow.call(context)
const duplicate = hooks.onShow.call(context)
assert.deepEqual(calls, ['pending'])
release()
await Promise.all([first, duplicate])
assert.deepEqual(calls, ['pending', 'detail+evidence'])
await hooks.onShow.call(context)
assert.equal(calls.length, 2)
hooks.onHide.call(context)
await hooks.onShow.call(context)
assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence'])
const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
assert.doesNotMatch(onLoad, /refreshAvailableBoxes|syncRemoteTask/)
})
test('camera and evidence preview resume still avoid unnecessary refreshes', async () => {
const page = await read('../pages/picking/picking.vue')
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
const hooks = new Function('requireSession', 'session', `return ({${show}})`)(() => true, { currentUser: {} })
for (const flag of ['cameraPicking', 'evidencePreviewReturning']) {
const context = { [flag]: true, startHardwareScanner() {}, debugEvidence() {}, refreshAvailableBoxes() { throw new Error('unexpected refresh') } }
await hooks.onShow.call(context)
assert.equal(context._pageVisible, true)
}
})
test('direct H5 refresh restores the page user before rendering picking content', async () => {
const page = await read('../pages/picking/picking.vue')
+20
View File
@@ -16,6 +16,26 @@ test('inventory resources expose distinct locations and total available cartons'
assert.deepEqual(state.summarizeInventoryResources(null), { location: '', availablePackageCount: null })
})
test('sealed pallet matches its DP code and resolves member cartons as an atomic pallet', () => {
const pallet = { id: '1', unitType: 'SEALED_PALLET', indivisible: true, palletNo: 'DP-260909-0143', boxCodes: ['IN#2-1', 'IN#2-2'], qty: 2 }
assert.equal(state.classifyScan([pallet], {}, 'IN#2-1').type, 'pallet')
assert.equal(state.classifyScan([pallet], {}, pallet.palletNo).type, 'pallet')
assert.equal(state.classifyScan([pallet], { 1: state.ITEM_STATUS.PICKED }, pallet.palletNo).type, 'processed')
})
test('only the authoritative sealed-pallet unit type selects pallet behavior', () => {
assert.equal(state.isSealedPalletItem({ unitType: 'SEALED_PALLET', indivisible: true }), true)
assert.equal(state.isSealedPalletItem({ unitType: 'PALLET', indivisible: true }), false)
assert.equal(state.isSealedPalletItem({ unitType: 'PACKAGE', indivisible: true }), false)
})
test('mixed task keeps package scanning separate from sealed pallet scanning', () => {
const mixed = [{ id: '1', unitType: 'SEALED_PALLET', palletNo: 'DP-260909-0001', boxCodes: ['BOX-1'] }, { id: '2', unitType: 'PACKAGE', boxCodes: ['BOX-2'] }]
assert.equal(state.classifyScan(mixed, {}, 'BOX-2').item.id, '2')
assert.equal(state.classifyScan(mixed, {}, 'DP-260909-0001').item.id, '1')
assert.equal(state.classifyScan(mixed, {}, 'BOX-1').type, 'pallet')
})
test('createItemStates initializes every item as pending', () => {
assert.deepEqual(state.createItemStates(items), {
1: state.ITEM_STATUS.PENDING,