Merge branch 'release/test' of http://git.buggoglobal.com/html/pda-overseas-uniapp into release/test

This commit is contained in:
2026-10-19 17:57:00 +08:00
15 changed files with 1077 additions and 95 deletions
+49
View File
@@ -27,6 +27,16 @@ test('scanning a discard outbound document opens the discard workflow with its l
assert.equal(vm.discardCurrent.outboundNo, 'OB-1')
})
test('退货废弃一箱五件分别展示,混装SKU按箱唛去重', () => {
const { vm } = page()
vm.discardTask = { cartonMarks: ['RETURN-BOX-1'], skus: [{ quantityUnit: '件', plannedPackages: 5 }] }
assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
vm.discardTask.skus = [{ quantityUnit: '件', plannedPackages: 2 }, { quantityUnit: '件', plannedPackages: 3 }]
assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
vm.discardTask = { cartonMarks: ['BOX-1', 'BOX-1'], skus: [{ quantityUnit: '箱', plannedPackages: 1 }] }
assert.equal(vm.discardQuantitySummary, '1 箱')
})
test('discard pallet resolution passes the selected discard number', async () => {
let received
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
@@ -38,6 +48,45 @@ test('discard pallet resolution passes the selected discard number', async () =>
assert.deepEqual(received, ['P-1', 'CD-2'])
})
test('点击打开废弃作业使用输入框值,不把 tap 事件当作托盘号', async () => {
let received
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
received = args
return { discardNo: 'CD-2', palletNo: 'P-1', skus: [] }
} })
vm.discardCurrent = { discardNo: 'CD-2' }
vm.discardPalletCode = ' P-1 '
await vm.resolveDiscardPallet({ type: 'tap', detail: { x: 10, y: 20 } })
assert.deepEqual(received, ['P-1', 'CD-2'])
assert.equal(vm.discardPalletCode, 'P-1')
})
test('同单按箱废弃允许扫描当前托盘及其他托盘所选箱唛,范围不扩展到其他单据', async () => {
const requests = []
const tasks = [
{ discardNo: 'CD-1', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-1'], skus: [], status: 'PENDING' },
{ discardNo: 'CD-1', palletNo: 'P-2', scanMode: 'CARTON', cartonMarks: ['BOX-2'], skus: [], status: 'PENDING' },
{ discardNo: 'CD-OTHER', palletNo: 'P-3', scanMode: 'CARTON', cartonMarks: ['BOX-3'], skus: [], status: 'PENDING' },
]
const { vm } = page({ resolveCargoDiscardPallet: async code => {
requests.push(code)
return tasks.find(task => task.palletNo === code || task.cartonMarks.includes(code))
} })
vm.discardTasks = tasks
vm.openDiscardOrder('CD-1')
await Promise.resolve()
assert.equal(vm.discardCurrentTasks.length, 2)
await vm.resolveDiscardPallet('P-1')
assert.equal(vm.error, null)
await vm.resolveDiscardPallet('BOX-2')
assert.equal(vm.discardTask.palletNo, 'P-2')
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
await vm.resolveDiscardPallet('BOX-3')
assert.deepEqual(requests, ['P-1', 'BOX-2'])
assert.equal(vm.discardTask.palletNo, 'P-2')
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
})
test('discard requires restored photos and retains task while native camera returns', async () => {
const { vm, definition } = page({ requireSession: () => true, restoreFulfillmentDraft() {} })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 3 }] }; vm.discardActuals = { sku: 3 }
+92 -2
View File
@@ -1,14 +1,16 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
const script = source.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
function setup() {
const calls = []
const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'bindRelabelPallet', script)(
{}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
() => ({ value: 'stable-key', signature: 'signature' }), () => {})
() => ({ value: 'stable-key', signature: 'signature' }), () => {}, async payload => calls.push(payload))
const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
return { component, value, calls }
@@ -33,3 +35,91 @@ test('only whole physical pallet quantities offer original pallet reuse', () =>
ctx.plannedPackages = 10;ctx.isReturnBulk = true
assert.equal(canRetain.call(ctx), false)
})
async function renderMeasurements(measurements) {
const { component } = setup()
const template = source.slice(source.indexOf('<template>') + 10, source.indexOf('<script>')).trim().replace(/<\/template>$/, '')
return renderToString(createSSRApp({ ...component, template,
components: { ScanTargetInput: { template: '<view />' } },
props: undefined,
data() { return { ...component.data(), task: { relabelNo: 'RL-1', items: [], history: [] }, measurements } }
}))
}
test('unchanged source renders real grouped box specs and totals without zero pallet dimension inputs', async () => {
const html = await renderMeasurements([
{ palletCode: 'FIRST', packages: 2, inherited: true, lengthCm: null, widthCm: null, heightCm: null,
grossWeightKg: 5, volumeCbm: 0.3, sourceCartonSpecs: [{ lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5, cartons: 2 }] },
{ palletCode: 'SECOND', packages: 3, inherited: true, lengthCm: null, widthCm: null, heightCm: null,
grossWeightKg: 9.7, volumeCbm: 0.31, sourceCartonSpecs: [
{ lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5, cartons: 1 },
{ lengthCm: 50, widthCm: 40, heightCm: 40, grossWeightKg: 3.6, cartons: 2 }] }
])
assert.match(html, /60 × 50 × 50 cm · 2.5 kg\/箱 · 2 箱/)
assert.match(html, /50 × 40 × 40 cm · 3.6 kg\/箱 · 2 箱/)
assert.match(html, /来源汇总:0.31 m³ · 9.7 kg/)
assert.doesNotMatch(html, /未测整托长宽高/)
assert.equal((html.match(/class="measurement-inputs"/g) || []).length, 0)
assert.doesNotMatch(html, /确认本托实测/)
})
test('changed pallets and explicit packaging changes still render final pallet measurement inputs', async () => {
for (const pallet of [{ inherited: false }, { inherited: true, remeasuring: true }]) {
const html = await renderMeasurements([{ ...pallet, palletCode: 'P', packages: 1 }])
assert.match(html, /class="measurement-inputs"/)
assert.match(html, /确认本托实测/)
assert.match(html, /长 cm/)
}
})
test('box aggregated pallets render current totals and no manual pallet measurement even after moving', async () => {
const html = await renderMeasurements([{ palletCode: 'NEW', packages: 1, inherited: true, cartonAggregated: true,
grossWeightKg: 3.6, volumeCbm: 0.08, sourceCartonSpecs: [{lengthCm:50,widthCm:40,heightCm:40,grossWeightKg:3.6,cartons:1}] }])
assert.doesNotMatch(html, /移出扣减、移入增加/)
assert.match(html, /0.08 m³ · 3.6 kg/)
assert.doesNotMatch(html, /确认本托实测|重新测量|measurement-inputs/)
})
const mixedBoxes = [
{ cartonMark: 'OTHER', lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5 },
{ cartonMark: 'SMALL1', customerCartonMark: 'CUSTOMER', lengthCm: 50, widthCm: 40, heightCm: 40, grossWeightKg: 3.6 }
]
test('mixed specs automatically bind the submitted carton without another picker', async () => {
const { value, calls } = setup()
Object.assign(value, { readyToBind: true, requiresMeasuredBoxMapping: true, marks: ['CUSTOMER'], measuredBoxChoices: mixedBoxes,
source: { palletCode: 'OLD', palletVersion: 1 }, target: { palletCode: 'NEW', palletVersion: 1 },
selectedSource: { positionId: '1', positionVersion: 1 }, quantity: '1', loadSources: async () => {}, showBindingMessage() {} })
await value.bind()
assert.deepEqual(calls[0].cartonMarks, ['CUSTOMER'])
assert.deepEqual(calls[0].measuredCartons, [{ cartonMark: 'CUSTOMER', sourceCartonMark: 'SMALL1' }])
assert.doesNotMatch(source, /<picker|chooseMeasuredBox|点击选择实测箱/)
})
test('warehouse carton marks also match automatically and follow the current batch', () => {
const { value } = setup()
Object.assign(value, { requiresMeasuredBoxMapping: true, measuredBoxChoices: mixedBoxes, marks: ['SMALL1'] })
assert.deepEqual(value.measuredCartonBindings(), [{ cartonMark: 'SMALL1', sourceCartonMark: 'SMALL1' }])
value.marks = ['OTHER']
assert.deepEqual(value.measuredCartonBindings(), [{ cartonMark: 'OTHER', sourceCartonMark: 'OTHER' }])
})
test('unmatched, ambiguous or duplicate physical boxes cannot bind another carton', async () => {
for (const [marks, boxes] of [
[['MISSING'], mixedBoxes],
[['CUSTOMER'], [...mixedBoxes, { ...mixedBoxes[0], customerCartonMark: 'CUSTOMER' }]],
[['CUSTOMER', 'SMALL1'], mixedBoxes]
]) {
const { value, calls } = setup()
Object.assign(value, { readyToBind: true, requiresMeasuredBoxMapping: true, measuredBoxChoices: boxes, marks })
await value.bind()
assert.equal(calls.length, 0)
assert.match(value.error, /不匹配/)
}
})
test('entering split quantity prepares the batch and clears invalid quantities', () => {
const { value } = setup()
Object.assign(value, { measuredBoxChoices: [{ cartonMark: 'A' }], eligibleItems: [{ cartonMark: 'X' }],
selectedSource: { movablePackages: 1 }, fillBatch() { this.marks = ['X'] } })
value.prepareMeasuredBatch({ detail: { value: '1' } })
assert.deepEqual(value.marks, ['X'])
value.prepareMeasuredBatch({ detail: { value: '0' } })
assert.deepEqual(value.marks, [])
})
+3 -3
View File
@@ -59,7 +59,7 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti
test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/)
assert.match(source, /来源已确认:.*尚未拆出.*绑定成功后才保存拆托明细/)
assert.match(source, /来源已确认:\{\{ source.palletCode \}\}<\/text>/)
assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
})
@@ -96,7 +96,7 @@ test('rescan same bound pallet does not call stock movement', async () => {
value.showBindingMessage = (title, content) => { message = content }
value.run = async () => assert.fail('same pallet must not move stock')
await value.submitRebind()
assert.match(message, /不会重复扣库存/)
assert.match(message, /已绑定此托盘/)
})
test('target confirmation submits binding directly, with no second submit button', async () => {
@@ -183,7 +183,7 @@ test('missing source scan shows modal and never submits a binding', async () =>
value.bind = async () => assert.fail('must not bind without source confirmation')
value.resolve = async () => assert.fail('must validate source first')
await value.confirmTargetAndBind()
assert.match(message, /扫描并确认实际来源托盘/)
assert.match(message, /扫描确认来源托盘/)
})
test('target lookup failure cannot submit binding or report success', async () => {
+1 -1
View File
@@ -5,7 +5,7 @@ import { runInNewContext } from 'node:vm'
const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'page =')
const scope = { PortalHeader: {}, ScanTargetInput: {} }
const scope = { PortalHeader: {}, ScanTargetInput: {}, CartonCountFields: {} }
runInNewContext(script, scope)
function state(a = '2', b = '2', c = '2') {
+10 -10
View File
@@ -36,7 +36,7 @@ test('warehouse transfer page loads detail without exposing internal identifiers
assert.match(page, /entryMode === 'work' && taskNo/)
assert.match(page, /mode=work&task=\$\{encodeURIComponent\(String\(item\.transferNo\)\)\}/)
assert.match(page, /v-if="!selected && entryMode === 'list'"/)
assert.match(page, /作业详情/)
assert.match(page, /返回列表/)
assert.match(page, /backToCandidates\(\)/)
assert.match(page, /backToCandidates\(\) \{ if \(this\.submitting.*this\.entryMode = 'list'/)
assert.match(page, /存在未完结的测量单/)
@@ -54,12 +54,12 @@ test('warehouse transfer removes completed work from the pending list', async ()
assert.match(page, /this\.selected = null; this\.pendingKey = '';[\s\S]*await this\.loadCandidates\(\)/)
})
test('warehouse transfer shows forecast and actual variance without blocking short or over receipt', async () => {
test('warehouse transfer shows forecast and actual variance and requires counted box reconciliation', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
for (const label of ['客户预报', '当前实收', '短收差异', '超收差异']) assert.match(page, new RegExp(label))
assert.match(page, /cartonDifference/)
for (const label of ['客户预报', 'SKU实收', 'SKU签收差异']) assert.match(page, new RegExp(label))
assert.match(page, /hasSkuReceiptDifference/)
assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/)
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts|transferItemId/)
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts/)
})
test('warehouse transfer retries reuse the pending idempotency key', async () => {
@@ -71,13 +71,13 @@ test('warehouse transfer retries reuse the pending idempotency key', async () =>
test('warehouse transfer measurement inputs retain explicit placeholders', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
for (const placeholder of ['长度', '宽度', '高度', '毛重']) assert.match(page, new RegExp(`placeholder="${placeholder}"`))
assert.doesNotMatch(page, /v-model.number="pallet\.(lengthCm|widthCm|heightCm|grossWeightKg)"/)
})
test('warehouse transfer measurements are grouped and summarized per pallet', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /v-for="\(pallet, index\) in pallets"/)
for (const label of ['本托实测', '当前托重量', '当前托箱数', '自动体积']) assert.match(page, new RegExp(label))
for (const label of ['本托箱子汇总', '实收箱数', '箱子重量 kg', '箱子体积 m³']) assert.match(page, new RegExp(label))
assert.match(page, /palletVolume\(pallet\)/)
assert.match(page, /rawPalletVolume\(pallet\)/)
})
@@ -137,8 +137,8 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
assert.match(page, /handlePalletScan/)
assert.match(page, /扫描或手输新托盘标签号/)
assert.match(page, /已绑定实物托盘/)
assert.match(page, /!pallet\.generated.*removePallet/)
assert.match(page, /:disabled="pallet\.generated"/)
assert.match(page, /@tap\.stop="removePallet\(index\)">×/)
assert.match(page, /请先生成托盘号,再填写签收箱子/)
assert.match(page, /collapsed: false/)
assert.match(page, /pallet\.collapsed = !pallet\.collapsed/)
assert.match(page, /v-show="!pallet\.collapsed"/)
@@ -149,5 +149,5 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
assert.match(page, /bindWarehouseTransferPallet/)
assert.match(page, /deleteWarehouseTransferPallet/)
assert.match(page, /请先生成并扫描确认绑定全部托盘/)
assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\] \}/)
assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\], discrepancyReason:/)
})
+301
View File
@@ -0,0 +1,301 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
const source = readFileSync(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue',import.meta.url),'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm,'').replace('export default','page =')
function setup() {
const storage = new Map()
const scope = { CartonCountFields:{},PortalHeader:{},ScanTargetInput:{},session:{apiBase:'LOCAL',currentUser:{id:'OP',warehouseId:'WH'}},uni:{showModal: options=>options.success({confirm:true}),getStorageSync:key=>storage.get(key),setStorageSync:(key,value)=>storage.set(key,JSON.parse(JSON.stringify(value))),removeStorageSync:key=>storage.delete(key)} }
runInNewContext(script,scope)
const value = scope.page.data()
for(const [key,method] of Object.entries(scope.page.methods)) value[key]=method.bind(value)
for(const [key,get] of Object.entries(scope.page.computed)) Object.defineProperty(value,key,{get:()=>get.call(value)})
value.selected={transferNo:'WT1',packageCount:1,status:'WAIT_PDA_RECEIVING',items:[{id:'I1',skuCode:'A',quantity:4}],cartons:[{cartonMark:'BOX1',transferItemId:'I1',status:'EXPECTED'}]}
value.pallets=[{key:'P1',draftId:'P1',palletNo:'PALLET1',generated:true,bound:false}];value.restoreCartons();Object.assign(value.cartonBoxes[0],{length:10,width:20,height:30,weight:2});value.cartonBoxes[0].items[0].quantity=4
return {scope,value,storage}
}
test('deletion needs confirmation and cancel preserves box and measurements',async()=>{
const {value,scope}=setup(); let warning;
scope.uni.showModal=options=>{warning=options;options.success({confirm:false})};
await value.removeCarton(0);
assert.equal(value.cartonBoxes.length,1);assert.equal(value.cartonBoxes[0].weight,2);
assert.match(warning.content,/不计入本次实收/);assert.equal(warning.confirmText,'确认删除');
assert.equal(value.restorableCartons.length,0);assert.equal(value.deletingCarton,false);
})
test('deleted box survives reload and restores original identity, SKU, measurements and pallet',async()=>{
const {value}=setup(); await value.removeCarton(0);
assert.equal(value.cartonBoxes.length,0);assert.equal(value.restorableCartons.length,1);
value.restoreCartons();await value.restoreDeletedCarton();
const box=value.cartonBoxes[0];
assert.equal(box.cartonMark,'BOX1');assert.equal(box.weight,2);assert.equal(box.items[0].quantity,4);
assert.equal(box.transferPalletId,'P1');assert.equal(value.restorableCartons.length,0);
assert.equal(value.selected.packageCount,1);assert.equal(value.cartonConfirmed(box),false);
})
test('saved missing box can be restored after reopening count without increasing forecast',async()=>{
const {value,scope}=setup();
value.selected.cartons[0].status='NOT_RECEIVED';value.selected.cartons[0].countedAt='2026-10-16';
value.restoreCartons();assert.equal(value.cartonBoxes.length,0);
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:value.selected.cartons.map(box=>({...box,countedAt:null}))});
await value.restoreDeletedCarton();
assert.equal(value.cartonsCounted,false);assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');
assert.equal(value.selected.packageCount,1);assert.equal(value.restorableCartons.length,0);
value.cartonBoxes[0].weight=9;value.saveCartonDraft();value.restoreCartons();
assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');assert.equal(value.cartonBoxes[0].weight,9);
})
test('previously deleted forecast box without an archive can still be restored',async()=>{
const {value}=setup();value.cartonBoxes=[];value.saveCartonDraft();value.restoreCartons();
assert.equal(value.restorableCartons.length,1);
await value.restoreDeletedCarton();
assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');assert.equal(value.selected.packageCount,1);
})
test('summary toggle preserves measurements and volume uses three decimals',()=>{
const {value}=setup()
assert.equal(value.summaryCollapsed,false)
value.summaryCollapsed=true
assert.equal(value.cartonBoxes[0].weight,2)
Object.assign(value.cartonBoxes[0],{length:10,width:10,height:6})
value.addExtraCarton()
Object.assign(value.cartonBoxes[1],{length:10,width:10,height:6,weight:2})
assert.equal(value.palletVolume(value.pallets[0]),'0.001')
assert.equal(value.totalVolume,'0.001')
assert.match(source,/aria-label="添加 SKU"/)
assert.match(source,/aria-label="删除箱子"/)
})
test('remeasure preserves saved measurements and unlocks carton editing',async()=>{
const {value,scope}=setup();
const box={...value.cartonBoxes[0],countedAt:'2026-10-16',status:'COUNTED'};
value.selected.cartons=[box]; value.cartonRequestKey='old-key';
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:[{...box,countedAt:null}]});
await value.remeasure();
assert.equal(value.cartonsCounted,false); assert.equal(value.cartonBoxes[0].weight,2);
assert.equal(value.cartonBoxes[0].transferPalletId,'P1'); assert.equal(value.cartonRequestKey,'');
assert.equal(value.cartonCountReady,true);
})
test('bound pallet deletion retains box values and requires reassignment',async()=>{
const {value,scope}=setup();
scope.uni.showModal=options=>options.success({confirm:true});
value.pallets[0].bound=true;
scope.deleteWarehouseTransferPallet=async()=>({...value.selected,pallets:[]});
await value.removePallet(0);
assert.equal(value.cartonBoxes[0].transferPalletId,''); assert.equal(value.cartonBoxes[0].weight,2);
assert.equal(value.generatedPallets.length,0); assert.equal(value.pallets.length,1);
assert.equal(value.cartonCountReady,false); assert.equal(value.cartonPalletFilter,'');
})
test('failed pallet deletion retains binding and measurements',async()=>{
const {value,scope}=setup();
scope.uni.showModal=options=>options.success({confirm:true});
scope.deleteWarehouseTransferPallet=async()=>{throw new Error('offline')};
await value.removePallet(0);
assert.equal(value.cartonBoxes[0].transferPalletId,'P1'); assert.equal(value.pallets[0].busy,false);
assert.equal(value.error.message,'offline');
})
test('transfer counts require valid pieces and measurements and record variance reason',()=>{
const {value}=setup();assert.equal(value.cartonCountReady,true)
value.cartonBoxes[0].items[0].quantity=0;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].items[0].quantity=1.5;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].items[0].quantity=4;value.cartonBoxes[0].height=0;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].height=30;value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
assert.equal(value.cartonCountReady,true);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012')
})
test('network retry preserves request key and changed content uses a new request',async()=>{
const {value,scope}=setup();const requests=[]
scope.countWarehouseTransferCartons=async(no,request)=>{requests.push(request);throw new Error('offline')}
await value.confirmCartons();await value.confirmCartons()
assert.equal(requests.length,2);assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)
value.cartonBoxes[0].items[0].quantity=8;await value.confirmCartons();assert.notEqual(requests[1].idempotencyKey,requests[2].idempotencyKey)
assert.equal(value.countingCartons,false);assert.equal(value.error.message,'offline')
})
test('draft restores additional cartons and discrepancy reason without crossing tasks',()=>{
const {value}=setup();value.addExtraCarton();value.countDiscrepancyReason='超收';value.saveCartonDraft();value.restoreCartons()
assert.equal(value.cartonBoxes.length,2);assert.equal(value.countDiscrepancyReason,'超收')
value.selected={...value.selected,transferNo:'WT2'};value.restoreCartons();assert.equal(value.cartonBoxes.length,1)
})
test('received count gates pallet completion and saving cannot switch active order',()=>{
const {value}=setup();assert.match(value.validationMessage,/先完成全部箱子/)
value.selected.cartons[0].countedAt='2026-10-15';value.cartonBoxes[0].transferPalletId='FOREIGN';assert.match(value.validationMessage,/已清点实收箱数/)
value.countingCartons=true;value.backToCandidates();assert.equal(value.selected.transferNo,'WT1')
})
test('batch applies only selected received boxes of one SKU and preserves independent drafts',()=>{
const {value}=setup()
value.selected.items.push({id:'I2',skuCode:'B'})
value.addExtraCarton();value.addExtraCarton();value.addExtraCarton()
value.cartonBoxes[2].items[0].skuCode='B'
value.cartonBoxes[3].received=false
value.selectBatchCartons(true)
assert.equal(value.batchTargetBoxes.length,2)
value.toggleBatchCarton(0)
Object.assign(value.batchBox,{length:32,width:24,height:18,weight:3.2})
value.batchBox.items[0].quantity=6
assert.equal(value.batchTotalQuantity,6)
value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
assert.equal(value.cartonBoxes[2].items[0].quantity,'')
assert.equal(value.cartonBoxes[3].items[0].quantity,'')
value.batchBox.items[0].quantity=8
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
value.restoreCartons()
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
assert.equal(value.batchTargetBoxes.length,0)
value.changeBatchSku(1)
assert.equal(value.batchSku,'B')
assert.equal(value.batchBox.items[0].quantity,'')
})
test('batch guards invalid quantities, busy state, submitted facts and stale mixed SKU selections',()=>{
const {value}=setup();value.selectBatchCartons(true)
Object.assign(value.batchBox,{length:32,width:24,height:18,weight:3.2})
for(const quantity of [0,1.5,2147483648]){
value.batchBox.items[0].quantity=quantity;value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
}
value.batchBox.items[0].quantity=6;value.countingCartons=true;value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
value.countingCartons=false;value.cartonBoxes[0].items[0].skuCode='B'
assert.equal(value.batchTargetBoxes.length,0);value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
value.cartonBoxes[0].items[0].skuCode='A';value.selected.cartons[0].countedAt='now';value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
})
test('copy uses preceding received same SKU without sharing quantity objects or changing box identity',()=>{
const {value}=setup();value.addExtraCarton();value.addExtraCarton()
value.cartonBoxes[1].items[0].skuCode='B'
value.copyPreviousCarton(1);assert.equal(value.cartonBoxes[1].items[0].quantity,'')
const identity=value.cartonBoxes[2].localKey
value.copyPreviousCarton(2)
assert.equal(value.cartonBoxes[2].length,10)
assert.equal(value.cartonBoxes[2].items[0].quantity,4)
assert.equal(value.cartonBoxes[2].localKey,identity)
value.cartonBoxes[2].items[0].quantity=9
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
value.cartonBoxes[0].received=false;assert.equal(value.previousMatchingCarton(2),undefined)
})
test('pallet totals follow actual box assignments and exclude missing boxes',()=>{
const {value}=setup();value.pallets.push({key:'P2',draftId:'P2',palletNo:'PALLET2',generated:true})
value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:40,width:20,height:10,weight:3,transferPalletId:'P2'});value.cartonBoxes[1].items[0].quantity=6
assert.equal(value.palletSummary(value.pallets[0]).cartonCount,1)
assert.equal(value.palletSummary(value.pallets[1]).volume,0.008)
assert.equal(value.totalWeight,'5.00')
value.assignCartonPallet(value.cartonBoxes[1],0)
assert.equal(value.palletSummary(value.pallets[0]).cartonCount,2)
assert.equal(value.palletSummary(value.pallets[1]).cartonCount,0)
value.cartonBoxes[1].received=false
assert.equal(value.totalWeight,'2.00')
value.cartonBoxes[0].transferPalletId='FOREIGN';assert.equal(value.cartonCountReady,false)
})
test('pallet number can be created before measurements and new boxes receive that identity',async()=>{
const {value,scope}=setup();const requests=[]
value.pallets=[{key:'NEW',generated:false,busy:false}];value.cartonBoxes[0].transferPalletId=''
const pallet={id:'CREATED',sequenceNo:1,status:'GENERATED',palletNo:'NEWPALLET'}
scope.saveWarehouseTransferPalletDraft=async(no,request)=>{requests.push(request);return {pallets:[{...pallet,status:'DRAFT'}]}}
scope.generateWarehouseTransferPallet=async()=>({pallets:[pallet]})
await value.generatePallet(value.pallets[0])
assert.equal(requests.length,1);assert.equal('lengthCm' in requests[0],false);assert.equal('grossWeightKg' in requests[0],false)
assert.equal(value.cartonBoxes[0].transferPalletId,'CREATED')
assert.equal(value.pallets[0].palletNo,'NEWPALLET')
})
// 最终完成入口需要区分无差异、取消、空白原因和网络重试。
for (const answer of [{confirm:false}, {confirm:true,content:' '}, {confirm:true,content:'少到一箱'}]) {
test('completion handles discrepancy answer '+JSON.stringify(answer),async()=>{
const {value,scope}=setup();let requests=[];let prompts=0
value.selected.items[0].quantity=5;value.selected.cartons[0].countedAt='2026-10-16'
value.pallets[0].bound=true;value.photoFileIds=[91]
scope.uni.showModal=options=>{prompts++;options.success(answer)}
scope.palletizeWarehouseTransfer=async(no,payload)=>{requests.push(payload);throw new Error('offline')}
await value.submit()
assert.equal(prompts,1);assert.equal(value.submitting,false)
assert.equal(requests.length,answer.confirm && answer.content?.trim()?1:0)
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)}
})
}
test('matching SKU counts with different carton count submit without prompting, and missing photos block before prompting',async()=>{
const {value,scope}=setup();let prompts=0;let requests=[]
value.selected.packageCount=9;value.selected.cartons[0].countedAt='2026-10-16';value.pallets[0].bound=true
scope.uni.showModal=()=>{prompts++}
scope.palletizeWarehouseTransfer=async(no,payload)=>{requests.push(payload);throw new Error('offline')}
await value.submit();assert.equal(requests.length,0);assert.equal(prompts,0)
value.photoFileIds=[91];await value.submit();assert.equal(prompts,0);assert.equal(requests.length,1);assert.equal(requests[0].discrepancyReason,null)
})
// 逐箱确认收起、再次编辑及草稿恢复必须保留其他箱的确认状态。
test('individual confirmation collapses only that box and resumes from draft',async()=>{
const {value,scope}=setup();value.addExtraCarton()
Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
let saves=0;scope.countWarehouseTransferCartons=async()=>{saves++;throw new Error('offline')}
await value.confirmSingleCarton(value.cartonBoxes[0])
assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true);assert.equal(value.allCartonsConfirmed,false);assert.equal(saves,0)
value.restoreCartons();assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true)
await value.editCarton(value.cartonBoxes[0]);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false)
await value.confirmSingleCarton(value.cartonBoxes[0]);await value.confirmSingleCarton(value.cartonBoxes[1])
assert.equal(value.allCartonsConfirmed,true);assert.equal(saves,1);assert.equal(value.error.message,'offline')
value.cartonBoxes[0].weight=3;assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false);assert.equal(value.allCartonsConfirmed,false)
})
test('saved boxes restore collapsed and editing reopens only selected box',async()=>{
const {value,scope}=setup()
const first={...value.cartonBoxes[0],quantity:4,countedAt:'2026-10-16',status:'COUNTED'}
const second={...first,cartonMark:'BOX2'}
value.selected.cartons=[first,second];value.restoreCartons()
assert.equal(value.cartonBoxes.every(box=>value.cartonConfirmed(box)),true)
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:value.selected.cartons.map(box=>({...box,countedAt:null}))})
await value.editCarton(value.cartonBoxes[0])
assert.equal(value.cartonsCounted,false);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false);assert.equal(value.cartonConfirmed(value.cartonBoxes[1]),true)
value.restoreCartons();assert.equal(value.cartonConfirmed(value.cartonBoxes[1]),true)
})
test('failed reopening preserves confirmed data and invalid box cannot confirm',async()=>{
const {value,scope}=setup();value.cartonBoxes[0].weight=0
await value.confirmSingleCarton(value.cartonBoxes[0]);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false)
value.cartonBoxes[0].weight=2;value.selected.cartons=[{...value.cartonBoxes[0],countedAt:'2026-10-16',status:'COUNTED'}];value.restoreCartons()
scope.reopenWarehouseTransferMeasurement=async()=>{throw new Error('offline')}
await value.editCarton(value.cartonBoxes[0]);assert.equal(value.cartonsCounted,true);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true)
})
// 箱数变化不影响 SKU 门禁,异 SKU 正负差异不能按总件数抵消。
test('SKU differences compare each SKU and ignore carton count',()=>{
const {value}=setup();value.selected.packageCount=9
assert.equal(value.hasSkuReceiptDifference,false)
value.selected.items.push({id:'I2',skuCode:'B',quantity:3})
value.cartonBoxes[0].items=[{skuCode:'A',quantity:3},{skuCode:'B',quantity:4}]
assert.equal(value.cartonQuantity,value.forecastSkuQuantity)
assert.equal(value.skuReceiptDifferences.length,2)
value.cartonBoxes[0].items=[{skuCode:'A',quantity:4},{skuCode:'B',quantity:3}]
assert.equal(value.hasSkuReceiptDifference,false)
})
// 客户补箱后,新增预报箱进入PDA,原本机草稿和删除标记不丢失。
test('forecast additions merge into existing draft without losing edited boxes',async()=>{
const {value}=setup();value.cartonBoxes[0].weight=7;value.saveCartonDraft()
value.selected.packageCount=2;value.selected.cartons.push({cartonMark:'BOX2',transferItemId:'I1',status:'EXPECTED'})
value.restoreCartons();assert.equal(value.cartonBoxes.length,2);assert.equal(value.cartonBoxes[0].weight,7);assert.equal(value.cartonBoxes[1].cartonMark,'BOX2')
await value.removeCarton(0);value.restoreCartons();assert.equal(value.cartonBoxes.length,1);assert.equal(value.cartonBoxes[0].cartonMark,'BOX2')
})
test('count payload carries forecast version for stale snapshot rejection',async()=>{
const {value,scope}=setup();let payload
scope.countWarehouseTransferCartons=async(no,request)=>{payload=request;throw new Error('offline')}
await value.confirmCartons();assert.equal(payload.forecastPackageCount,1);assert.equal(payload.boxes[0].cartonMark,'BOX1')
})
test('新增预报箱无SKU绑定时恢复为可编辑箱,保留已有草稿', () => {
const { value } = setup()
value.selected.cartons.push({ cartonMark: 'NEW-UNBOUND', cartonIndex: 2, status: 'EXPECTED', transferItemId: null })
value.selected.packageCount = 2
value.restoreCartons()
const added = value.cartonBoxes.find(box => box.cartonMark === 'NEW-UNBOUND')
assert.ok(added)
assert.ok(value.declaredSkuCodes.includes(added.items[0].skuCode))
})
@@ -0,0 +1,127 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'return')
const page = new Function('CartonCountFields', 'PortalHeader', 'ScanTargetInput', 'session', 'uni', script)({}, {}, {}, { currentUser: null }, { showModal: options => options.success({confirm:true}), getStorageSync: key => globalThis.uni?.getStorageSync(key) })
function context() {
const ctx = { ...page.data(), selected: { transferNo: 'WT1', packageCount: 2, items: [{ id: '1', skuCode: 'A' }, { id: '2', skuCode: 'B' }], cartons: [] }, saveCartonDraft() {} }
for (const [name, method] of Object.entries(page.methods)) ctx[name] = method.bind(ctx)
for (const [name, getter] of Object.entries(page.computed)) Object.defineProperty(ctx, name, { get: getter.bind(ctx), configurable: true })
ctx.saveCartonDraft = () => {}
return ctx
}
const box = (sku = 'A', quantity = 3) => ({ received: true, length: 10, width: 20, height: 30, weight: 2, items: [{ skuCode: sku, quantity }] })
test('one carton can add distinct forecast SKUs and validates every quantity', () => {
const ctx = context(), current = box()
ctx.addCartonSku(current)
assert.deepEqual(current.items, [{ skuCode: 'A', quantity: 3 }, { skuCode: 'B', quantity: '' }])
assert.equal(ctx.validCartonSkuLines(current), false)
current.items[1].quantity = 7
assert.equal(ctx.validCartonSkuLines(current), true)
ctx.addCartonSku(current)
assert.equal(current.items.length, 2)
current.items[1].skuCode = 'A'
assert.equal(ctx.validCartonSkuLines(current), false)
current.items[1].skuCode = 'FOREIGN'
assert.equal(ctx.validCartonSkuLines(current), false)
})
test('single SKU batch changes never overwrite mixed carton contents', () => {
const ctx = context(), single = box(), mixed = box()
mixed.items.push({ skuCode: 'B', quantity: 7 })
ctx.cartonBoxes = [single, mixed]; ctx.batchSku = 'A'
ctx.selectBatchCartons(true)
assert.deepEqual(ctx.batchSelectedIndexes, [0])
ctx.toggleBatchCarton(1)
assert.deepEqual(ctx.batchSelectedIndexes, [0])
assert.equal(ctx.previousMatchingCarton(1), null)
assert.equal(ctx.cartonQuantity, 13)
})
test('delete only changes actual draft and preserves forecast; additions keep their own SKU rows', async () => {
const ctx = context()
ctx.cartonBoxes = [box(), box('B')]; ctx.batchSelectedIndexes = [1]
await ctx.removeCarton(0)
assert.equal(ctx.cartonBoxes.length, 1)
assert.equal(ctx.selected.packageCount, 2)
assert.deepEqual(ctx.batchSelectedIndexes, [])
ctx.addExtraCarton()
assert.equal(ctx.cartonBoxes.length, 2)
assert.equal(ctx.cartonBoxes[1].items[0].skuCode, 'A')
ctx.cartonBoxes[1].items[0].quantity = 8
assert.equal(ctx.cartonBoxes[0].items[0].quantity, 3)
})
test('draft reload preserves deletions, including an empty actual list', () => {
const ctx = context()
ctx.selected.cartons = [{ cartonMark: 'BOX1', transferItemId: '1', status: 'EXPECTED' }, { cartonMark: 'BOX2', transferItemId: '2', status: 'EXPECTED' }]
let saved = { forecastMarks: ['BOX1','BOX2'], boxes: [{ ...box('B'), cartonMark: 'BOX2', transferItemId: '2' }], discrepancyReason: '少到一箱' }
globalThis.uni = { getStorageSync: () => saved }
try {
ctx.restoreCartons()
assert.equal(ctx.cartonBoxes.length, 1)
assert.equal(ctx.cartonBoxes[0].cartonMark, 'BOX2')
assert.equal(ctx.countDiscrepancyReason, '少到一箱')
saved = { forecastMarks: ['BOX1','BOX2'], boxes: [], discrepancyReason: '重新录入' }
ctx.restoreCartons()
assert.equal(ctx.cartonBoxes.length, 0)
assert.equal(ctx.selected.cartons.length, 2)
} finally { delete globalThis.uni }
})
test('100 same SKU cartons accept two distinct specification groups and independent exceptions', () => {
const ctx = context()
ctx.selected.packageCount = 100
ctx.pallets = [{ generated: true, draftId: 'P1' }, { generated: true, draftId: 'P2' }]
ctx.cartonBoxes = Array.from({ length: 100 }, (_, index) => ({ ...box('A', ''), localKey: `BOX-${index}`, transferItemId: '1', length: '', width: '', height: '', weight: '' }))
ctx.batchSku = 'A'; ctx.batchPalletId = 'P1'; ctx.batchBox = { ...box('A', 12), length: 40, width: 30, height: 20, weight: 5 }
ctx.selectBatchCartons(true)
for (let index = 60; index < 100; index++) ctx.toggleBatchCarton(index)
assert.equal(ctx.batchTargetBoxes.length, 60)
assert.equal(ctx.batchTotalQuantity, 720)
ctx.applyBatchCartons()
assert.equal(ctx.cartonBoxes[59].items[0].quantity, 12)
assert.equal(ctx.cartonBoxes[60].items[0].quantity, '')
ctx.selectBatchCartons(true)
for (let index = 0; index < 60; index++) ctx.toggleBatchCarton(index)
ctx.batchPalletId = 'P2'; ctx.batchBox = { ...box('A', 8), length: 50, width: 40, height: 30, weight: 7 }
ctx.applyBatchCartons()
assert.equal(ctx.palletSummary(ctx.pallets[0]).cartonCount, 60)
assert.equal(ctx.palletSummary(ctx.pallets[1]).cartonCount, 40)
assert.equal(ctx.cartonQuantity, 1040)
assert.equal(ctx.totalWeight, '580.00')
assert.equal(ctx.totalVolume, '3.840')
assert.equal(ctx.cartonCountReady, true)
ctx.cartonBoxes[0].items[0].quantity = 11
ctx.cartonBoxes[0].weight = 6
assert.equal(ctx.cartonBoxes[1].items[0].quantity, 12)
assert.equal(ctx.cartonBoxes[1].weight, 5)
assert.equal(ctx.batchBox.items[0].quantity, 8)
assert.equal(ctx.cartonQuantity, 1039)
assert.equal(ctx.totalWeight, '581.00')
})
test('copy previous matching SKU skips other SKUs and preserves the target pallet', () => {
const ctx = context()
ctx.cartonBoxes = [{ ...box('A', 12), length: 40, width: 30, height: 20, weight: 5, transferPalletId: 'P1' }, box('B', 99), { ...box('A', ''), transferPalletId: 'P2' }]
ctx.copyPreviousCarton(2)
assert.equal(ctx.cartonBoxes[2].length, 40)
assert.equal(ctx.cartonBoxes[2].weight, 5)
assert.equal(ctx.cartonBoxes[2].items[0].quantity, 12)
assert.equal(ctx.cartonBoxes[2].transferPalletId, 'P2')
ctx.cartonBoxes[2].items[0].quantity = 9
assert.equal(ctx.cartonBoxes[0].items[0].quantity, 12)
})
test('预报箱可调整原SKU,新增无绑定箱也可清点', () => {
const ctx = context(); const current = { ...box('B', 4), cartonMark: 'FORECAST-1', transferItemId: '1' }
assert.equal(ctx.validCartonSkuLines(current), true)
current.transferItemId = null
assert.equal(ctx.validCartonSkuLines(current), true)
current.items[0].skuCode = 'OTHER'
assert.equal(ctx.validCartonSkuLines(current), false)
assert.doesNotMatch(source, /:lock-first-sku="Boolean\(box.cartonMark\)"/)
})