费用优化
This commit is contained in:
@@ -7,11 +7,14 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { listOverseasFeeItems, type OverseasFee } from '@/modules/overseas-business'
|
||||
import { getPlatformWarehouse } from '@/modules/warehouses'
|
||||
import { getWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn(() => false))
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
const getDocumentFees = vi.hoisted(() => vi.fn(async (): Promise<OverseasFee[]> => []))
|
||||
vi.mock('@/modules/overseas-business', async () => ({ BusinessDocumentFees: { name: 'BusinessDocumentFees', props: { businessType: String, businessId: String, businessNo: String, customerId: String, relatedDocuments: Array, direction: String, canCreate: Boolean, hideWhenEmpty: Boolean, heading: String }, template: '<div><slot name="toolbar-actions" /></div>', methods: { refresh() {}, getFees: getDocumentFees } }, listOverseasFeeItems: vi.fn(), sameProjectFees: (await import('../../overseas-business/overseasFeePresentation')).sameProjectFees, sameProjectFeeSummary: (await import('../../overseas-business/overseasFeePresentation')).sameProjectFeeSummary, feeStatus: (await import('../../overseas-business/overseasFeePresentation')).feeStatus, overseasBusinessPermissions: (await import('../../overseas-business/permissions')).overseasBusinessPermissions }))
|
||||
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
@@ -30,14 +33,12 @@ async function openFeeEditor() {
|
||||
}
|
||||
async function fillFee(dialog: VueWrapper, direction: 'RECEIVABLE' | 'PAYABLE') {
|
||||
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', 'STORAGE_FEE'); selects[0]!.vm.$emit('change', 'STORAGE_FEE')
|
||||
selects[1]!.vm.$emit('update:modelValue', direction); await flushPromises()
|
||||
selects[0]!.vm.$emit('update:modelValue', 'FEE_TYPE_071')
|
||||
if (direction === 'PAYABLE') dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!.vm.$emit('update:modelValue', feeSupplier.id)
|
||||
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择币种')!.vm.$emit('update:modelValue', direction === 'PAYABLE' ? 'CNY' : 'USD')
|
||||
const numbers = dialog.findAllComponents({ name: 'ElInputNumber' })
|
||||
numbers[0]!.vm.$emit('update:modelValue', 80); numbers[1]!.vm.$emit('update:modelValue', 2)
|
||||
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
|
||||
dates[0]!.vm.$emit('update:modelValue', '2026-10-20'); dates[1]!.vm.$emit('update:modelValue', '2026-10-01'); dates[2]!.vm.$emit('update:modelValue', '2026-10-20')
|
||||
await flushPromises()
|
||||
await dialog.get('input[placeholder="如 箱、托、天、m³"]').setValue('托')
|
||||
}
|
||||
@@ -130,9 +131,11 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
getDocumentFees.mockReset().mockResolvedValue([])
|
||||
can.mockReset().mockReturnValue(false)
|
||||
vi.mocked(listOverseasFeeItems).mockReset().mockResolvedValue([{ feeCode: 'FEE_TYPE_071', feeTypeName: '贴标费', defaultPrice: 1 }, { feeCode: 'FEE_TYPE_077', feeTypeName: '仓租', defaultPrice: 1 }])
|
||||
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [feeSupplier], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
|
||||
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'FEE_TYPE_071', feeItemName: '贴标费', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
|
||||
push.mockReset()
|
||||
vi.mocked(getWarehouseTransfer).mockReset()
|
||||
vi.mocked(getPlatformWarehouse).mockReset()
|
||||
@@ -252,14 +255,19 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
const { dialog } = await openFeeEditor()
|
||||
expect(dialog.props('title')).toContain('FL-WH-001')
|
||||
expect(dialog.props('width')).toContain('720px')
|
||||
expect(dialog.text()).not.toContain('业务日期')
|
||||
expect(dialog.text()).not.toContain('计费开始日期')
|
||||
expect(dialog.text()).not.toContain('计费结束日期')
|
||||
expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
|
||||
expect(dialog.get('[aria-label="尾程费用登记"]').attributes('data-tone')).toBeUndefined()
|
||||
await fillFee(dialog, direction)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
|
||||
const payable = direction === 'PAYABLE'
|
||||
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', {
|
||||
feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: '2026-10-20',
|
||||
feeItemCode: 'FEE_TYPE_071', feeItemName: '贴标费', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: new Date().toISOString().slice(0, 10),
|
||||
supplierId: payable ? feeSupplier.id : undefined, supplierName: payable ? feeSupplier.name : undefined, supplierCategory: payable ? feeSupplier.category : undefined,
|
||||
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40, periodStart: '2026-10-01', periodEnd: '2026-10-20',
|
||||
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40,
|
||||
})
|
||||
})
|
||||
it('keeps fee required-field validation before a request', async () => {
|
||||
@@ -267,6 +275,98 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
|
||||
expect(createLastMileOrderFee).not.toHaveBeenCalled()
|
||||
})
|
||||
it('uses the live catalog, excludes automatic rent and only loads suppliers for payables', async () => {
|
||||
const { dialog } = await openFeeEditor()
|
||||
expect(listOverseasFeeItems).toHaveBeenCalledWith('RECEIVABLE')
|
||||
expect(listSuppliers).not.toHaveBeenCalled()
|
||||
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
|
||||
expect(select.props('allowCreate')).toBe(false)
|
||||
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071'])
|
||||
await fillFee(dialog, 'PAYABLE')
|
||||
expect(listOverseasFeeItems).toHaveBeenLastCalledWith('PAYABLE')
|
||||
expect(listSuppliers).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('blocks saving when the catalog fails and supports retry', async () => {
|
||||
vi.mocked(listOverseasFeeItems).mockRejectedValueOnce(new Error('目录暂不可用'))
|
||||
const { dialog } = await openFeeEditor()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
expect(dialog.text()).toContain('费用项目加载失败')
|
||||
await dialog.findAll('button').find(button => button.text() === '重新加载')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
expect(createLastMileOrderFee).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps supplier failures in the editor, blocks payables and reloads the shared supplier list', async () => {
|
||||
vi.mocked(listSuppliers).mockRejectedValueOnce(new ApiResponseError('仓储数据库暂不可用', 'S0503', 503, null, 'supplier-request'))
|
||||
const { dialog } = await openFeeEditor()
|
||||
await fillFee(dialog, 'PAYABLE')
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
|
||||
expect(dialog.text()).toContain('供应商加载失败')
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
|
||||
expect(createLastMileOrderFee).not.toHaveBeenCalled()
|
||||
await dialog.findAll('button').find(button => button.text() === '重试加载供应商')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.text()).not.toContain('供应商加载失败')
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
const supplier = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!
|
||||
expect(supplier.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual([feeSupplier.id])
|
||||
})
|
||||
it('ignores a supplier response after switching back to customer receivables', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
vi.mocked(listSuppliers).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
|
||||
const { dialog } = await openFeeEditor()
|
||||
const direction = dialog.findAllComponents({ name: 'ElSelect' })[1]!
|
||||
direction.vm.$emit('update:modelValue', 'PAYABLE'); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
direction.vm.$emit('update:modelValue', 'RECEIVABLE'); await flushPromises()
|
||||
reject(new Error('旧请求失败')); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
expect(dialog.text()).not.toContain('供应商加载失败')
|
||||
})
|
||||
it('places fees and related source charges inside the exact order without a duplicated receivable table', async () => {
|
||||
const result = await getLastMileOrder('internal-order-id')
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ ...result, order: { ...result.order, orderType: 'RETURN_RELABEL', sourceOrderNo: 'TH-01' } })
|
||||
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
|
||||
const panels = wrapper.findAllComponents({ name: 'BusinessDocumentFees' })
|
||||
expect(panels).toHaveLength(2)
|
||||
for (const [index, panel] of panels.entries()) expect(panel.props()).toMatchObject({
|
||||
businessType: 'LASTLEG_ORDER', businessId: result.order.id, businessNo: result.order.orderNo, customerId: result.order.customerId,
|
||||
direction: index === 0 ? 'RECEIVABLE' : 'PAYABLE',
|
||||
relatedDocuments: [{ businessType: 'RETURN_ORDER', businessNo: 'TH-01', customerId: result.order.customerId }],
|
||||
})
|
||||
expect(panels[0]!.props()).toMatchObject({ heading: '应收明细', hideWhenEmpty: true, canCreate: false })
|
||||
expect(panels[1]!.props()).toMatchObject({ heading: '应付明细', hideWhenEmpty: true, canCreate: false })
|
||||
expect(wrapper.findAll('#pane-fees .el-table').length).toBe(0)
|
||||
})
|
||||
it('uses one create button and selects payable inside the shared editor', async () => {
|
||||
can.mockReturnValue(true)
|
||||
const wrapper = mount(LastMileOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
|
||||
const createButtons = wrapper.findAll('#pane-fees button').filter(button => button.text().trim() === '新增费用')
|
||||
expect(createButtons).toHaveLength(1)
|
||||
await createButtons[0]!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.findAllComponents({ name: 'ElSelect' })[1]!.props('modelValue')).toBe('RECEIVABLE')
|
||||
await fillFee(dialog, 'PAYABLE')
|
||||
expect(listOverseasFeeItems).toHaveBeenCalledWith('PAYABLE')
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
|
||||
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
|
||||
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({
|
||||
direction: 'PAYABLE', supplierId: feeSupplier.id, settlementMode: 'ON_ACCOUNT',
|
||||
}))
|
||||
})
|
||||
it('shows existing amount and approval before allowing another fee with a reason', async () => {
|
||||
getDocumentFees.mockResolvedValue([{ sourceFeeId: 'old-fee', feeItemCode: 'FEE_TYPE_071', direction: 'RECEIVABLE', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', originalCurrency: 'USD', originalAmount: 80 } as OverseasFee])
|
||||
const { dialog } = await openFeeEditor()
|
||||
await fillFee(dialog, 'RECEIVABLE')
|
||||
expect(dialog.text()).toContain('USD 80.00 · 已通过')
|
||||
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
|
||||
expect(createLastMileOrderFee).not.toHaveBeenCalled()
|
||||
await dialog.get('textarea[placeholder="补收或重复作业时,请说明原因"]').setValue('第二次贴标作业')
|
||||
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
|
||||
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeRemark: '第二次贴标作业' }))
|
||||
})
|
||||
it('locks fee controls and preserves failed values and diagnostic context', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
vi.mocked(createLastMileOrderFee).mockReturnValue(new Promise((_, no) => { reject = no }))
|
||||
|
||||
Reference in New Issue
Block a user