fix(first-leg): freeze versioned submission commands across retries

This commit is contained in:
2026-10-02 23:08:58 +08:00
parent 187deb5047
commit e564353a80
6 changed files with 251 additions and 30 deletions
+2 -1
View File
@@ -574,10 +574,11 @@ export function cancelMerchantFirstLegOrder(id: number | string, payload: { expe
return apiRequest<void>({ method: 'POST', url: `/merchant/v1/orders/first-leg/${id}/cancel`, data: payload, headers: { 'Idempotency-Key': idempotencyKey } }) return apiRequest<void>({ method: 'POST', url: `/merchant/v1/orders/first-leg/${id}/cancel`, data: payload, headers: { 'Idempotency-Key': idempotencyKey } })
} }
export function submitMerchantFirstLegOrder(id: number | string, idempotencyKey: string) { export function submitMerchantFirstLegOrder(id: number | string, payload: { expectedVersion: number }, idempotencyKey: string) {
return apiRequest<void>({ return apiRequest<void>({
method: 'POST', method: 'POST',
url: `/merchant/v1/orders/first-leg/${id}/submit`, url: `/merchant/v1/orders/first-leg/${id}/submit`,
data: payload,
headers: { 'Idempotency-Key': idempotencyKey }, headers: { 'Idempotency-Key': idempotencyKey },
}) })
} }
@@ -23,7 +23,9 @@ function harness(t) {
if (behavior.failRead) throw new Error('读取失败') if (behavior.failRead) throw new Error('读取失败')
return { order: { id: 'one', orderNo: 'FL-ONE', version: behavior.version, status: behavior.status, receivingWarehouseId: 'warehouse', destinationCountryCode: 'US' }, cargoes: [] } return { order: { id: 'one', orderNo: 'FL-ONE', version: behavior.version, status: behavior.status, receivingWarehouseId: 'warehouse', destinationCountryCode: 'US' }, cargoes: [] }
} }
if (behavior.failWrite) throw new Error('响应丢失') if (behavior.failWrite || (behavior.failSubmit && config.url.endsWith('/submit'))) throw new Error('响应丢失')
if (config.url.endsWith('/submit') && behavior.failSubmitRead) behavior.failRead = true
if (config.method === 'POST' && config.url === '/merchant/v1/orders/first-leg') return 'one'
} } }) } } })
const module = compile('./firstLegOrders.ts', { const module = compile('./firstLegOrders.ts', {
vue, pinia, vue, pinia,
@@ -53,6 +55,23 @@ test('raw and unified cancelled detail remain CANCELLED even when unified mainSt
assert.notEqual(unknown.status, 'CANCELLED', 'unknown summary must not be reclassified as cancellation') assert.notEqual(unknown.status, 'CANCELLED', 'unknown summary must not be reclassified as cancellation')
}) })
test('fresh direct draft detail preserves raw DRAFT with null unified summary and opens an edit session', async t => {
const h = harness(t)
assert.equal(h.store.orders.length, 0)
const direct = await h.store.loadDetail('one')
assert.equal(direct.status, 'DRAFT')
assert.equal(h.store.beginDraftEdit(direct).expectedVersion, 9)
h.store.orders = []
const refreshed = await h.store.loadDetail('one')
assert.equal(refreshed.status, 'DRAFT')
h.store.orders = []
h.behavior.status = 'APPROVED'
const unknown = await h.store.loadDetail('one')
assert.notEqual(unknown.status, 'DRAFT')
assert.notEqual(unknown.status, 'CANCELLED')
assert.throws(() => h.store.beginDraftEdit(unknown), /重新打开/)
})
test('cancelled detail summary has no pending overseas synchronization entries', () => { test('cancelled detail summary has no pending overseas synchronization entries', () => {
const source = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8') const source = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8')
const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1] const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
@@ -217,5 +236,150 @@ test('cancel UI blocks unavailable permission and irreversible visible stages be
test('both editor entry points capture detail sessions and both save paths pass them', () => { test('both editor entry points capture detail sessions and both save paths pass them', () => {
const view = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8') const view = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8')
assert.equal((view.match(/editingSession\.value = store\.beginDraftEdit\(detailedOrder\)/g) ?? []).length, 2) assert.equal((view.match(/editingSession\.value = store\.beginDraftEdit\(detailedOrder\)/g) ?? []).length, 2)
assert.equal((view.match(/store\.saveDraft\(cloneDraft\(form\), tenantStore\.company\.companyName, editingId\.value, editingSession\.value\)/g) ?? []).length, 2) 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
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)
})
test('submit refuses missing version or non-draft first decision and preserves command after failed readback', async t => {
const h = harness(t)
for (const version of [undefined, -1, 1.5]) {
h.behavior.version = version
await assert.rejects(h.store.submitSaved('one'), /版本缺失/)
}
h.behavior.version = 0; h.behavior.status = 'SUBMITTED'
await assert.rejects(h.store.submitSaved('one'), /不是草稿/)
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.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 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)
})
async function submissionViewHarness(t) {
const h = harness(t)
const source = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const ast = ts.createSourceFile('view.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const functions = ast.statements.filter(node => ts.isFunctionDeclaration(node) && ['submitOrder', 'confirmSubmissionEdit', 'submitSelectedDraftOrders'].includes(node.name?.text)).map(node => node.getText(ast)).join('\n')
const code = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const messages = [], confirmations = []
const globals = {
store: h.store, saving: vue.ref(false), form: h.draft, cloneDraft: draft => structuredClone(draft),
editingId: vue.ref('one'), editingSession: vue.shallowRef(h.store.beginDraftEdit(await h.store.loadDraftDetail('one'))),
companySubmissionBlocked: vue.ref(false), companyAuditLoaded: vue.ref(true), tenantStore: { company: { companyName: '客户', auditStatus: 'ACTIVE' } },
validateEditorForm: async () => true, markEditorClean: () => {}, editorOpen: vue.ref(true), isCreatePage: vue.ref(false), loadPageData: async () => {},
batchSubmitting: vue.ref(false), selectedDraftOrders: vue.ref([{ id: 'one', version: 0 }]), pendingBatchSubmissionIds: vue.ref([]),
searchOrders: async () => { globals.selectedDraftOrders.value = [] }, appliedFilters: vue.ref({}),
ElMessageBox: { confirm: async message => { confirmations.push(message); if (h.behavior.cancelConfirm) throw 'cancel' } },
ElMessage: { error: message => messages.push(message), warning: message => messages.push(message), success: message => messages.push(message) },
}
const handlers = new Function(...Object.keys(globals), code + '\nreturn {submitOrder, submitSelectedDraftOrders}')(...Object.values(globals))
return { ...h, ...handlers, globals, messages, confirmations }
}
test('real editor submit handler retries original submission without PUT and requires confirmation for changed form', async t => {
const h = await submissionViewHarness(t)
h.behavior.failSubmit = true
await h.submitOrder()
await h.submitOrder()
assert.equal(h.calls.filter(call => call.method === 'PUT').length, 1)
const posts = h.calls.filter(call => call.url.endsWith('/submit'))
assert.deepEqual(posts[0], posts[1])
h.draft.customerRemark = '修改后的决定'
h.behavior.cancelConfirm = true
await h.submitOrder()
assert.equal(h.confirmations.length, 1)
assert.equal(h.calls.filter(call => call.method === 'PUT').length, 1)
h.behavior.cancelConfirm = false; h.behavior.failSubmit = false; h.behavior.version = 12
await h.submitOrder()
assert.equal(h.confirmations.length, 2)
assert.equal(h.calls.filter(call => call.method === 'PUT').length, 2)
const last = h.calls.filter(call => call.url.endsWith('/submit')).at(-1)
assert.deepEqual(last.data, { expectedVersion: 12 })
assert.notEqual(last.headers['Idempotency-Key'], posts[0].headers['Idempotency-Key'])
assert.equal(h.globals.saving.value, false)
})
test('real batch handler retains uncertain IDs across list refresh and retries original commands even if now submitted', async t => {
const h = await submissionViewHarness(t)
h.behavior.failSubmit = true
await h.submitSelectedDraftOrders()
assert.deepEqual(h.globals.pendingBatchSubmissionIds.value, ['one'])
assert.deepEqual(h.globals.selectedDraftOrders.value, [])
h.behavior.failSubmit = false; h.behavior.status = 'SUBMITTED'; h.behavior.version = 10
await h.submitSelectedDraftOrders()
const posts = h.calls.filter(call => call.url.endsWith('/submit'))
assert.equal(posts.length, 2)
assert.deepEqual(posts[1], posts[0])
assert.deepEqual(posts[0].data, { expectedVersion: 9 })
assert.deepEqual(h.globals.pendingBatchSubmissionIds.value, [])
assert.equal(h.globals.batchSubmitting.value, false)
})
test('real new-order handler creates once and retries the saved order submission with the original version', async t => {
const h = await submissionViewHarness(t)
h.globals.editingId.value = undefined; h.globals.editingSession.value = undefined
h.behavior.version = 0; h.behavior.failSubmit = true
await h.submitOrder()
assert.equal(h.globals.editingId.value, 'one')
h.behavior.version = 1; h.behavior.status = 'SUBMITTED'; h.behavior.failSubmit = false
await h.submitOrder()
assert.equal(h.calls.filter(call => call.method === 'POST' && call.url === '/merchant/v1/orders/first-leg').length, 1)
assert.equal(h.calls.filter(call => call.method === 'PUT').length, 0)
const posts = h.calls.filter(call => call.url.endsWith('/submit'))
assert.deepEqual(posts[0], posts[1])
assert.deepEqual(posts[0].data, { expectedVersion: 0 })
}) })
@@ -213,13 +213,13 @@ test('uses the versioned merchant cancellation contract', () => {
test('sends an idempotency key for create and submit writes', () => { test('sends an idempotency key for create and submit writes', () => {
assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\('first-leg-create'\)/) assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\('first-leg-create'\)/)
assert.match(apiSource, /submitMerchantFirstLegOrder\(id: number \| string, idempotencyKey: string\)/) assert.match(apiSource, /submitMerchantFirstLegOrder\(id: number \| string, payload: \{ expectedVersion: number \}, idempotencyKey: string\)/)
assert.match(apiSource, /'Idempotency-Key': idempotencyKey/) assert.match(apiSource, /'Idempotency-Key': idempotencyKey/)
assert.match(apiSource, /createMerchantFirstLegOrderBatch\(items: MerchantBatchDraftItem\[\], idempotencyKey: string\)/) assert.match(apiSource, /createMerchantFirstLegOrderBatch\(items: MerchantBatchDraftItem\[\], idempotencyKey: string\)/)
assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/) assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
assert.match(storeSource, /createMerchantFirstLegOrderBatch\(items, idempotencyKey\)/) assert.match(storeSource, /createMerchantFirstLegOrderBatch\(items, idempotencyKey\)/)
assert.match(storeSource, /pendingSubmitKeys\.get\(id\) \?\? createIdempotencyKey\(`first-leg-submit-\$\{id\}`\)/) assert.match(storeSource, /pendingSubmissions\.get\(id\)/)
assert.match(storeSource, /submitMerchantFirstLegOrder\(id, idempotencyKey\)/) assert.match(storeSource, /submitMerchantFirstLegOrder\(id, pending\.payload, pending\.key\)/)
}) })
test('does not expose the contradictory backend rejected state in the client model', () => { test('does not expose the contradictory backend rejected state in the client model', () => {
+52 -11
View File
@@ -384,7 +384,8 @@ function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: P
if (!detail?.summary || !detail.order?.order) throw new Error('订单详情数据不完整,请刷新列表后重试') if (!detail?.summary || !detail.order?.order) throw new Error('订单详情数据不完整,请刷新列表后重试')
const mapped = mapOrder(detail.order.order, warehouses, detail.order) const mapped = mapOrder(detail.order.order, warehouses, detail.order)
mapped.id = String(detail.summary.orderId) mapped.id = String(detail.summary.orderId)
mapped.status = detail.order.order.status === 'CANCELLED' ? 'CANCELLED' : detail.summary.mainStatus const rawStatus = detail.order.order.status
mapped.status = rawStatus === 'CANCELLED' || rawStatus === 'DRAFT' ? rawStatus : detail.summary.mainStatus
mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType
mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode
mapped.businessSummary = businessSummary(detail.summary) mapped.businessSummary = businessSummary(detail.summary)
@@ -476,7 +477,9 @@ export interface DraftEditSession {
export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () => { export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () => {
const orders = ref<FirstLegOrder[]>([]) const orders = ref<FirstLegOrder[]>([])
const pendingSubmitKeys = new Map<string, string>() const pendingSubmissions = new Map<string, { key: string; payload: { expectedVersion: number } }>()
const submissionForms = new Map<string, { snapshot: string; order: FirstLegOrder }>()
const submittingIds = new Set<string>()
const pendingEdits = new WeakMap<DraftEditSession, { key: string; payload: { expectedVersion: number; draft: FirstLegOrderSaveRequest } }>() const pendingEdits = new WeakMap<DraftEditSession, { key: string; payload: { expectedVersion: number; draft: FirstLegOrderSaveRequest } }>()
const pendingCancellations = new Map<string, { key: string; payload: { expectedVersion: number; reason: string } }>() const pendingCancellations = new Map<string, { key: string; payload: { expectedVersion: number; reason: string } }>()
@@ -602,6 +605,7 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
} }
async function saveDraft(draft: FirstLegOrderDraft, customerName: string, id?: string, editSession?: DraftEditSession) { async function saveDraft(draft: FirstLegOrderDraft, customerName: string, id?: string, editSession?: DraftEditSession) {
if (hasPendingSubmission(id)) throw new Error('上次提交结果未确认,请重试原提交,或确认重新编辑后再保存')
const warehouse = prepareDraftForSave(draft, warehouses.value) const warehouse = prepareDraftForSave(draft, warehouses.value)
if (id) { if (id) {
if (!editSession || editSession.orderId !== id) throw new Error('请重新打开草稿详情后编辑') if (!editSession || editSession.orderId !== id) throw new Error('请重新打开草稿详情后编辑')
@@ -661,21 +665,58 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
async function submitSaved(id: string, companyAuditStatus?: MerchantTenantStatus) { async function submitSaved(id: string, companyAuditStatus?: MerchantTenantStatus) {
if (companyAuditStatus && companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核') if (companyAuditStatus && companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核')
const idempotencyKey = pendingSubmitKeys.get(id) ?? createIdempotencyKey(`first-leg-submit-${id}`) if (submittingIds.has(id)) throw new Error('订单正在提交,请稍候')
pendingSubmitKeys.set(id, idempotencyKey) submittingIds.add(id)
await submitMerchantFirstLegOrder(id, idempotencyKey) try {
pendingSubmitKeys.delete(id) let pending = pendingSubmissions.get(id)
return loadUnifiedDetail(id) // Always observe the raw detail; a retry must retain the original command.
const { order } = await getMerchantFirstLegOrder(id)
if (!pending) {
if (order.status !== 'DRAFT') throw new Error('当前订单不是草稿,请刷新订单查看最新状态')
if (!Number.isSafeInteger(order.version) || order.version < 0) throw new Error('订单版本缺失,请重新加载详情')
pending = { key: createIdempotencyKey(`first-leg-submit-${id}`), payload: { expectedVersion: order.version } }
pendingSubmissions.set(id, pending)
}
await submitMerchantFirstLegOrder(id, pending.payload, pending.key)
const submitted = await loadLegacyDetail(id)
pendingSubmissions.delete(id)
submissionForms.delete(id)
return submitted
} finally {
submittingIds.delete(id)
}
} }
async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus?: MerchantTenantStatus, editSession?: DraftEditSession) { function hasPendingSubmission(id?: string) { return !!id && (pendingSubmissions.has(id) || submissionForms.has(id)) }
const order = await saveDraft(draft, customerName, id, editSession)
return submitSaved(order.id, companyAuditStatus) function isSubmissionRetry(id: string | undefined, draft: FirstLegOrderDraft) {
return !!id && hasPendingSubmission(id) && submissionForms.get(id)?.snapshot === JSON.stringify(draft)
}
async function beginNewSubmissionEdit(id: string) {
if (submittingIds.has(id)) throw new Error('订单正在提交,请稍候')
const detail = await getMerchantFirstLegOrder(id)
if (detail.order.status !== 'DRAFT') throw new Error('订单已不在草稿状态,不能重新编辑,请刷新订单查看最新状态')
if (!Number.isSafeInteger(detail.order.version) || detail.order.version < 0) throw new Error('订单版本缺失,请重新加载详情')
const order = mapOrder(detail.order, warehouses.value, detail)
const session = beginDraftEdit(order)
pendingSubmissions.delete(id)
submissionForms.delete(id)
upsertOrder(order)
return session
}
async function saveDraftForSubmission(draft: FirstLegOrderDraft, customerName: string, id?: string, editSession?: DraftEditSession) {
if (id && isSubmissionRetry(id, draft)) return submissionForms.get(id)!.order
const snapshot = JSON.stringify(draft)
const order = await saveDraft(JSON.parse(snapshot), customerName, id, editSession)
submissionForms.set(order.id, { snapshot, order })
return order
} }
async function loadDraftDetail(id: string) { async function loadDraftDetail(id: string) {
return loadLegacyDetail(id, getById(id)) return loadLegacyDetail(id, getById(id))
} }
return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, beginDraftEdit, saveDraft, prepareCancellation, cancelOrder, saveDraftBatch, submitSaved, submit } return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, beginDraftEdit, saveDraft, prepareCancellation, cancelOrder, saveDraftBatch, submitSaved, hasPendingSubmission, isSubmissionRetry, beginNewSubmissionEdit, saveDraftForSubmission }
}) })
@@ -636,8 +636,8 @@ test('shows billing-weight confirmation only when the backend marks it pending',
test('submits only selected draft orders in one batch action', () => { test('submits only selected draft orders in one batch action', () => {
assert.match(source, /selectedDraftOrders = computed\(\(\) => selectedOrders\.value\.filter\(\(order\) => order\.status === 'DRAFT'\)\)/) assert.match(source, /selectedDraftOrders = computed\(\(\) => selectedOrders\.value\.filter\(\(order\) => order\.status === 'DRAFT'\)\)/)
assert.match(source, /Promise\.allSettled\(drafts\.map\(\(order\) => submitMerchantFirstLegOrder/) assert.match(source, /Promise\.allSettled\(ids\.map\(id => store\.submitSaved/)
assert.match(source, /createIdempotencyKey\(`first-leg-batch-submit-\$\{order\.id\}`\)/) assert.doesNotMatch(source, /submitMerchantFirstLegOrder/)
assert.match(source, /v-if="canSubmitOrder"[\s\S]*批量提交草稿/) assert.match(source, /v-if="canSubmitOrder"[\s\S]*批量提交草稿/)
}) })
@@ -29,7 +29,6 @@ import {
getMerchantOrderAddresses, getMerchantOrderAddresses,
getMerchantPricingChannels, getMerchantPricingChannels,
importMerchantFirstLegOrders, importMerchantFirstLegOrders,
submitMerchantFirstLegOrder,
updateMerchantOrderAddress, updateMerchantOrderAddress,
uploadMerchantProductImage, uploadMerchantProductImage,
type CustomerOrderAddress, type CustomerOrderAddress,
@@ -237,6 +236,7 @@ const tablePageSize = ref(10)
const downloadingMarks = ref(false) const downloadingMarks = ref(false)
const exportingOrders = ref(false) const exportingOrders = ref(false)
const batchSubmitting = ref(false) const batchSubmitting = ref(false)
const pendingBatchSubmissionIds = ref<string[]>([])
const selectedOrders = ref<FirstLegOrder[]>([]) const selectedOrders = ref<FirstLegOrder[]>([])
const selectedOrderCount = computed(() => selectedOrders.value.length) const selectedOrderCount = computed(() => selectedOrders.value.length)
const selectedDraftOrders = computed(() => selectedOrders.value.filter((order) => order.status === 'DRAFT')) const selectedDraftOrders = computed(() => selectedOrders.value.filter((order) => order.status === 'DRAFT'))
@@ -1738,14 +1738,14 @@ function handleOrderSelectionChange(rows: FirstLegOrder[]) {
} }
async function submitSelectedDraftOrders() { async function submitSelectedDraftOrders() {
const drafts = selectedDraftOrders.value const ids = [...new Set([...pendingBatchSubmissionIds.value, ...selectedDraftOrders.value.map(order => order.id)])]
if (batchSubmitting.value || !drafts.length) return if (batchSubmitting.value || !ids.length) return
if (companySubmissionBlocked.value) { if (companySubmissionBlocked.value) {
ElMessage.warning('企业资料审核通过后才能提交订单') ElMessage.warning('企业资料审核通过后才能提交订单')
return return
} }
try { try {
await ElMessageBox.confirm(`确认提交选中的 ${drafts.length} 条草稿订单吗?`, '批量提交订单', { await ElMessageBox.confirm(`确认提交 ${ids.length} 条草稿订单吗?${pendingBatchSubmissionIds.value.length ? '上次结果未确认的订单将使用原提交命令重试。' : ''}`, '批量提交订单', {
type: 'warning', type: 'warning',
confirmButtonText: '确认提交', confirmButtonText: '确认提交',
cancelButtonText: '取消', cancelButtonText: '取消',
@@ -1757,10 +1757,8 @@ async function submitSelectedDraftOrders() {
batchSubmitting.value = true batchSubmitting.value = true
try { try {
const results = await Promise.allSettled(drafts.map((order) => submitMerchantFirstLegOrder( const results = await Promise.allSettled(ids.map(id => store.submitSaved(id, companyAuditLoaded.value ? tenantStore.company.auditStatus : undefined)))
order.id, pendingBatchSubmissionIds.value = ids.filter((id, index) => results[index]?.status === 'rejected' && store.hasPendingSubmission(id))
createIdempotencyKey(`first-leg-batch-submit-${order.id}`),
)))
const successCount = results.filter((result) => result.status === 'fulfilled').length const successCount = results.filter((result) => result.status === 'fulfilled').length
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected') const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
await searchOrders(appliedFilters.value) await searchOrders(appliedFilters.value)
@@ -2012,6 +2010,7 @@ async function saveDraft() {
if (!await validateEditorForm()) return if (!await validateEditorForm()) return
saving.value = true saving.value = true
try { try {
if (!await confirmSubmissionEdit()) return
const order = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value, editingSession.value) const order = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value, editingSession.value)
editingId.value = order.id editingId.value = order.id
editingSession.value = store.beginDraftEdit(order) editingSession.value = store.beginDraftEdit(order)
@@ -2026,6 +2025,20 @@ async function saveDraft() {
} }
} }
async function confirmSubmissionEdit() {
if (!editingId.value || !store.hasPendingSubmission(editingId.value)) return true
try {
await ElMessageBox.confirm('上次提交结果尚未确认。确认重新读取订单并按当前表单重新编辑吗?只有服务端仍为草稿时才能开始新的提交决定。', '重新编辑订单', {
type: 'warning', center: true, confirmButtonText: '确认重新编辑', cancelButtonText: '返回',
})
} catch (error) {
if (error === 'cancel' || error === 'close') return false
throw error
}
editingSession.value = await store.beginNewSubmissionEdit(editingId.value)
return true
}
async function submitOrder() { async function submitOrder() {
if (saving.value) return if (saving.value) return
if (companySubmissionBlocked.value) { if (companySubmissionBlocked.value) {
@@ -2044,12 +2057,14 @@ async function submitOrder() {
} }
return return
} }
if (!await validateEditorForm()) return const retry = store.isSubmissionRetry(editingId.value, cloneDraft(form))
if (!retry && !await validateEditorForm()) return
try { try {
saving.value = true saving.value = true
if (!retry && !await confirmSubmissionEdit()) return
const knownCompanyAuditStatus = companyAuditLoaded.value ? tenantStore.company.auditStatus : undefined const knownCompanyAuditStatus = companyAuditLoaded.value ? tenantStore.company.auditStatus : undefined
const savedOrder = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value, editingSession.value) const savedOrder = await store.saveDraftForSubmission(cloneDraft(form), tenantStore.company.companyName, editingId.value, editingSession.value)
editingId.value = savedOrder.id editingId.value = savedOrder.id
editingSession.value = store.beginDraftEdit(savedOrder) editingSession.value = store.beginDraftEdit(savedOrder)
await store.submitSaved(savedOrder.id, knownCompanyAuditStatus) await store.submitSaved(savedOrder.id, knownCompanyAuditStatus)
@@ -2149,7 +2164,7 @@ async function searchOrders(model: QueryModel) {
</el-popover> </el-popover>
<el-button :loading="downloadingMarks" :disabled="selectedOrderCount === 0" @click="downloadShippingMarks"><Download :size="16" />下载唛头</el-button> <el-button :loading="downloadingMarks" :disabled="selectedOrderCount === 0" @click="downloadShippingMarks"><Download :size="16" />下载唛头</el-button>
<el-button :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button> <el-button :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button>
<el-button v-if="canSubmitOrder" :loading="batchSubmitting" :disabled="selectedDraftOrders.length === 0 || companySubmissionBlocked" @click="submitSelectedDraftOrders"><Send :size="17" />批量提交草稿</el-button> <el-button v-if="canSubmitOrder" :loading="batchSubmitting" :disabled="(selectedDraftOrders.length === 0 && pendingBatchSubmissionIds.length === 0) || companySubmissionBlocked" @click="submitSelectedDraftOrders"><Send :size="17" />{{ pendingBatchSubmissionIds.length ? '重试批量提交' : '批量提交草稿' }}</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button> <el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button>
<el-button v-if="props.showCreateAction && auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button> <el-button v-if="props.showCreateAction && auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>