From dae119a1e8bc6de349825f1000b75392eed45ace Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 21:36:10 +0800 Subject: [PATCH] fix(first-leg): preserve edit commands and add versioned cancellation --- src/modules/first-leg/api/firstLeg.ts | 8 +- .../store/firstLegDraftCommands.test.mjs | 196 ++++++++++++++++++ .../first-leg/store/firstLegOrders.test.mjs | 6 +- src/modules/first-leg/store/firstLegOrders.ts | 63 +++++- .../views/FirstLegOrdersView.test.mjs | 3 +- .../first-leg/views/FirstLegOrdersView.vue | 65 +++++- 6 files changed, 321 insertions(+), 20 deletions(-) create mode 100644 src/modules/first-leg/store/firstLegDraftCommands.test.mjs diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index 1ae92a3..dc0eab0 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -566,8 +566,12 @@ export function createMerchantFirstLegOrderBatch(items: MerchantBatchDraftItem[] }) } -export function updateMerchantFirstLegOrder(id: number | string, payload: FirstLegOrderSaveRequest) { - return apiRequest({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload }) +export function updateMerchantFirstLegOrder(id: number | string, payload: { expectedVersion: number; draft: FirstLegOrderSaveRequest }, idempotencyKey: string) { + return apiRequest({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload, headers: { 'Idempotency-Key': idempotencyKey } }) +} + +export function cancelMerchantFirstLegOrder(id: number | string, payload: { expectedVersion: number; reason: string }, idempotencyKey: string) { + return apiRequest({ method: 'POST', url: `/merchant/v1/orders/first-leg/${id}/cancel`, data: payload, headers: { 'Idempotency-Key': idempotencyKey } }) } export function submitMerchantFirstLegOrder(id: number | string, idempotencyKey: string) { diff --git a/src/modules/first-leg/store/firstLegDraftCommands.test.mjs b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs new file mode 100644 index 0000000..67fefb4 --- /dev/null +++ b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import * as pinia from 'pinia' + +function compile(path, dependencies) { + const source = readFileSync(new URL(path, import.meta.url), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText + const exports = {} + new Function('require', 'exports', code)(id => { assert.ok(id in dependencies, id); return dependencies[id] }, exports) + return exports +} +function harness(t) { + const context = pinia.createPinia(); pinia.setActivePinia(context) + t.after(() => pinia.disposePinia(context)) + const calls = [] + const behavior = { failWrite: false, failRead: false, version: 9, status: 'DRAFT' } + const api = compile('../api/firstLeg.ts', { '@/api/http': { apiRequest: async config => { + calls.push(structuredClone(config)) + if (config.method === 'GET') { + if (behavior.failRead) throw new Error('读取失败') + return { order: { id: 'one', orderNo: 'FL-ONE', version: behavior.version, status: behavior.status, receivingWarehouseId: 'warehouse', destinationCountryCode: 'US' }, cargoes: [] } + } + if (behavior.failWrite) throw new Error('响应丢失') + } } }) + const module = compile('./firstLegOrders.ts', { + vue, pinia, + '@/modules/first-leg/api/firstLeg': api, + '@/modules/first-leg/api/unifiedOrders': {}, + '@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value }, + '@/modules/first-leg/utils/warehousePagination': {}, + }) + const store = module.useFirstLegOrdersStore() + store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }] + const draft = { receivingWarehouseId: 'warehouse', destinationType: 'PRIVATE', destinationCountryCode: 'US', items: [], customerRemark: '原草稿' } + return { store, api, calls, behavior, draft } +} + +test('edit freezes detail version rather than cached list version and replays same key/body after uncertain result', async t => { + const h = harness(t) + const original = await h.store.loadDraftDetail('one') + const session = h.store.beginDraftEdit(original) + h.store.orders[0].version = 0 + h.behavior.failWrite = true + await assert.rejects(h.store.saveDraft(h.draft, '测试企业', 'one', session), /响应丢失/) + const first = h.calls.find(call => call.method === 'PUT') + assert.equal(first.data.expectedVersion, 9) + assert.equal(first.data.draft.customerRemark, '原草稿') + assert.ok(first.headers['Idempotency-Key']) + assert.deepEqual(h.calls.map(call => call.method), ['GET', 'PUT'], 'no pre-save version refresh') + h.behavior.failWrite = false; h.behavior.version = 10 + const result = await h.store.saveDraft(h.draft, '测试企业', 'one', session) + const writes = h.calls.filter(call => call.method === 'PUT') + assert.deepEqual(writes[1], first) + assert.deepEqual(h.calls.map(call => call.method), ['GET', 'PUT', 'GET', 'PUT', 'GET']) + assert.equal(result.version, 10) +}) + +test('changed retry payload or missing edit session fails closed without issuing a new write', async t => { + const h = harness(t) + await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one'), /重新打开/) + assert.equal(h.calls.length, 0) + const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) + h.behavior.failWrite = true + await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one', session)) + h.draft.customerRemark = '不同内容' + await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one', session), /重试原内容/) + assert.equal(h.calls.filter(call => call.method === 'PUT').length, 1) + assert.equal(session.expectedVersion, 9) + assert.ok(Object.isFrozen(session)) +}) + +test('read failure after successful edit preserves the same command for retry', async t => { + const h = harness(t) + const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) + h.behavior.failRead = true + await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one', session), /读取失败/) + h.behavior.failRead = false + await h.store.saveDraft(h.draft, '客户', 'one', session) + const writes = h.calls.filter(call => call.method === 'PUT') + assert.deepEqual(writes[0], writes[1]) +}) + +test('cancel forwards version and reason without rejecting submitted orders locally; retry keeps key and body', async t => { + const h = harness(t) + h.behavior.status = 'SUBMITTED' + h.store.orders = [{ id: 'one', status: 'PENDING_REVIEW', version: 0 }] + h.behavior.failWrite = true + await assert.rejects(h.store.cancelOrder('one', 12, '客户要求取消'), /响应丢失/) + const first = h.calls[0] + assert.equal(first.method, 'POST') + assert.equal(first.url, '/merchant/v1/orders/first-leg/one/cancel') + assert.deepEqual(first.data, { expectedVersion: 12, reason: '客户要求取消' }) + await assert.rejects(h.store.cancelOrder('one', 13, '其他原因'), /原版本和原因/) + assert.equal(h.calls.length, 1) + h.behavior.failWrite = false + await h.store.cancelOrder('one', 12, '客户要求取消') + assert.deepEqual(h.calls[2], first) + assert.equal(h.calls[1].method, 'GET') + assert.equal(h.store.orders.length, 0) +}) + +test('cancel requires a detail version and a nonempty reason', async t => { + const h = harness(t) + await assert.rejects(h.store.cancelOrder('one', undefined, '理由')) + await assert.rejects(h.store.cancelOrder('one', 1, ' ')) + assert.equal(h.calls.length, 0) +}) + +test('cancellation decision reads raw server status and version including SUBMITTED', async t => { + const h = harness(t) + for (const status of ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED']) { + h.behavior.status = status + const target = await h.store.prepareCancellation('one') + assert.equal(target.expectedVersion, 9) + assert.equal(target.businessNo, 'FL-ONE') + } + for (const status of ['PROCESSING', 'COMPLETED', 'CANCELLED']) { + h.behavior.status = status + await assert.rejects(h.store.prepareCancellation('one'), /不允许取消/) + } + assert.ok(h.calls.every(call => call.method === 'GET')) +}) + +function cancellationViewHarness() { + const source = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8').match(/