diff --git a/src/api/return-packing.js b/src/api/return-packing.js index 0510fc9..0c461ed 100644 --- a/src/api/return-packing.js +++ b/src/api/return-packing.js @@ -76,12 +76,9 @@ export function normalizeReturnPackingPlan(value = {}) { } export async function listReturnPackingPlans() { - try { - const value = await overseasRequest('GET', `${BASE}/packing/plans`) - const records = Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] - return records.map(normalizeReturnPackingPlan) - } - catch (error) { if (isNotFound(error)) return []; throw error } + const value = await overseasRequest('GET', `${BASE}/packing/plans`) + const records = Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] + return records.map(normalizeReturnPackingPlan) } export async function getReturnPackingPlan(planNo) { diff --git a/src/pages/return-packing/return-packing.vue b/src/pages/return-packing/return-packing.vue index c237bff..eef0d8c 100644 --- a/src/pages/return-packing/return-packing.vue +++ b/src/pages/return-packing/return-packing.vue @@ -56,7 +56,24 @@ removePlanBox(index) { if (this.planBoxes.length > 1) this.planBoxes.splice(index, 1) }, addPlanItem(box) { box.items.push({ skuCode: '', quantity: 0 }) }, removePlanItem(box, index) { if (box.items.length > 1) box.items.splice(index, 1) }, - async completePlan() { if (!this.canCompletePlan || this.busy || !this.activePlan) return; const payload = { boxes: this.planBoxes.map((box, index) => ({ boxNo: index + 1, items: box.items.filter((item) => String(item.skuCode || '').trim() && Number(item.quantity) > 0).map((item) => ({ skuCode: String(item.skuCode).trim(), quantity: Number(item.quantity) })), length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight) })), idempotencyKey: warehouseIdempotencyKey('returnPackingPlan', 'complete', { planNo: this.activePlan.planNo, boxes: this.planBoxes }).value }; await this.run(async () => { this.result = await mergeReturnPackingPlan(this.activePlan.planNo, payload); this.activePlan = null; this.planBoxes = []; await this.loadPlans(); playSuccessSound() }, '合箱计划执行失败') }, + async completePlan() { + if (!this.canCompletePlan || this.busy || !this.activePlan) return + await this.run(async () => { + const planNo = this.activePlan.planNo + const boxes = this.planBoxes.map((box, index) => ({ + boxNo: index + 1, + items: box.items.filter((item) => String(item.skuCode || '').trim() && Number(item.quantity) > 0).map((item) => ({ skuCode: String(item.skuCode).trim(), quantity: Number(item.quantity) })), + length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight) + })) + const key = warehouseIdempotencyKey('returnPackingPlan', 'complete', { planNo, boxes }) + this.result = await mergeReturnPackingPlan(planNo, { boxes, idempotencyKey: key.value }) + releaseWarehouseKey('returnPackingPlan', key.signature) + this.activePlan = null + this.planBoxes = [] + await this.loadPlans() + playSuccessSound() + }, '合箱计划执行失败') + }, startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (!this.busy) { this.searchCode = value; this.search(value) } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null }, async search(raw) { const value = String(raw?.detail?.value ?? raw ?? this.searchCode).trim(); if (!value || this.busy) return; this.searchCode = value; await this.run(async () => { const preview = await findPackingOrders(value); this.candidates = preview.orders.filter((item) => !this.selectedOrders.some((selected) => selected.returnNo === item.returnNo)); if (this.candidates.length) playSuccessSound() }, '查询可合箱退货单失败') }, diff --git a/src/stores/warehouse-work.js b/src/stores/warehouse-work.js index 6cfc049..9fdbcd0 100644 --- a/src/stores/warehouse-work.js +++ b/src/stores/warehouse-work.js @@ -8,6 +8,7 @@ const blankState = () => ({ relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} }, returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {}, pendingKeys: {} }, returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} }, + returnPackingPlan: { pendingKeys: {} }, warehouseTransfer: { currentNo: '', pendingKeys: {} }, query: { inventoryCode: '', locationCode: '', palletCode: '' } }) @@ -40,6 +41,7 @@ export function restoreWarehouseWork(scopeKey) { stocktake: domain(saved.stocktake, fresh.stocktake), move: domain(saved.move, fresh.move), relabel: domain(saved.relabel, fresh.relabel), returnReceipt: domain(saved.returnReceipt, fresh.returnReceipt), returnPacking: domain(saved.returnPacking, fresh.returnPacking), + returnPackingPlan: domain(saved.returnPackingPlan, fresh.returnPackingPlan), warehouseTransfer: domain(saved.warehouseTransfer, fresh.warehouseTransfer), query: { ...fresh.query, ...(saved.query || {}) } }) diff --git a/tests/return-packing-api.test.mjs b/tests/return-packing-api.test.mjs index 412ff9f..e45cdda 100644 --- a/tests/return-packing-api.test.mjs +++ b/tests/return-packing-api.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { findPackingOrders, getReturnCartonLabel, mergeReturnCartons, normalizePackingPreview } from '../src/api/return-packing.js' +import { findPackingOrders, getReturnCartonLabel, listReturnPackingPlans, mergeReturnCartons, normalizePackingPreview } from '../src/api/return-packing.js' import { session } from '../src/stores/session.js' test('packing preview keeps customer scope internal and exposes stable order references', () => { @@ -40,3 +40,11 @@ test('return packing lookup still rejects real service errors', async () => { globalThis.uni = { request(options) { options.success({ statusCode: 500, data: { code: 'B0500', message: '服务暂不可用' }, header: {} }) } } await assert.rejects(() => findPackingOrders('RT-1'), (error) => error.code === 'B0500') }) + +test('plan list distinguishes an empty success from a missing API and preserves diagnostics', async () => { + session.apiBase = '/api'; session.token = 'packing-token' + globalThis.uni = { request(options) { options.success({ statusCode: 200, data: { code: '200', data: [] }, header: {} }) } } + assert.deepEqual(await listReturnPackingPlans(), []) + globalThis.uni = { request(options) { options.success({ statusCode: 404, data: { code: 'A0404', message: '计划接口不存在', requestId: 'missing-plan-api' }, header: {} }) } } + await assert.rejects(() => listReturnPackingPlans(), error => error.code === 'A0404' && error.requestId === 'missing-plan-api') +}) diff --git a/tests/return-packing-page.test.mjs b/tests/return-packing-page.test.mjs new file mode 100644 index 0000000..51e035b --- /dev/null +++ b/tests/return-packing-page.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +function setup(merge) { + const saved = new Map() + const context = vm.createContext({ reactive: value => value, PortalHeader: {}, ScanTargetInput: {}, + playSuccessSound() {}, playErrorSound() {}, mergeReturnPackingPlan: merge, + uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key) } }) + 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 source = readFileSync(new URL('../src/pages/return-packing/return-packing.vue', import.meta.url), 'utf8') + const script = source.match(/]*>([\s\S]*?)<\/script>/)[1] + const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context) + vm.runInContext("restoreWarehouseWork('operator:warehouse')", context) + const page = Object.assign({}, component.methods, { canCompletePlan: true, busy: false, + activePlan: { planNo: 'PLAN-1' }, planBoxes: [{ items: [{ skuCode: ' SKU-1 ', quantity: '2' }], length: 10, width: 20, height: 30, weight: 1 }], + async loadPlans() {} }) + return { page, restore: scope => vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context), + keys: () => vm.runInContext('warehouseWork.returnPackingPlan.pendingKeys', context) } +} + +test('plan completion reaches API and preserves the key across lost responses and draft restore', async () => { + const calls = []; let fail = true + const fixture = setup(async (plan, payload) => { calls.push({ plan, payload }); if (fail) throw new Error('response lost'); return { labels: [] } }) + await fixture.page.completePlan() + assert.equal(calls.length, 1) + assert.equal(fixture.page.error.message, 'response lost') + assert.equal(fixture.page.busy, false) + fixture.restore('operator:warehouse') + fail = false + await fixture.page.completePlan() + assert.equal(calls.length, 2) + assert.equal(calls[0].payload.idempotencyKey, calls[1].payload.idempotencyKey) + assert.equal(calls[1].payload.boxes[0].items[0].skuCode, 'SKU-1') + assert.equal(fixture.page.activePlan, null) + assert.equal(Object.keys(fixture.keys()).length, 0) +}) + +test('plan completion keys are not reused by another operator or warehouse', async () => { + const keys = [] + const fixture = setup(async (_plan, payload) => { keys.push(payload.idempotencyKey); throw new Error('response lost') }) + await fixture.page.completePlan() + fixture.restore('other-operator:warehouse') + await fixture.page.completePlan() + assert.equal(keys.length, 2) + assert.notEqual(keys[0], keys[1]) +})