import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAuthStore } from '@/core/auth/auth' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { ApiResponseError } from '@/shared/api/http' 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() })) vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) const storageKey = 'procurement-command:7:10001:OP-100' function storage(): Storage { const values = new Map(); return { get length() { return values.size }, clear: () => values.clear(), getItem: key => values.get(key) ?? null, key: index => [...values.keys()][index] ?? null, removeItem: key => values.delete(key), setItem: (key, value) => values.set(key, value) } } function fixture(key = 'REVIEW', allowed = true): PlatformProcurement { const keys: Record = { REVIEW: ['审核采购', 'review'], PURCHASE: ['登记购买完成', 'buy'], AUTHORIZE_BUDGET: ['核验预算上限', 'review'], PLAN_WAREHOUSE: ['修改计划仓', 'review'], CANCEL: ['购买前取消', 'cancel'], RECEIVE_OVERSEAS: ['登记海外实收', 'receive-overseas'], FINISH_DOMESTIC: ['确认国内收货完成', 'receive-domestic'], PACK: ['保存装箱草稿', 'pack'], MANAGE: ['事实更正及异常处置', 'manage'], LABELS: ['维护两段面单', 'label'], ESTIMATE: ['核对预估运费', 'estimate'], SHIP_OVERSEAS: ['确认海外交寄', 'ship-overseas'], CONFIRM_CHARGES: ['确认国内费用', 'charge'], FINANCE_ADJUST: ['费用更正与退款', 'finance-adjust'], EXCEPTION_CLOSE: ['异常关闭', 'manage'] } const [label, suffix] = keys[key] return { customerView: { order: { id: '1', orderNo: 'OP-100', customerId: '10001', customerName: '测试客户', purchaseCategory: '家居', productName: '样品商品', productSpecification: '黑色', productUrl: null, purchaseQuantity: 2, budgetUnitPrice: '10.00', currency: 'USD', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: null, actualWarehouseCode: null, actualWarehouseName: null, status: 'PENDING_REVIEW', version: 3, createdAt: '', updatedAt: '' }, budget: { totalAmount: '20.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: [], exceptionSummaries: [] }, purchaseFacts: {}, physical: { dispositions: [], receipts: [], cartons: [], packingVersion: null, packingStatus: null }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [{ key, label, permission: `platform:overseas-business:purchase:${suffix}`, allowed, reason: allowed ? null : '业务条件不满足' }], internalRemark: null, } } function render(detail = fixture(), permissions = ['review', 'buy', 'cancel', 'receive-overseas', 'receive-domestic', 'pack', 'manage', 'label', 'estimate', 'ship-overseas', 'charge', 'finance-adjust']) { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.token = 'test-token'; auth.permissions = permissions.map(item => `platform:overseas-business:purchase:${item}`); useAccessPreviewStore().initializeAfterLogin() return mount(ProcurementNodeActions, { attachTo: document.body, props: { detail, customerId: '10001' }, global: { plugins: [pinia, ElementPlus] } }) } async function open(wrapper: ReturnType) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) } async function explain(wrapper: ReturnType, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) } async function confirm(wrapper: ReturnType) { wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() } enableAutoUnmount(afterEach) afterEach(() => vi.unstubAllGlobals()) beforeEach(() => { vi.stubGlobal('sessionStorage', storage()); vi.stubGlobal('localStorage', storage()); document.body.innerHTML = '' vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外收货仓', countryCode: 'US' }]) vi.mocked(submitProcurementCommand).mockReset().mockResolvedValue(undefined) 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('disables terminal physical changes and keeps the management evidence form open', async () => { const value = fixture('MANAGE'); value.customerView.order.status = 'SHIPPED_TO_CUSTOMER' value.purchaseFacts = { overseas_shipped_at: '2020-01-02', customer_shipped_at: '2020-01-03', actual_warehouse_id: '20' } value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02', active: true }] const wrapper = render(value); const dialog = await open(wrapper) const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' }) expect(selector.props('modelValue')).toBe('control-notes') const options = selector.findAllComponents({ name: 'ElOption' }) expect(options.find(option => option.props('value') === 'receipts/correct')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('订单已结束') }) expect(options.find(option => option.props('value') === 'control-notes')!.props('disabled')).toBe(false) expect(options.find(option => option.props('value') === 'actual-warehouse/correct')!.props('disabled')).toBe(false) selector.vm.$emit('update:modelValue', 'receipts/correct'); await flushPromises() expect(dialog.props('confirmDisabled')).toBe(true) await confirm(wrapper) expect(submitProcurementCommand).not.toHaveBeenCalled() expect(sessionStorage.getItem(storageKey)).toBeNull() }) it('disables overseas receipt candidates after international shipment without blocking domestic correction', async () => { const value = fixture('MANAGE'); value.customerView.order.status = 'DOMESTIC_RECEIVING' value.purchaseFacts.overseas_shipped_at = '2020-01-02' const receipt = { id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS' as const, sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-01', active: true } value.physical.receipts = [receipt, { ...receipt, id: '12', receiptNo: 'PR-12', leg: 'DOMESTIC', sourceCartonId: '31' }] const wrapper = render(value); const dialog = await open(wrapper) dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'receipts/correct') await flushPromises() const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '待更正实收')!.findComponent({ name: 'ElSelect' }) const options = selector.findAllComponents({ name: 'ElOption' }) expect(options.find(option => option.props('value') === '11')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('本段已实际交寄') }) expect(options.find(option => option.props('value') === '12')!.props('disabled')).toBe(false) selector.vm.$emit('update:modelValue', '12'); await flushPromises() 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 () => { 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 }] value.files = [{ id: '51', fileId: '501', category: 'EXCEPTION', fileName: 'agreement.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }] const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '内部客户沟通及供应商处理依据') await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled() const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! field('实际处置').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'SUPPLIER_RETAINED') field('本项处置凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501') await field('实际去向及接收方').find('input').setValue('供应商原仓留存') field('客户同意停止履约凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501') await field('财务处理结论(仅平台可见)').find('textarea').setValue('全部退款及冻结释放已处理') await field('对客关闭摘要(客户可见)').find('textarea').setValue('货物由供应商保留,停止后续寄送') await confirm(wrapper) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'exception-close', expect.objectContaining({ resolutions: [{ kind: 'PURCHASE_REMAINDER', sourceId: null, quantity: 2, decision: 'SUPPLIER_RETAINED', destination: '供应商原仓留存', voucherFileId: '501' }], customerVoucherFileId: '501', financialConclusion: '全部退款及冻结释放已处理', clientSummary: '货物由供应商保留,停止后续寄送' })) expect(dialog.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0) }) it('confirms three explicit zero determinations and marks other charges not applicable in one request', async () => { const value = fixture('CONFIRM_CHARGES'); value.purchaseFacts.domestic_received_at = '2020-01-04T10:00:00' const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '整单费用核对完成') await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled() const groups = dialog.findAllComponents({ name: 'ProcurementChargeFields' }); expect(groups).toHaveLength(3) for (const group of groups) { const determination = group.findAllComponents({ name: 'ElSelect' })[0]; determination.vm.$emit('update:modelValue', 'ZERO'); determination.vm.$emit('change', 'ZERO') group.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2020-01-04') await group.find('input[placeholder="说明计费依据;零元或不适用也需说明"]').setValue('本项费用免收') } await confirm(wrapper) expect(submitProcurementCommand).toHaveBeenCalledTimes(1) const [number, customer, command, payload] = vi.mocked(submitProcurementCommand).mock.calls[0] expect([number, customer, command]).toEqual(['OP-100', '10001', 'charges/confirm']) for (const selection of [payload.internationalFreight, payload.duty, payload.domesticFreight]) expect(selection).toEqual({ value: { determination: 'ZERO', amount: '0.00', currency: 'CNY', businessDate: '2020-01-04', reason: '本项费用免收' } }) expect(payload.otherDetermination).toBe('NOT_APPLICABLE'); expect(payload.otherFees).toEqual([]) }) it('lets finance replace a terminated unpaid sample without modifying its original purchase fact', async () => { const value = fixture('FINANCE_ADJUST') value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: false, 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: 'TERMINATED', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }] value.files = [{ id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }] const wrapper = render(value, ['finance-adjust']); const dialog = await open(wrapper) const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! field('财务处理方式').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'fees/replace-failed'); await flushPromises() field('原费用').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11'); await flushPromises() await dialog.find('input[placeholder="填写原费用链正确总额,最多两位小数"]').setValue('12.00') field('本单凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501'); await explain(wrapper, '原资金已终止,按真实凭证重新核定') await confirm(wrapper) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/replace-failed', expect.objectContaining({ feeId: '11', targetOriginalAmount: '12.00', voucherFileId: '501' })) expect(value.fees[0].originalAmount).toBe('10.00'); expect(value.fees[0].active).toBe(false) }) it('lets a buyer retry a failed sample payment without granting ordinary charge or finance-adjust access', async () => { const value = fixture('PURCHASE') 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: 'FREEZE_FAILED', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }] const wrapper = render(value, ['buy']); await wrapper.findAll('button').find(button => button.text() === '重试失败资金请求')!.trigger('click'); await flushPromises(); await explain(wrapper, '客户已补足余额,重试原资金请求') wrapper.findComponent(AppDialog).findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11'); await confirm(wrapper) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/retry', expect.objectContaining({ feeId: '11', version: 3 })) expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('amount') wrapper.unmount(); const restricted = render(value, ['charge']); expect(restricted.text()).not.toContain('重试失败资金请求') }) it('uploads an external label and submits a per-carton association with receiving details', async () => { const value = fixture('LABELS'); value.physical.packingStatus = 'CONFIRMED'; value.physical.packingVersion = 2 value.transport.overseasCargo = [{ cartonId: '31', cartonNo: 'BOX-31', quantity: 2, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', packagingRecordId: null, sources: [{ receiptId: '11', quantity: 2 }] }] vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '51', fileId: '501', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }) const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '国内客服制作国际面单') const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! await field('国内接收人').find('input').setValue('国内收货人'); await field('联系电话').find('input').setValue('13800000001'); await field('完整收件地址').find('input').setValue('上海国内收货地址') const input = dialog.find('input[aria-label="上传快递面单"]'); const file = new File(['pdf'], 'label.pdf', { type: 'application/pdf' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises() field('本单面单文件').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501'); await field('承运商').find('input').setValue('UPS'); await field('本箱运单号').find('input').setValue('TRACK-31') await confirm(wrapper) expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'LABEL', file) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'labels', expect.objectContaining({ leg: 'OVERSEAS', packingVersion: 2, receiver: { name: '国内收货人', phone: '13800000001', address: '上海国内收货地址' }, labels: [{ cartonId: '31', fileId: '501', page: 1, carrier: 'UPS', trackingNo: 'TRACK-31' }] })) expect(submitProcurementCommand).toHaveBeenCalledTimes(1) }) it('records a partial overseas receipt separately from completing receipt', async () => { const value = fixture('RECEIVE_OVERSEAS'); value.purchaseFacts = { purchased_at: '2020-01-01T10:00:00', purchased_quantity: 2 } const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '首批样品到仓') dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001') dialog.findAllComponents({ name: 'ElInputNumber' })[0].vm.$emit('update:modelValue', 1) dialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2020-01-02T10:00:00') await confirm(wrapper) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'overseas-receipts', expect.objectContaining({ version: 3, leg: 'OVERSEAS', warehouseId: '20001', goodQuantity: 1, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00' })) expect(submitProcurementCommand).toHaveBeenCalledTimes(1) expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('sourceCartonId') }) it('saves structured packing sources without invoking packing confirmation', async () => { const value = fixture('PACK') value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }] const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '核对装箱草稿') const values = ['BOX-ONE', '10.12', '20', '30', '1.234'] for (const [index, input] of dialog.findAll('.packing-grid input').entries()) await input.setValue(values[index]) dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11') dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2) await confirm(wrapper) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'packing', expect.objectContaining({ cartons: [{ cartonNo: 'BOX-ONE', lengthCm: '10.12', widthCm: '20', heightCm: '30', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 2 }] }] })) expect(submitProcurementCommand).toHaveBeenCalledTimes(1) }) it('uses exception evidence and source quantities for damaged goods disposition', async () => { const value = fixture('MANAGE'); value.purchaseFacts.purchased_quantity = 2; value.purchaseFacts.purchased_at = '2020-01-01T10:00:00' value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 1, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }] vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '12', fileId: '102', category: 'EXCEPTION', fileName: 'damage.png', contentType: 'image/png', sizeBytes: 3, createdAt: '' }) const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '破损样品已经处理,客户已确认') const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! field('差异及处置').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DAMAGED'); await flushPromises() field('对应实收').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11') dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1) const input = dialog.find('input[type="file"]'); const file = new File(['img'], 'damage.png', { type: 'image/png' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises() await confirm(wrapper) expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'EXCEPTION', file) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'dispositions', expect.objectContaining({ receiptId: '11', kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', quantity: 1, voucherFileId: '102' })) }) it('requires an authoritative warehouse on approval and sends the current order version', async () => { const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper) await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓') dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', remark: '已核对客户资料', purchaseWarehouseId: '20001' }) expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull() }) it('rejects without a warehouse and never substitutes an invented candidate', async () => { vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('unavailable')) const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper, '购买对象不符合要求') dialog.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT'); await confirm(wrapper) expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT' })) expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('purchaseWarehouseId') }) it('records explicit zero purchase with the exact product snapshot and purchase-purpose evidence', async () => { const wrapper = render(fixture('PURCHASE')); const dialog = await open(wrapper); await explain(wrapper, '供应商免费提供样品') await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled() const amount = dialog.findAll('.el-form-item').find(item => item.text().startsWith('供应商实付总额'))!.find('input'); await amount.setValue('0.00') const dates = dialog.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-08T10:00:00'); dates[1].vm.$emit('update:modelValue', '2026-10-15T10:00:00') const input = dialog.find('input[type="file"]'); const file = new File(['pdf'], 'purchase.pdf', { type: 'application/pdf' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises() expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'PURCHASE', file) 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 () => { 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() }) it('preserves an earlier uncertain financial or physical command when replay is rejected', 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() }) it('keeps a version conflict visible, releases the failed command and does not report success', async () => { vi.mocked(submitProcurementCommand).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新', 'A0409')) const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper) expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(document.body.textContent).toContain('订单版本冲突'); expect(wrapper.emitted('changed')).toBeUndefined() }) it('hides unauthorized actions and preserves blocked node reasons without a submit button', async () => { let wrapper = render(fixture(), []); expect(wrapper.find('.purchase-actions button').exists()).toBe(false); wrapper.unmount() wrapper = render(fixture('REVIEW', false)) expect(wrapper.find('.purchase-actions button').exists()).toBe(false) expect(wrapper.find('details').text()).toContain('审核采购') expect(wrapper.find('details').text()).toContain('业务条件不满足') expect(submitProcurementCommand).not.toHaveBeenCalled() }) it('keeps executable workflow actions in front and financial exceptions in a closed group', () => { const detail = fixture('PURCHASE') detail.actions.push(...fixture('FINANCE_ADJUST').actions, ...fixture('PACK', false).actions) const wrapper = render(detail) const primary = wrapper.get('[data-testid="current-procurement-actions"]') expect(primary.text()).toContain('登记购买完成') expect(primary.text()).not.toContain('费用更正与退款') expect(primary.text()).not.toContain('保存装箱草稿') const groups = wrapper.findAll('details') expect(groups[0].text()).toContain('费用更正与退款') expect(groups[0].attributes('open')).toBeUndefined() expect(groups[1].text()).toContain('业务条件不满足') }) it('prevents a second submit while the first command is pending', async () => { let complete!: () => void; vi.mocked(submitProcurementCommand).mockImplementation(() => new Promise(resolve => { complete = resolve })) const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper) 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 () => { 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() }) })