diff --git a/src/modules/overseas-business/components/ProcurementNodeActions.test.ts b/src/modules/overseas-business/components/ProcurementNodeActions.test.ts index e522f75d..85766766 100644 --- a/src/modules/overseas-business/components/ProcurementNodeActions.test.ts +++ b/src/modules/overseas-business/components/ProcurementNodeActions.test.ts @@ -41,6 +41,51 @@ beforeEach(() => { vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }) }) describe('procurement purchase node operations', () => { + it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', 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' } }) + sessionStorage.setItem(storageKey, original) + let wrapper = render(value) + const click = async (label: string) => { await wrapper.findAll('button').find(button => button.text() === label)!.trigger('click'); await flushPromises() } + await click('准备费用更正草稿') + const dialog = wrapper.findComponent(AppDialog) + dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '原费用')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11') + await dialog.find('input[placeholder="填写原费用链正确总额,最多两位小数"]').setValue('12.00') + await explain(wrapper, '准备下一版本') + await click('保存本地草稿') + 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() + 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(sessionStorage.getItem(`${storageKey}:correction-draft`)).not.toBeNull() + const latest = structuredClone(value) + latest.actions[0].allowed = true; latest.customerView.order.version = 9 + latest.files = [{ id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }] + await wrapper.setProps({ detail: latest }) + 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(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 }] + await wrapper.setProps({ detail: structuredClone(latest) }) + await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenLastCalledWith('OP-100', '10001', 'fees/correct-target', expect.objectContaining({ version: 9, targetOriginalAmount: '12.00', feeId: '11' })) + expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).toBeNull() + // Restore a saved draft before changing the scope to ensure isolation is meaningful. + sessionStorage.setItem(`${storageKey}:correction-draft`, JSON.stringify({ feeId: '11', targetOriginalAmount: '13.00', reason: '下一目标', voucherFileId: '501', orderVersion: 9 })) + await wrapper.setProps({ customerId: 'OTHER' }); expect(wrapper.text()).not.toContain('恢复费用更正草稿') + await wrapper.setProps({ customerId: '10001', detail: { ...value, customerView: { ...value.customerView, order: { ...value.customerView.order, orderNo: 'OTHER' } } } }); expect(wrapper.text()).not.toContain('恢复费用更正草稿') + useAuthStore().user = { id: '8', name: '另一操作员', account: 'other', roles: [] } + await wrapper.setProps({ detail: value }); expect(wrapper.text()).not.toContain('恢复费用更正草稿') + }) it('requires explicit remaining-goods outcomes and keeps private closure reasons separate', async () => { const value = fixture('EXCEPTION_CLOSE'); value.purchaseFacts.purchased_at = '2020-01-01T10:00:00' value.controls.remaining = [{ kind: 'PURCHASE_REMAINDER', sourceId: null, businessNo: 'OP-100', quantity: 2 }] diff --git a/src/modules/overseas-business/components/ProcurementNodeActions.vue b/src/modules/overseas-business/components/ProcurementNodeActions.vue index c695c291..e54ce007 100644 --- a/src/modules/overseas-business/components/ProcurementNodeActions.vue +++ b/src/modules/overseas-business/components/ProcurementNodeActions.vue @@ -11,6 +11,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness' import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands' import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending' +import { readCorrectionDraft, type ProcurementCorrectionDraft } from '../procurementCorrectionDraft' import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes' import type { OverseasPurchaseWarehouseOption } from '../types' import ProcurementControlFields from './ProcurementControlFields.vue' @@ -29,6 +30,7 @@ const retryFees = computed(() => props.detail.fees.filter(fee => fee.active && f const actions = computed(() => [...props.detail.actions.filter(item => routes[item.key] && access.can(item.permission)), ...(retryFees.value.length ? [{ key: 'RETRY_FEE', label: '重试失败资金请求', permission: feeRetryPermission(retryFees.value[0]), allowed: true, reason: null }] : [])]) const opened = ref(false); const active = ref(null); const saving = ref(false); const uploading = ref(false) const error = ref(null); const storageError = ref(''); const pending = ref(null) +const editingDraft = ref(false); const correctionDraft = ref(null); const draftError = ref('') const warehouses = ref([]); const warehouseLoading = ref(false); const warehouseError = ref('') const extraFiles = ref([]) const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' }) @@ -39,8 +41,10 @@ const transport = ref(transportForm(props.detail)) const physical = ref(physicalForm()) const manageCommand = ref('dispositions') const key = computed(() => auth.user?.id ? `procurement-command:${auth.user.id}:${props.customerId}:${props.detail.customerView.order.orderNo}` : '') +const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : '') +const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose))) const commandPermissions: Record = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' } -const command = computed(() => pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined)) +const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined)) const isControl = computed(() => !!command.value && controlCommands.has(command.value)) const isFinance = computed(() => !!command.value && financeCommands.has(command.value)) const isTransport = computed(() => !!command.value && transportCommands.has(command.value)) @@ -53,10 +57,12 @@ const allFiles = computed(() => [...props.detail.files, ...extraFiles.value].fil const files = computed(() => allFiles.value.filter(file => file.category === fileCategory.value)) const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purchase-action', label: computed(() => active.value?.label || '采购节点操作'), isOpen: opened, model: reactive({ purchase: form, physical, transport, finance, control }), requiredPermission: computed(() => active.value?.permission ?? null) }) watch(key, () => { - opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null + 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 }) async function loadWarehouses() { if (warehouseLoading.value) return @@ -67,7 +73,7 @@ async function loadWarehouses() { } function open(action: ProcurementAction) { if (!action.allowed || pending.value || storageError.value || saving.value) return - active.value = action; error.value = null; manageCommand.value = 'dispositions'; physical.value = physicalForm(props.detail) + editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = 'dispositions'; physical.value = physicalForm(props.detail) control.value = controlForm(props.detail) finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target' transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS') @@ -76,7 +82,37 @@ function open(action: ProcurementAction) { opened.value = true; captureBaseline() if (needsWarehouse.value) void loadWarehouses() } -watch(financialCommand, () => { finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null }) +function openCorrectionDraft() { + if (!canDraft.value || saving.value || uploading.value) return + editingDraft.value = true + active.value = { key: 'CORRECTION_DRAFT', label: '费用更正草稿', permission: 'platform:overseas-business:purchase:finance-adjust', allowed: true, reason: null } + finance.value = financeForm(props.detail) + const draft = correctionDraft.value + finance.value.feeId = draft?.feeId || ''; finance.value.targetOriginalAmount = draft?.targetOriginalAmount || '' + form.reason = draft?.reason || ''; form.voucherFileId = draft?.voucherFileId || '' + error.value = null; opened.value = true; captureBaseline() +} +function saveCorrectionDraft() { + if (!editingDraft.value || !canDraft.value || saving.value || uploading.value) return + try { + const draft = { feeId: finance.value.feeId, targetOriginalAmount: finance.value.targetOriginalAmount, reason: form.reason, voucherFileId: form.voucherFileId, orderVersion: props.detail.customerView.order.version } + sessionStorage.setItem(draftKey.value, JSON.stringify(draft)); correctionDraft.value = draft; draftError.value = ''; captureBaseline() + ElMessage.success('更正草稿已保存在本浏览器标签页,尚未确认费用或发起资金请求') + } catch { draftError.value = '草稿保存失败,请保留当前输入并重试' } +} +function useCorrectionDraft() { + if (!editingDraft.value || pending.value || storageError.value || !canDraft.value) return + const action = actions.value.find(item => item.key === 'FINANCE_ADJUST' && item.allowed) + if (!action) { error.value = { message: '当前业务状态不允许财务确认,请刷新订单后核对' }; return } + 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 { const order = props.detail.customerView.order @@ -103,6 +139,7 @@ function build(): ProcurementCommandBody { return base } 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 @@ -118,10 +155,12 @@ async function submit() { 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 } saving.value = true; error.value = null + const confirmsCorrection = !resuming && ['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 @@ -131,7 +170,7 @@ async function submit() { } async function upload(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = '' - if (!file || uploading.value || pending.value) return + if (!file || uploading.value || (pending.value && !editingDraft.value)) return if (file.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)) { ElMessage.warning('请上传不超过 10MB 的 PDF、JPG 或 PNG'); return } const scope = key.value; uploading.value = true try { @@ -145,29 +184,30 @@ async function upload(event: Event) {