diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs index 7bbf4b4..d65d9c8 100644 --- a/src/modules/cross-border/procurement.test.mjs +++ b/src/modules/cross-border/procurement.test.mjs @@ -54,16 +54,8 @@ test('successful net debit excludes the original estimate and counts only succes assert.equal(presentation.feeStatus(fee('DUTY', null, 100)), '待核定') }) -test('request recovery preserves content and identity while malformed recovery cannot create a replacement', () => { - const original = presentation.buildProcurementCreate(draft(), 'Company', 'fixed-key') - assert.deepEqual(normalized(submission.restoreProcurementSubmission(JSON.stringify(original))), normalized(original)) - assert.throws(() => submission.restoreProcurementSubmission('{}')) - assert.throws(() => submission.restoreProcurementSubmission(JSON.stringify({ ...original, currency: 'EUR' }))) - assert.equal(submission.definitelyRejected({ code: 'TIMEOUT' }), false) - assert.equal(submission.definitelyRejected({ status: 500 }), false) - assert.equal(submission.definitelyRejected({ status: 409 }), false) - assert.equal(submission.definitelyRejected({ status: 400 }), true) - assert.equal(submission.procurementRequestKey({ getRandomValues: target => { target.set([1, 2, 3, 4]); return target } }), 'proc-00000001000000020000000300000004') +test('new requests get unique keys without restoring stored submissions', () => { + assert.notEqual(submission.procurementRequestKey(crypto), submission.procurementRequestKey(crypto)) }) class ApiError extends Error { constructor(message, code, status) { super(message); this.code = code; this.status = status } } @@ -109,18 +101,16 @@ test('detail has one in-flight request and an older response cannot overwrite an } finally { harness.dispose() } }) -test('timeout retries the exact original body after reload and does not accept changed form data', async () => { - const storage = new Map(), calls = [] - const first = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); throw new ApiError('timeout', 'TIMEOUT') } } }) +test('timeout releases the form and another click submits the edited current values', async () => { + const calls = [] + const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(normalized(payload)); throw new ApiError('timeout', 'TIMEOUT') } } }) try { - Object.assign(first.state.form, draft()); await first.state.submit(); assert.equal(calls.length, 1); assert.ok(first.state.pending.value) - first.state.form.productName = 'changed'; await first.state.submit(); assert.deepEqual(calls[1], calls[0]) - } finally { first.dispose() } - const recovered = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); return { order: { orderNo: 'PO-SAME' } } } } }) - try { - assert.equal(recovered.state.form.productName, 'Test product'); await recovered.state.submit(); assert.deepEqual(calls[2], calls[0]) - assert.equal(storage.size, 0); assert.equal(recovered.pushes[0].params.orderNo, 'PO-SAME') - } finally { recovered.dispose() } + Object.assign(harness.state.form, draft()); await harness.state.submit() + assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0) + harness.state.form.productName = 'changed'; await harness.state.submit() + assert.equal(calls[1].productName, 'changed'); assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey) + assert.equal(harness.state.submitting.value, false) + } finally { harness.dispose() } }) test('submission double-click sends once and definitive validation rejection permits correction', async () => { @@ -129,7 +119,7 @@ test('submission double-click sends once and definitive validation rejection per try { Object.assign(harness.state.form, draft()); const first = harness.state.submit(); await harness.state.submit(); assert.equal(calls, 1) reject(new ApiError('invalid', 'A0422')); await first - assert.equal(harness.state.pending.value, null); assert.equal(harness.storage.size, 0) + assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0) } finally { harness.dispose() } }) @@ -149,24 +139,18 @@ test('pagination batches one request and switching account clears old rows befor }) -test('replay rejection cannot discard an earlier unknown creation outcome', async () => { - const calls = []; let attempt = 0 - const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { - calls.push(normalized(payload)); attempt++ - if (attempt === 1) throw new ApiError('timeout', 'TIMEOUT') - throw new ApiError('permission revoked', 'A0403') - } } }) +test('consecutive timeout and permission failures never persist or freeze a submission', async () => { + let attempt = 0 + const harness = viewHarness({ createPage: true, api: { createProcurement: async () => { throw new ApiError('failed', ++attempt === 1 ? 'TIMEOUT' : 'A0403') } } }) try { - Object.assign(harness.state.form, draft()); await harness.state.submit() - const saved = [...harness.storage.values()][0]; await harness.state.submit() - assert.equal([...harness.storage.values()][0], saved); assert.ok(harness.state.pending.value) - assert.deepEqual(calls[1], calls[0]) + Object.assign(harness.state.form, draft()); await harness.state.submit(); await harness.state.submit() + assert.equal(attempt, 2); assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0) + harness.state.form.productName = 'editable'; assert.equal(harness.state.form.productName, 'editable') } finally { harness.dispose() } }) test('purchase category is optional while its length remains bounded', () => { const payload = presentation.buildProcurementCreate({ ...draft(), purchaseCategory: ' ' }, 'Company', 'key') assert.equal(payload.purchaseCategory, '') - assert.equal(submission.restoreProcurementSubmission(JSON.stringify(payload)).purchaseCategory, '') assert.throws(() => presentation.buildProcurementCreate({ ...draft(), purchaseCategory: 'x'.repeat(129) }, 'Company', 'key'), /品类/) }) diff --git a/src/modules/cross-border/procurementSubmission.ts b/src/modules/cross-border/procurementSubmission.ts index 3c4e9c4..ef5d581 100644 --- a/src/modules/cross-border/procurementSubmission.ts +++ b/src/modules/cross-border/procurementSubmission.ts @@ -1,23 +1,3 @@ -import type { ProcurementCreate } from './api/procurement' -import { buildProcurementCreate, emptyProcurementDraft } from './procurementPresentation' - -/** Restore the exact request; an unknown result cannot become a new order. */ -export function restoreProcurementSubmission(raw: string): ProcurementCreate { - const saved: unknown = JSON.parse(raw) - if (!saved || typeof saved !== 'object' || Array.isArray(saved)) throw new Error('提交记录格式错误') - const value = saved as Record - for (const key of ['customerName', 'purchaseCategory', 'productName', 'currency', 'receivingAddress', 'contactName', 'contactPhone', 'idempotencyKey']) if (typeof value[key] !== 'string') throw new Error('提交记录不完整') - if (typeof value.purchaseQuantity !== 'number' || typeof value.budgetUnitPrice !== 'number' || !String(value.idempotencyKey).trim() || String(value.idempotencyKey).length > 255) throw new Error('提交记录不完整') - for (const key of ['productSpecification', 'productUrl', 'remark']) if (value[key] !== undefined && typeof value[key] !== 'string') throw new Error('提交记录不完整') - if (value.currency !== 'CNY' && value.currency !== 'USD') throw new Error('提交记录币种无效') - return buildProcurementCreate({ ...emptyProcurementDraft(), purchaseCategory: String(value.purchaseCategory), productName: String(value.productName), - productSpecification: String(value.productSpecification || ''), productUrl: String(value.productUrl || ''), purchaseQuantity: String(value.purchaseQuantity), - budgetUnitPrice: String(value.budgetUnitPrice), currency: value.currency, receivingAddress: String(value.receivingAddress), contactName: String(value.contactName), - contactPhone: String(value.contactPhone), remark: String(value.remark || '') }, String(value.customerName), String(value.idempotencyKey)) -} -export function definitelyRejected(error: { code?: string; status?: number }) { - return ['A0400', 'A0401', 'A0403', 'A0422'].includes(error.code || '') || [400, 401, 403, 422].includes(error.status || 0) -} export function procurementRequestKey(provider: Pick & Partial> = globalThis.crypto) { if (provider.randomUUID) return provider.randomUUID() return `proc-${Array.from(provider.getRandomValues(new Uint32Array(4)), value => value.toString(16).padStart(8, '0')).join('')}` diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index ea9ed66..e54fbeb 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -9,9 +9,9 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import type { TableAction } from '@/components/common/tableActions' import ProcurementDetailPanel from '../components/ProcurementDetailPanel.vue' -import { createProcurement, getProcurement, listProcurements, type ProcurementCreate, type ProcurementDetail, type ProcurementSummary } from '../api/procurement' +import { createProcurement, getProcurement, listProcurements, type ProcurementDetail, type ProcurementSummary } from '../api/procurement' import { buildProcurementCreate, dateTime, draftBudgetTotal, emptyProcurementDraft, money, procurementStatuses, statusLabel, statusTone } from '../procurementPresentation' -import { definitelyRejected, procurementRequestKey, restoreProcurementSubmission } from '../procurementSubmission' +import { procurementRequestKey } from '../procurementSubmission' const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false }) const auth = useAuthStore(), route = useRoute(), router = useRouter() @@ -20,11 +20,9 @@ const orderNo = computed(() => typeof route.params.orderNo === 'string' ? route. const canCreate = computed(() => auth.hasPermission('merchant:cross-border:procurement:create')) const canList = computed(() => auth.hasPermission('merchant:cross-border:procurement:list')) const identity = computed(() => auth.token ? accessTokenIdentity(auth.token) : null) -const storageKey = computed(() => identity.value ? `cc-procurement-submit:${encodeURIComponent(identity.value)}` : null) const companyName = computed(() => auth.profile.tenantName || (auth.user?.tenantName && !/^企业租户 \d+$/.test(auth.user.tenantName) ? auth.user.tenantName : '')) const form = reactive(emptyProcurementDraft()) -const pending = shallowRef(null) -const recoveryError = ref(''), submitError = ref(''), submittedOrder = ref('') +const submitError = ref(''), submittedOrder = ref('') const submitting = ref(false), loading = ref(false), error = ref('') const detail = shallowRef(null) const rows = ref([]), total = ref(0) @@ -74,43 +72,22 @@ async function loadDetail() { } function search() { if (query.page === 1) void loadList(); else query.page = 1 } function resetQuery() { const reload = query.page === 1; Object.assign(query, { page: 1, orderNo: '', purchaseCategory: '', productName: '', status: '' }); if (reload) void loadList() } -function populate(payload: ProcurementCreate) { - Object.assign(form, { purchaseCategory: payload.purchaseCategory, productName: payload.productName, productSpecification: payload.productSpecification || '', productUrl: payload.productUrl || '', - purchaseQuantity: String(payload.purchaseQuantity), budgetUnitPrice: String(payload.budgetUnitPrice), currency: payload.currency, receivingAddress: payload.receivingAddress, - contactName: payload.contactName, contactPhone: payload.contactPhone, remark: payload.remark || '' }) -} -function restorePending() { - pending.value = null; recoveryError.value = ''; submitError.value = ''; submittedOrder.value = ''; Object.assign(form, emptyProcurementDraft()) - if (!storageKey.value) return - try { const saved = sessionStorage.getItem(storageKey.value); if (saved) { pending.value = Object.freeze(restoreProcurementSubmission(saved)); populate(pending.value) } } - catch { recoveryError.value = '上次提交记录暂无法恢复,请先在订单列表或通过客服核对提交结果,避免重复下单。' } -} +function resetForm() { submitError.value = ''; submittedOrder.value = ''; Object.assign(form, emptyProcurementDraft()) } async function submit() { - if (submitting.value || !canCreate.value || recoveryError.value || !storageKey.value) return - submitError.value = ''; const owner = identity.value, key = storageKey.value; const resuming = Boolean(pending.value) - let started = false + if (submitting.value || !canCreate.value || !identity.value || submittedOrder.value) return + const owner = identity.value; submitting.value = true; submitError.value = '' try { - if (!pending.value) { - const payload = buildProcurementCreate(form, companyName.value, procurementRequestKey()) - try { sessionStorage.setItem(key, JSON.stringify(payload)) } catch { throw new Error('浏览器暂无法保留提交信息,请允许会话存储后重试') } - pending.value = Object.freeze(payload) - } - submitting.value = true; started = true - const result = await createProcurement(pending.value) - sessionStorage.removeItem(key) + const result = await createProcurement(buildProcurementCreate(form, companyName.value, procurementRequestKey())) if (!active || owner !== identity.value) return - pending.value = null; submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交') + submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交') if (canList.value) await router.push({ name: 'cross-border-procurement-detail', params: { orderNo: result.order.orderNo } }) - } catch (cause) { - if (!active || owner !== identity.value) return - if (!resuming && cause instanceof ApiError && definitelyRejected(cause)) { sessionStorage.removeItem(key); pending.value = null } - submitError.value = started && pending.value ? `${message(cause)}。提交结果尚未确认,请再次确认上次提交结果。` : message(cause) - } finally { submitting.value = false } + } catch (cause) { if (active && owner === identity.value) submitError.value = message(cause) } + finally { submitting.value = false } } watch(() => [mode.value, orderNo.value, query.page, query.size, identity.value], (values, previous) => { if (values[4] !== previous?.[4]) { rows.value = []; total.value = 0 } ++requestSequence; inFlight = ''; loading.value = false; error.value = ''; detail.value = null - if (mode.value === 'create') restorePending() + if (mode.value === 'create') resetForm() else if (mode.value === 'detail') void loadDetail() else void loadList() }, { immediate: true }) @@ -140,12 +117,10 @@ onBeforeUnmount(() => { active = false; ++requestSequence })

