费用优化

This commit is contained in:
2026-10-26 13:43:08 +08:00
parent 090431b409
commit 18a78e5508
74 changed files with 3339 additions and 116 deletions
@@ -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 }))