diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue index 7b6baea..cb2a731 100644 --- a/src/pages/return-receipt/return-receipt.vue +++ b/src/pages/return-receipt/return-receipt.vue @@ -99,7 +99,7 @@ printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ }, acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() }, async confirmReceipt() { const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => (this.unitType === 'BOX' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return } - await this.run(async () => { const rows = this.unitType === 'BOX' ? this.boxRows : [null]; const results = []; for (const [index, box] of rows.entries()) { const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PALLET' ? this.palletCode.trim() : this.unitType === 'PARCEL' ? this.unitCode.trim() : undefined, palletCode: this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }; const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload); results.push(await confirmReturnReceipt({ ...payload, idempotencyKey: key.value })); releaseWarehouseKey('returnReceipt', key.signature) } const last = results[results.length - 1]; this.result = { ...last, labels: results.filter(item => item.unitType === 'BOX' && item.printableHtml).map(item => ({ cartonMark: item.cartonMark, printableHtml: item.printableHtml })) }; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '退货入库失败') }, + await this.run(async () => { const rows = this.unitType === 'BOX' ? this.boxRows : [null]; const results = []; for (const [index, box] of rows.entries()) { const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' ? this.unitCode.trim() : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }; const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload); results.push(await confirmReturnReceipt({ ...payload, idempotencyKey: key.value })); releaseWarehouseKey('returnReceipt', key.signature) } const last = results[results.length - 1]; this.result = { ...last, labels: results.filter(item => item.unitType === 'BOX' && item.printableHtml).map(item => ({ cartonMark: item.cartonMark, printableHtml: item.printableHtml })) }; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '退货入库失败') }, manualComplete() { if (!this.current || this.busy) return; uni.showModal({ title: '缺货手动完结', editable: true, placeholderText: '请填写实收少于预期数量的原因', success: async (modal) => { if (!modal.confirm) return; const reason = String(modal.content || '').trim(); if (!reason) { this.setError(new Error('必须填写差异原因'), '手动完结失败'); return } await this.run(async () => { await manuallyCompleteReturnReceipt(this.current.returnNo, reason); this.result = { palletCode: '', receiptNo: '', receiptBatchNo: '', expectedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.expectedQuantity, 0), receivedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.receivedQuantity, 0), fullyMatched: false }; this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '手动完结失败') } }) }, choosePhoto() { if (!this.current || this.busy || this.photoFileIds.length >= 20) return; const no = this.current.returnNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { await this.run(async () => { for (const [index,path] of (tempFilePaths || []).entries()) { if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId); this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path } playSuccessSound() }, '退货打托照片上传失败') } }) }, previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) }, diff --git a/tests/return-receipt-page.test.mjs b/tests/return-receipt-page.test.mjs new file mode 100644 index 0000000..84ffe15 --- /dev/null +++ b/tests/return-receipt-page.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +function setup(confirm) { + const context = vm.createContext({ reactive: value => value, PortalHeader: {}, ScanTargetInput: {}, + playSuccessSound() {}, playErrorSound() {}, confirmReturnReceipt: confirm }) + 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(/]*>([\s\S]*?)<\/script>/)[1] + const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context) + return 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', + palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1, + quantity: () => 2, boxCount: () => 1 + }) +} + +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.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('PALLET response failure keeps the draft and same idempotency key for retry', 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.equal(calls[0].idempotencyKey, calls[1].idempotencyKey) +})