海外采购下单

一单一个商品及规格;预算仅授权样品货款,运费及关税另行核定。

返回列表
- +
diff --git a/src/modules/first-leg/store/firstLegDraftCommands.test.mjs b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs index 8b012cf..9b3288e 100644 --- a/src/modules/first-leg/store/firstLegDraftCommands.test.mjs +++ b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs @@ -128,41 +128,25 @@ test('cancelled detail summary has no pending overseas synchronization entries', assert.deepEqual(summary, []) }) -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 = [{ ...original, 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) +test('edit preserves the detail version but generates a fresh command after failure', async t => { + const h = harness(t), session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) + h.store.orders = [{ id: 'one', version: 0 }]; h.behavior.failWrite = true + await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one', session), /响应丢失/) + h.behavior.failWrite = false; h.draft.customerRemark = '已修改' + 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) + assert.equal(writes[0].data.expectedVersion, 9); assert.equal(writes[1].data.draft.customerRemark, '已修改') + assert.notEqual(writes[0].headers['Idempotency-Key'], writes[1].headers['Idempotency-Key']) }) - -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 +test('missing edit session is invalid while changed inputs after failure are accepted', async t => { + const h = harness(t); await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one'), /重新打开/) + 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)) + h.draft.customerRemark = '不同内容'; h.behavior.failWrite = false + await h.store.saveDraft(h.draft, '客户', 'one', session) + assert.equal(h.calls.filter(call => call.method === 'PUT').length, 2) }) - -test('read failure after successful edit preserves the same command for retry', async t => { +test('read failure after edit does not retain a frozen request', async t => { const h = harness(t) const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) h.behavior.failRead = true @@ -170,28 +154,17 @@ test('read failure after successful edit preserves the same command for retry', 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]) + assert.notEqual(writes[0].headers['Idempotency-Key'], writes[1].headers['Idempotency-Key']) }) -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 +test('cancel submits the current version and reason after a previous failure', async t => { + const h = harness(t); h.behavior.status = 'SUBMITTED'; 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) + h.behavior.failWrite = false; await h.store.cancelOrder('one', 13, '其他原因') + const writes = h.calls.filter(call => call.method === 'POST') + assert.equal(writes.length, 2); assert.deepEqual(writes[1].data, { expectedVersion: 13, reason: '其他原因' }) + assert.notEqual(writes[0].headers['Idempotency-Key'], writes[1].headers['Idempotency-Key']) }) - test('cancel requires a detail version and a nonempty reason', async t => { const h = harness(t) await assert.rejects(h.store.cancelOrder('one', undefined, '理由')) @@ -252,24 +225,13 @@ test('cancel UI uses mandatory-reason centered prompt and refreshes after succes assert.deepEqual(h.calls[1], ['cancel', 'one', 12, '客户申请取消']) }) -test('cancel UI keeps an uncertain command for explicit retry, but conflict requires a fresh decision', async () => { - const h = cancellationViewHarness() - const order = { id: 'one', status: 'DRAFT' } - h.behavior.error = new Error('网络无响应') - await h.handler(order) - assert.equal(h.pendingCancellation.value.expectedVersion, 12) - h.behavior.error = null - await h.handler(order) - assert.equal(h.prompts.length, 1) - assert.equal(h.calls.filter(call => call[0] === 'prepare').length, 1) - assert.equal(h.calls.filter(call => call[0] === 'confirm').length, 1) - assert.deepEqual(h.calls.filter(call => call[0] === 'cancel')[0], h.calls.filter(call => call[0] === 'cancel')[1]) - h.behavior.error = new h.ApiError() - await h.handler(order) - assert.equal(h.pendingCancellation.value, undefined) - assert.match(h.messages.at(-1), /刷新订单后重新确认/) +test('cancel UI releases failure and asks for a current cancellation decision on the next click', async () => { + const h = cancellationViewHarness(), order = { id: 'one', status: 'DRAFT' } + h.behavior.error = new Error('网络无响应'); await h.handler(order) + h.behavior.error = null; await h.handler(order) + assert.equal(h.prompts.length, 2); assert.equal(h.calls.filter(call => call[0] === 'prepare').length, 2) + assert.equal(h.calls.filter(call => call[0] === 'cancel').length, 2) }) - test('cancel UI blocks unavailable permission and irreversible visible stages before reads or writes', async () => { const h = cancellationViewHarness() h.canCancelOrder.value = false @@ -286,23 +248,14 @@ test('both editor entry points capture detail sessions and both save paths pass assert.equal((view.match(/store\.saveDraft(?:ForSubmission)?\(cloneDraft\(form\), tenantStore\.company\.companyName, editingId\.value, editingSession\.value\)/g) ?? []).length, 2) }) -test('submit reads raw detail version rather than list zero and replays original key and version after timeout', async t => { - const h = harness(t) - h.store.orders = [{ id: 'one', status: 'DRAFT', version: 0 }] - h.behavior.failSubmit = true +test('submit uses the current raw version and a fresh key after timeout', async t => { + const h = harness(t); h.store.orders = [{id:'one',status:'DRAFT',version:0}]; h.behavior.failSubmit = true await assert.rejects(h.store.submitSaved('one', 'ACTIVE'), /响应丢失/) - const first = h.calls[1] - assert.equal(first.url, '/merchant/v1/orders/first-leg/one/submit') - assert.deepEqual(first.data, { expectedVersion: 9 }) - assert.ok(first.headers['Idempotency-Key']) - h.behavior.version = 10; h.behavior.status = 'SUBMITTED'; h.behavior.failSubmit = false - await h.store.submitSaved('one', 'ACTIVE') - assert.deepEqual(h.calls[3], first) - assert.deepEqual(h.calls.map(call => call.method), ['GET', 'POST', 'GET', 'POST', 'GET']) - assert.equal(h.store.hasPendingSubmission('one'), false) + h.behavior.version = 10; h.behavior.failSubmit = false; await h.store.submitSaved('one', 'ACTIVE') + const writes = h.calls.filter(call => call.url.endsWith('/submit')) + assert.deepEqual(writes.map(call => call.data.expectedVersion), [9,10]); assert.notEqual(writes[0].headers['Idempotency-Key'],writes[1].headers['Idempotency-Key']) }) - -test('submit refuses missing version or non-draft first decision and preserves command after failed readback', async t => { +test('submit validates current status and releases failure after readback', async t => { const h = harness(t) for (const version of [undefined, -1, 1.5]) { h.behavior.version = version @@ -313,49 +266,25 @@ test('submit refuses missing version or non-draft first decision and preserves c assert.ok(h.calls.every(call => call.method === 'GET')) h.behavior.status = 'DRAFT'; h.behavior.failSubmitRead = true await assert.rejects(h.store.submitSaved('one'), /读取失败/) - assert.equal(h.store.hasPendingSubmission('one'), true) h.behavior.failRead = false; h.behavior.failSubmitRead = false await h.store.submitSaved('one') const posts = h.calls.filter(call => call.method === 'POST') - assert.deepEqual(posts[0], posts[1]) + assert.notEqual(posts[0].headers['Idempotency-Key'], posts[1].headers['Idempotency-Key']) assert.deepEqual(posts[0].data, { expectedVersion: 0 }, 'raw detail zero is valid') }) -test('save then submit retries without another save and changed form requires an explicit new decision', async t => { - const h = harness(t) - const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', session) - h.behavior.failSubmit = true - await assert.rejects(h.store.submitSaved('one')) - const first = h.calls.find(call => call.url.endsWith('/submit')) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', session) - assert.equal(h.calls.filter(call => call.method === 'PUT').length, 1) - h.draft.customerRemark = '新决定' - await assert.rejects(h.store.saveDraftForSubmission(h.draft, '客户', 'one', session), /重新编辑/) - h.behavior.status = 'SUBMITTED' - await assert.rejects(h.store.beginNewSubmissionEdit('one'), /不能重新编辑/) - assert.equal(h.store.hasPendingSubmission('one'), true) - h.behavior.status = 'DRAFT'; h.behavior.version = 12 - const newSession = await h.store.beginNewSubmissionEdit('one') - assert.equal(newSession.expectedVersion, 12) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', newSession) - h.behavior.version = 13; h.behavior.failSubmit = false - await h.store.submitSaved('one') - const posts = h.calls.filter(call => call.url.endsWith('/submit')) - assert.notEqual(posts[1].headers['Idempotency-Key'], first.headers['Idempotency-Key']) - assert.deepEqual(posts[1].data, { expectedVersion: 13 }) +test('failed submit leaves the draft editable for a fresh save and submission', async t => { + const h = harness(t), session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) + await h.store.saveDraft(h.draft, '客户', 'one', session); h.behavior.failSubmit = true + await assert.rejects(h.store.submitSaved('one')); h.draft.customerRemark = '新内容' + await h.store.saveDraft(h.draft, '客户', 'one', session); h.behavior.failSubmit = false + await h.store.submitSaved('one'); assert.equal(h.calls.filter(call => call.method === 'PUT').length, 2) }) - -test('failed initial submit read does not resave the prepared draft on retry', async t => { - const h = harness(t) - const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', session) - h.behavior.failRead = true - await assert.rejects(h.store.submitSaved('one')) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', session) - assert.equal(h.calls.filter(call => call.method === 'PUT').length, 1) +test('failed initial submit read releases the submission guard for the next click', async t => { + const h = harness(t); h.behavior.failRead = true; await assert.rejects(h.store.submitSaved('one')) + h.behavior.failRead = false; await h.store.submitSaved('one') + assert.equal(h.calls.filter(call => call.url.endsWith('/submit')).length, 1) }) - async function submissionViewHarness(t) { const h = harness(t) const source = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8').match(/