fix: align audit and dispatch workflows and release failed requests
This commit is contained in:
@@ -9,7 +9,6 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ProcurementNodeActions from './ProcurementNodeActions.vue'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { readPendingProcurement } from '../procurementPending'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
|
||||
@@ -74,7 +73,7 @@ describe('procurement purchase node operations', () => {
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
|
||||
it('keeps ordinary correction drafts scoped to operator/customer/order while ignoring old requests', async () => {
|
||||
const value = fixture('FINANCE_ADJUST', false)
|
||||
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
|
||||
const original = JSON.stringify({ command: 'fees/correct-target', payload: { version: 3, idempotencyKey: 'original-request', feeId: '11', targetOriginalAmount: '11.00', reason: '原请求', voucherFileId: '501' } })
|
||||
@@ -90,12 +89,11 @@ describe('procurement purchase node operations', () => {
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem(storageKey)).toBe(original)
|
||||
expect(JSON.parse(sessionStorage.getItem(`${storageKey}:correction-draft`)!)).toMatchObject({ targetOriginalAmount: '12.00', feeId: '11', reason: '准备下一版本' })
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount(); wrapper = render(value)
|
||||
await click('恢复费用更正草稿')
|
||||
expect((wrapper.findComponent(AppDialog).find('input[placeholder="填写原费用链正确总额,最多两位小数"]').element as HTMLInputElement).value).toBe('12.00')
|
||||
await click('确认上次提交结果')
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/correct-target', JSON.parse(original).payload)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).not.toBeNull()
|
||||
const latest = structuredClone(value)
|
||||
latest.actions[0].allowed = true; latest.customerView.order.version = 9
|
||||
@@ -104,7 +102,7 @@ describe('procurement purchase node operations', () => {
|
||||
await click('恢复费用更正草稿'); await click('核对并继续确认')
|
||||
wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '本单凭证')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent(AppDialog).text()).toContain('原实际费用及来源关联尚未完成')
|
||||
latest.fees[0].walletStatus = 'SETTLED'; latest.fees[0].walletLedgerId = 'LEDGER-1'
|
||||
latest.customerView.fees = [{ feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', name: '样品货款', version: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeNo: null, walletStatus: 'SETTLED', sourceLinked: true }]
|
||||
@@ -251,22 +249,20 @@ describe('procurement purchase node operations', () => {
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'purchase', expect.objectContaining({ productName: '样品商品', specification: '黑色', quantity: 2, amount: '0.00', currency: 'USD', voucherFileId: '101', purchasedAt: '2026-10-08T10:00:00' }))
|
||||
})
|
||||
it('recovers an uncertain request after reload without changing its body or key', async () => {
|
||||
it('unknown request leaves inputs editable and a later click submits current input', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout'))
|
||||
let wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0][3]; expect(sessionStorage.getItem(storageKey)).not.toBeNull(); wrapper.unmount()
|
||||
wrapper = render(fixture('CANCEL')); await flushPromises()
|
||||
const retry = wrapper.findAll('button').find(button => button.text() === '确认上次提交结果')!; await retry.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(original); expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3]
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull(); await explain(wrapper, '修改后的原因'); await confirm(wrapper)
|
||||
const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3]
|
||||
expect(next.idempotencyKey).not.toBe(original.idempotencyKey); expect(next.reason).toBe('修改后的原因')
|
||||
})
|
||||
it('preserves an earlier uncertain financial or physical command when replay is rejected', async () => {
|
||||
it('consecutive failures never retain a request or block another click', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout')).mockRejectedValueOnce(new ApiResponseError('资源正在处理中', 'A0409'))
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
|
||||
const saved = sessionStorage.getItem(storageKey); expect(saved).not.toBeNull()
|
||||
await confirm(wrapper)
|
||||
expect(sessionStorage.getItem(storageKey)).toBe(saved)
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(vi.mocked(submitProcurementCommand).mock.calls[0][3])
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper); await confirm(wrapper)
|
||||
const calls = vi.mocked(submitProcurementCommand).mock.calls
|
||||
expect(calls).toHaveLength(2); expect(calls[1]![3].idempotencyKey).not.toBe(calls[0]![3].idempotencyKey)
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
})
|
||||
it('keeps a version conflict visible, releases the failed command and does not report success', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新', 'A0409'))
|
||||
@@ -300,9 +296,9 @@ describe('procurement purchase node operations', () => {
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1); complete(); await flushPromises()
|
||||
})
|
||||
it('rejects malformed recovery records instead of allowing a fresh competing operation', async () => {
|
||||
it('old malformed request storage does not disable current actions', async () => {
|
||||
sessionStorage.setItem(storageKey, '{bad')
|
||||
const wrapper = render(); expect(wrapper.text()).toContain('上次操作记录无法读取'); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined()
|
||||
expect(() => readPendingProcurement(JSON.stringify({ command: 'purchase', payload: { version: 3, idempotencyKey: 'a', amount: '-2' } }))).toThrow()
|
||||
const wrapper = render(); expect(wrapper.text()).not.toContain('上次操作记录无法读取')
|
||||
expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands'
|
||||
import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending'
|
||||
import { commandKey, type PendingProcurement } from '../procurementPending'
|
||||
import { readCorrectionDraft, type ProcurementCorrectionDraft } from '../procurementCorrectionDraft'
|
||||
import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes'
|
||||
import type { OverseasPurchaseWarehouseOption } from '../types'
|
||||
@@ -75,8 +75,6 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purch
|
||||
watch(key, () => {
|
||||
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null; editingDraft.value = false; correctionDraft.value = null; draftError.value = ''
|
||||
if (!key.value) return
|
||||
try { pending.value = readPendingProcurement(sessionStorage.getItem(key.value)) }
|
||||
catch { storageError.value = '上次操作记录无法读取,请先联系管理员核对该订单的处理结果。' }
|
||||
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
|
||||
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
|
||||
}, { immediate: true })
|
||||
@@ -123,11 +121,6 @@ function useCorrectionDraft() {
|
||||
const fee = props.detail.fees.find(row => row.id === finance.value.feeId)
|
||||
active.value = action; financialCommand.value = fee && !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) ? 'fees/replace-failed' : 'fees/correct-target'; editingDraft.value = false
|
||||
}
|
||||
async function retryPending() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); if (draftError.value) return }
|
||||
editingDraft.value = false; opened.value = false
|
||||
await submit()
|
||||
}
|
||||
watch(financialCommand, () => { if (editingDraft.value) return; finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null }, { flush: 'sync' })
|
||||
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
|
||||
function build(): ProcurementCommandBody {
|
||||
@@ -157,33 +150,29 @@ function build(): ProcurementCommandBody {
|
||||
async function submit() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); return }
|
||||
if (saving.value || uploading.value || storageError.value || !key.value) return
|
||||
const resuming = !!pending.value
|
||||
const scope = key.value; const orderNo = props.detail.customerView.order.orderNo; const customer = props.customerId
|
||||
if (!pending.value) {
|
||||
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
|
||||
try {
|
||||
if (managementBlock.value) throw new Error(managementBlock.value)
|
||||
const request = { command: command.value, payload: build() }
|
||||
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
|
||||
sessionStorage.setItem(scope, serialized); pending.value = request
|
||||
pending.value = request
|
||||
} catch (issue) { error.value = { message: issue instanceof Error ? issue.message : '提交信息保存失败,请重试' }; return }
|
||||
}
|
||||
const retryFee = pending.value.command === 'fees/retry' ? props.detail.fees.find(fee => fee.id === pending.value?.payload.feeId) : null
|
||||
const requiredPermission = pending.value.command === 'fees/retry' ? (retryFee ? feeRetryPermission(retryFee) : '') : `platform:overseas-business:purchase:${commandPermissions[pending.value.command]}`
|
||||
if (!requiredPermission || !access.can(requiredPermission)) { error.value = { message: '当前账号无此节点操作权限' }; return }
|
||||
if (!requiredPermission || !access.can(requiredPermission)) { pending.value = null; error.value = { message: '当前账号无此节点操作权限' }; return }
|
||||
saving.value = true; error.value = null
|
||||
const confirmsCorrection = !resuming && ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
|
||||
const confirmsCorrection = ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
|
||||
try {
|
||||
await submitProcurementCommand(orderNo, customer, pending.value.command, pending.value.payload)
|
||||
sessionStorage.removeItem(scope)
|
||||
if (key.value !== scope) return
|
||||
if (confirmsCorrection) { sessionStorage.removeItem(draftKey.value); correctionDraft.value = null }
|
||||
pending.value = null; opened.value = false; captureBaseline(); ElMessage.success('操作已保存,请核对最新业务及资金结果'); emit('changed')
|
||||
} catch (issue) {
|
||||
if (key.value !== scope) return
|
||||
if (!resuming && definitelyRejected(issue)) { sessionStorage.removeItem(scope); pending.value = null }
|
||||
error.value = getApiErrorDetails(issue, '结果尚未确认,请使用原请求重试')
|
||||
} finally { saving.value = false }
|
||||
error.value = getApiErrorDetails(issue, '操作失败,请重新提交')
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
@@ -212,10 +201,9 @@ async function upload(event: Event) {
|
||||
<dl><template v-for="action in unavailableActions" :key="action.key"><dt>{{ action.label }}</dt><dd>{{ action.reason || '当前不可操作' }}</dd></template></dl>
|
||||
</details>
|
||||
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
|
||||
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
|
||||
<ApiErrorMeta v-if="error && !opened" :details="error" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
|
||||
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="submit">
|
||||
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
|
||||
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
|
||||
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
|
||||
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
|
||||
@@ -235,7 +223,7 @@ async function upload(event: Event) {
|
||||
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></div></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -19,7 +19,6 @@ const commands = computed(() => access.can(overseasBusinessPermissions.truckStat
|
||||
const open = ref(false), saving = ref(false)
|
||||
const active = ref<TruckCommand | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
|
||||
// 响应丢失时保留原命令、版本和幂等键,重试不得变更请求内容。
|
||||
const pending = ref<TruckOperation | null>(null)
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
|
||||
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
|
||||
@@ -71,7 +70,7 @@ function buildOperation(): TruckOperation | null {
|
||||
async function submit() {
|
||||
if (saving.value || !active.value || !commands.value.includes(active.value)) return
|
||||
validationError.value = ''; error.value = null
|
||||
const operation = pending.value ?? buildOperation()
|
||||
const operation = buildOperation()
|
||||
if (!operation) return
|
||||
pending.value = operation; saving.value = true
|
||||
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
|
||||
@@ -83,20 +82,20 @@ async function submit() {
|
||||
emit('updated', updated)
|
||||
} catch (cause) {
|
||||
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
|
||||
} finally { saving.value = false }
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="commands.length" class="truck-order-actions">
|
||||
<el-button v-for="command in commands" :key="command" :type="command === 'cancel' ? 'danger' : 'primary'" :plain="command === 'cancel'" :disabled="saving" @click="start(command)">{{ truckCommandLabels[command] }}</el-button>
|
||||
<p v-if="pending && !open" role="status">上次操作尚未确认结果,请重新打开重试,或刷新订单核对最新记录。</p>
|
||||
|
||||
</div>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="pending ? '重试原操作' : '确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
|
||||
<template v-if="active">
|
||||
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
|
||||
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可重试原操作;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
|
||||
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
|
||||
<template v-if="active === 'receive-pallets'">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
|
||||
export interface PendingProcurement { command: ProcurementCommand; payload: ProcurementCommandBody }
|
||||
const commands = new Set(['review', 'purchase', 'purchase/confirm', 'budget-authorizations', 'planned-warehouse', 'cancel', 'overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke', 'labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment', 'charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct', 'exception-close'])
|
||||
@@ -7,126 +5,3 @@ export function commandKey() {
|
||||
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
export function readPendingProcurement(raw: string | null): PendingProcurement | null {
|
||||
if (raw === null) return null
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
|
||||
const payload = value.payload
|
||||
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
|
||||
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
|
||||
const body = payload as Record<string, unknown>
|
||||
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
|
||||
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
|
||||
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
|
||||
if (value.command === 'review') {
|
||||
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
|
||||
} else if (!nonempty('reason')) throw new Error('missing pending reason')
|
||||
if (value.command === 'planned-warehouse' && !positiveId('warehouseId')) throw new Error('invalid pending warehouse')
|
||||
if (['purchase', 'budget-authorizations'].includes(String(value.command))) {
|
||||
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !['CNY', 'USD'].includes(String(body.currency)) || !positiveId('voucherFileId')) throw new Error('invalid pending amount')
|
||||
}
|
||||
if (value.command === 'purchase' && (!nonempty('productName') || !Number.isSafeInteger(body.quantity) || Number(body.quantity) < 1 || !nonempty('purchasedAt') || !nonempty('estimatedArrivalAt'))) throw new Error('invalid pending purchase')
|
||||
const count = (field: string, minimum: number) => Number.isSafeInteger(body[field]) && Number(body[field]) >= minimum
|
||||
if (['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'receipts/correct', 'dispositions'].includes(String(value.command))) {
|
||||
if (!['OVERSEAS', 'DOMESTIC'].includes(String(body.leg))) throw new Error('invalid receipt leg')
|
||||
if ((String(value.command).startsWith('overseas') && body.leg !== 'OVERSEAS') || (String(value.command).startsWith('domestic') && body.leg !== 'DOMESTIC')) throw new Error('wrong receipt endpoint')
|
||||
}
|
||||
if (['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(String(value.command))) {
|
||||
if (!['goodQuantity', 'damagedQuantity', 'extraQuantity'].every(field => count(field, 0)) || Number(body.goodQuantity) + Number(body.damagedQuantity) + Number(body.extraQuantity) <= 0 || !nonempty('receivedAt')) throw new Error('invalid pending receipt')
|
||||
if (body.leg === 'OVERSEAS' ? !positiveId('warehouseId') || body.sourceCartonId != null : !positiveId('sourceCartonId') || body.warehouseId != null) throw new Error('invalid receipt source')
|
||||
}
|
||||
if (['receipts/correct', 'dispositions', 'dispositions/revoke'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing correction evidence')
|
||||
if (value.command === 'receipts/correct' && !positiveId('receiptId')) throw new Error('missing original receipt')
|
||||
if (value.command === 'dispositions/revoke' && !positiveId('dispositionId')) throw new Error('missing original disposition')
|
||||
if (value.command === 'dispositions') {
|
||||
const decisions: Record<string, string> = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' }
|
||||
if (!count('quantity', 1) || !decisions[String(body.kind)] || decisions[String(body.kind)] !== body.decision || (body.kind !== 'SHORTAGE' && !positiveId('receiptId')) || (body.kind === 'SHORTAGE' && body.receiptId != null) || (body.leg === 'DOMESTIC' && !positiveId('sourceCartonId'))) throw new Error('invalid pending disposition')
|
||||
}
|
||||
if (value.command === 'packing/confirm' && !count('packingVersion', 1)) throw new Error('invalid packing version')
|
||||
if (value.command === 'packing') {
|
||||
if (!Array.isArray(body.cartons) || !body.cartons.length) throw new Error('missing packing list')
|
||||
const names = new Set<string>()
|
||||
for (const item of body.cartons) {
|
||||
if (!item || typeof item !== 'object' || typeof item.cartonNo !== 'string' || !item.cartonNo.trim() || names.has(item.cartonNo) || Object.keys(item).some(key => !['cartonNo', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'sources'].includes(key))) throw new Error('invalid packing carton')
|
||||
names.add(item.cartonNo)
|
||||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
|
||||
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
|
||||
if (typeof item[field] !== 'string' || !pattern.test(item[field]) || Number(item[field]) <= 0) throw new Error('invalid packing measurement')
|
||||
}
|
||||
if (!Array.isArray(item.sources) || !item.sources.length || item.sources.some((source: unknown) => {
|
||||
if (!source || typeof source !== 'object') return true
|
||||
const row = source as Record<string, unknown>
|
||||
return Object.keys(row).some(key => !['receiptId', 'quantity'].includes(key)) || typeof row.receiptId !== 'string' || !/^[1-9]\d*$/.test(row.receiptId) || !Number.isSafeInteger(row.quantity) || Number(row.quantity) < 1
|
||||
})) throw new Error('invalid packing source')
|
||||
}
|
||||
}
|
||||
if (value.command === 'labels') {
|
||||
const receiver = body.receiver
|
||||
if (!count('packingVersion', 1) || !['OVERSEAS', 'DOMESTIC'].includes(String(body.leg)) || !receiver || typeof receiver !== 'object' || !['name', 'phone', 'address'].every(key => typeof (receiver as Record<string, unknown>)[key] === 'string' && String((receiver as Record<string, unknown>)[key]).trim())) throw new Error('invalid label address')
|
||||
if (!Array.isArray(body.labels) || !body.labels.length) throw new Error('missing labels')
|
||||
const cartonIds = new Set<string>()
|
||||
for (const row of body.labels) {
|
||||
if (!row || typeof row !== 'object' || typeof row.cartonId !== 'string' || !/^[1-9]\d*$/.test(row.cartonId) || typeof row.fileId !== 'string' || !/^[1-9]\d*$/.test(row.fileId) || !Number.isSafeInteger(row.page) || row.page < 1 || !row.carrier?.trim() || !row.trackingNo?.trim() || cartonIds.has(row.cartonId)) throw new Error('invalid label association')
|
||||
cartonIds.add(row.cartonId)
|
||||
}
|
||||
}
|
||||
if (value.command === 'international-estimate' && (!count('labelVersion', 1) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !nonempty('businessDate'))) throw new Error('invalid pending estimate')
|
||||
if (['labels/apply', 'overseas-shipment', 'customer-shipment'].includes(String(value.command))) {
|
||||
if (!count('labelVersion', 1) || !Array.isArray(body.cartonIds) || !body.cartonIds.length || body.cartonIds.some(id => typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) || new Set(body.cartonIds).size !== body.cartonIds.length) throw new Error('invalid transport cartons')
|
||||
if (!nonempty(value.command === 'labels/apply' ? 'appliedAt' : 'shippedAt')) throw new Error('missing actual transport time')
|
||||
if ((value.command === 'overseas-shipment' && body.leg !== 'OVERSEAS') || (value.command === 'customer-shipment' && body.leg !== 'DOMESTIC')) throw new Error('wrong shipment leg')
|
||||
}
|
||||
if (value.command === 'domestic-repack') {
|
||||
if (!positiveId('cartonId') || !positiveId('voucherFileId')) throw new Error('invalid repack reference')
|
||||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
|
||||
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
|
||||
if (typeof body[field] !== 'string' || !pattern.test(String(body[field])) || Number(body[field]) <= 0) throw new Error('invalid repack measurement')
|
||||
}
|
||||
}
|
||||
if (String(value.command).startsWith('fees/') && value.command !== 'fees/additional') {
|
||||
if (!positiveId('feeId')) throw new Error('invalid original fee')
|
||||
if (['fees/correct-target', 'fees/refund', 'fees/replace-failed'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing finance evidence')
|
||||
if (['fees/correct-target', 'fees/replace-failed'].includes(String(value.command)) && !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.targetOriginalAmount))) throw new Error('invalid correction target')
|
||||
if (value.command === 'fees/refund' && (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0)) throw new Error('invalid refund amount')
|
||||
}
|
||||
if (value.command === 'charges/confirm') {
|
||||
const selection = (input: unknown) => {
|
||||
if (!input || typeof input !== 'object') throw new Error('missing fee determination')
|
||||
const item = input as Record<string, unknown>
|
||||
if ((item.feeId == null) === (item.value == null) || Object.keys(item).some(key => !['feeId', 'value'].includes(key))) throw new Error('ambiguous fee determination')
|
||||
if (item.feeId != null) { if (typeof item.feeId !== 'string' || !/^[1-9]\d*$/.test(item.feeId)) throw new Error('invalid fee reference'); return }
|
||||
if (!item.value || typeof item.value !== 'object') throw new Error('invalid fee amount')
|
||||
const value = item.value as Record<string, unknown>
|
||||
if (Object.keys(value).some(key => !['determination', 'amount', 'currency', 'businessDate', 'voucherFileId', 'reason'].includes(key))) throw new Error('unexpected fee amount field')
|
||||
if (!['POSITIVE', 'ZERO', 'NOT_APPLICABLE'].includes(String(value.determination)) || typeof value.amount !== 'string' || !/^\d{1,16}(?:\.\d{1,2})?$/.test(value.amount) || (value.determination === 'POSITIVE' ? Number(value.amount) <= 0 : Number(value.amount) !== 0) || !['CNY', 'USD'].includes(String(value.currency)) || typeof value.businessDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.businessDate) || typeof value.reason !== 'string' || !value.reason.trim()) throw new Error('invalid fee determination value')
|
||||
if (value.determination === 'POSITIVE' && (typeof value.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(value.voucherFileId))) throw new Error('missing fee voucher')
|
||||
}
|
||||
for (const key of ['internationalFreight', 'duty', 'domesticFreight']) selection(body[key])
|
||||
if (!['NOT_APPLICABLE', 'ITEMIZED'].includes(String(body.otherDetermination)) || !Array.isArray(body.otherFees) || (body.otherDetermination === 'NOT_APPLICABLE') !== (body.otherFees.length === 0)) throw new Error('invalid other fee determination')
|
||||
const codes = new Set<string>()
|
||||
for (const row of body.otherFees) {
|
||||
if (!row || typeof row !== 'object' || typeof row.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || typeof row.name !== 'string' || !row.name.trim()) throw new Error('invalid other fee')
|
||||
codes.add(row.code); selection(row.selection)
|
||||
}
|
||||
}
|
||||
if (['control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing control evidence')
|
||||
if (value.command === 'control-notes' && (!['PURCHASE', 'WAREHOUSE', 'RECEIPT', 'SHIPMENT'].includes(String(body.targetType)) || typeof body.blocking !== 'boolean' || (body.targetType === 'PURCHASE' ? body.targetId != null : !positiveId('targetId')))) throw new Error('invalid control target')
|
||||
if (value.command === 'control-notes/resolve' && !positiveId('noteId')) throw new Error('missing original blocker')
|
||||
if (value.command === 'actual-warehouse/correct' && !positiveId('warehouseId')) throw new Error('invalid actual warehouse')
|
||||
if (value.command === 'exception-close') {
|
||||
if (!positiveId('customerVoucherFileId') || !nonempty('financialConclusion') || !nonempty('clientSummary') || !Array.isArray(body.resolutions)) throw new Error('incomplete closure facts')
|
||||
const decisions: Record<string, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
|
||||
const sources = new Set<string>()
|
||||
for (const row of body.resolutions) {
|
||||
if (!row || typeof row !== 'object' || !decisions[row.kind]?.includes(row.decision) || !Number.isSafeInteger(row.quantity) || row.quantity < 1 || typeof row.destination !== 'string' || !row.destination.trim() || typeof row.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(row.voucherFileId) || (row.kind === 'PURCHASE_REMAINDER' ? row.sourceId != null : typeof row.sourceId !== 'string' || !/^[1-9]\d*$/.test(row.sourceId))) throw new Error('invalid closure resolution')
|
||||
const key = `${row.kind}:${row.sourceId ?? ''}`; if (sources.has(key)) throw new Error('duplicate closure source'); sources.add(key)
|
||||
}
|
||||
}
|
||||
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
|
||||
return value as PendingProcurement
|
||||
}
|
||||
export function definitelyRejected(error: unknown) {
|
||||
const status = error instanceof ApiResponseError ? error.status : isAxiosError(error) ? error.response?.status : undefined
|
||||
const code = error instanceof ApiResponseError ? error.code : ''
|
||||
return [400, 401, 403, 404, 409, 422].includes(status ?? 0) || ['A0400', 'A0401', 'A0403', 'A0404', 'A0409', 'A0422'].includes(code)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user