Files
admin-web/src/modules/loading-lists/components/ContainerFeesPanel.test.ts
T

409 lines
34 KiB
TypeScript

import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { adjustContainerFee, createContainerFee, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { createContainerFeeAllocationGroup } from '../api/containerFees'
vi.mock('../api/containerFees', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/containerFees')>()
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), getContainerSettlementDetails: vi.fn(), createContainerFee: vi.fn(), createContainerFeeAllocationGroup: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
})
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
const containerDetail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
class ResizeObserverStub { observe() {}; unobserve() {}; disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); 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) } }
async function mountPanel(permissions: string[], customers = [{ accountId: '11', customerName: '客户A' }], containerBookingId = '99') {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', cabinetNo: 'CAB-9', containerNo: 'CONT-9', containerBookingId, customers, businessReferences: [{ accountId: '11', customerName: '客户A', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } })
await flushPromises(); return wrapper
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage())
vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([])
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset()
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
vi.mocked(createContainerFeeAllocationGroup).mockReset().mockResolvedValue({} as never)
document.body.innerHTML = ''
})
describe('ContainerFeesPanel contract', () => {
it('连续分摊表保留两个客户归属,合计不符时阻止保存,全屏后按原归属提交', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: null, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }])
await wrapper.setProps({ businessReferences: [
{ accountId: '11', customerName: '客户A', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' },
{ accountId: '22', customerName: '客户B', orderId: '502', orderNo: 'FL-502', inboundId: null, inboundCode: null },
] })
const state = (wrapper.vm.$ as unknown as { setupState: {
accountId: string; openCreate(): void; save(): Promise<void>; form: { feeItemId: string; amount: number; remark: string }
} }).setupState
state.accountId = '11'
await flushPromises()
state.openCreate()
await flushPromises()
state.form.feeItemId = '7'; state.form.amount = 100; state.form.remark = '分摊测试备注'
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增柜次费用'))!
const rows = dialog.findAll('table[aria-label="业务归属分摊"] tbody tr')
expect(rows).toHaveLength(2)
expect(rows[0]!.text()).toContain('客户A')
expect(rows[1]!.text()).toContain('客户B')
expect(rows[1]!.text()).toContain('无入仓码')
rows[0]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40)
rows[1]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 50)
await state.save()
expect(createContainerFeeAllocationGroup).not.toHaveBeenCalled()
rows[1]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 60)
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.find('textarea').element.value).toBe('分摊测试备注')
await state.save()
expect(createContainerFeeAllocationGroup).toHaveBeenCalledWith('9', {
fee: expect.objectContaining({ amount: 100, feeItemId: '7', remark: '分摊测试备注', direction: 'RECEIVABLE' }),
allocations: [
{ customerId: '11', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001', amount: 40 },
{ customerId: '22', orderId: '502', orderNo: 'FL-502', inboundId: null, inboundCode: null, amount: 60 },
],
})
wrapper.unmount()
})
it.each(['success', 'failure'])('ignores a closed editor request that finishes late: %s', async (outcome) => {
const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
let resolveOld!: (items: typeof item[]) => void
let rejectOld!: (reason: Error) => void
vi.mocked(listApplicableFeeItems).mockImplementationOnce(() => new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject }))
vi.mocked(listApplicableFeeItems).mockResolvedValueOnce([{ ...item, defaultPrice: 80 }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
const state = (wrapper.vm.$ as unknown as { setupState: {
openCreate(): void
editorOpen: boolean
form: { amount: number }
feeItems: typeof item[]
feeItemsLoading: boolean
feeItemsError: unknown
} }).setupState
state.openCreate(); await flushPromises()
state.editorOpen = false; await flushPromises()
state.openCreate(); await flushPromises()
expect(state.feeItems[0]?.defaultPrice).toBe(80)
if (outcome === 'success') resolveOld([item])
else rejectOld(new Error('旧窗口请求失败'))
await flushPromises()
expect(state.feeItemsError).toBeNull()
expect(state.feeItemsLoading).toBe(false)
const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((field) => field.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', '7'); select.vm.$emit('change', '7')
await flushPromises()
expect(state.form.amount).toBe(80)
wrapper.unmount()
})
it('does not let an old completion clear the current loading state or refill a closed editor', async () => {
const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
let resolveOld!: (items: typeof item[]) => void
let resolveCurrent!: (items: typeof item[]) => void
vi.mocked(listApplicableFeeItems)
.mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveCurrent = resolve }))
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
const state = (wrapper.vm.$ as unknown as { setupState: {
openCreate(): void; editorOpen: boolean; feeItems: unknown[]; feeItemsLoading: boolean; feeItemsError: unknown
} }).setupState
state.openCreate(); await flushPromises()
state.editorOpen = false
state.openCreate(); await flushPromises()
resolveOld([item]); await flushPromises()
expect(state.feeItems).toEqual([])
expect(state.feeItemsLoading).toBe(true)
state.editorOpen = false; await flushPromises()
resolveCurrent([{ ...item, defaultPrice: 80 }]); await flushPromises()
expect(state.feeItems).toEqual([])
expect(state.feeItemsLoading).toBe(false)
expect(state.feeItemsError).toBeNull()
wrapper.unmount()
})
it('prefills the selected default, clears stale prices and retains manual overrides', async () => {
const base = { name: '操作费', feeCode: 'OPERATION', supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
vi.mocked(listApplicableFeeItems).mockResolvedValue([
{ ...base, id: '7', defaultPrice: 60 }, { ...base, id: '8', defaultPrice: null }, { ...base, id: '9', defaultPrice: 0 },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
const state = (wrapper.vm.$ as unknown as { setupState: {
openCreate(): void; save(): Promise<void>
resetForm(row: Record<string, unknown>): void
form: { feeItemId: string; amount: number }
} }).setupState
state.resetForm({ feeItemId: '7', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 100 })
await flushPromises()
expect(state.form.amount).toBe(100)
state.openCreate(); await flushPromises()
const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((item) => item.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
for (const [id, price] of [['7', 60], ['8', 0], ['9', 0]] as const) {
select.vm.$emit('update:modelValue', id); select.vm.$emit('change', id)
await flushPromises()
expect(state.form.amount).toBe(price)
}
select.vm.$emit('update:modelValue', '7'); select.vm.$emit('change', '7')
await flushPromises()
state.form.amount = 75
await state.save(); await flushPromises()
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ feeItemId: '7', amount: 75 }))
})
it('keeps fee controls and table utilities in compact aligned toolbars', () => {
expect(panel).toContain('class="fees-header__actions"')
expect(panel).toContain('class="customer-context"')
expect(panel).toContain('<template #toolbar-start><div class="fees-table-title">')
expect(panel).toContain('class="fees-panel admin-density"')
})
it('is exposed from the domestic container fee tab and derives customer context from real items', () => {
expect(detail).not.toContain('ContainerFeesPanel')
expect(containerDetail).toContain('title="国内段费用(头程)"')
expect(containerDetail).toContain(':container-booking-id="detail.id"')
expect(containerDetail).toContain(':plan-id="overview.loadingPlanId"')
expect(containerDetail).toContain('item.accountId')
expect(panel).toContain('该柜次包含多个客户,请先选择费用归属客户')
expect(panel).toContain('装柜明细缺少客户归属,无法安全维护费用')
})
it('covers create, review, reject, approved adjustment and void actions', () => {
expect(panel).toContain('新增费用')
expect(panel).toContain("row.reviewStatus === 'PENDING'")
expect(panel).toContain('approveContainerFee')
expect(panel).toContain('rejectContainerFee')
expect(panel).toContain("row.reviewStatus === 'APPROVED'")
expect(panel).toContain('adjustContainerFee')
expect(panel).toContain('voidContainerFee')
expect(panel).toContain('调整仅允许修改业务日期和金额')
expect(panel).toContain('请先作废后新增')
expect(panel).toContain("row.supplierName || '服务端未返回'")
})
it('adjusts only business date and amount while preserving the approved snapshot', async () => {
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
const item = { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '拖车费', direction: 'PAYABLE' as const, supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING' as const, businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, reviewStatus: 'APPROVED' as const, sourceStatus: 'ACTIVE' as const, sourceVersion: 3, remark: '原备注', reviewerName: null, reviewedAt: null, reviewReason: null }
const state = (wrapper.vm.$ as unknown as { setupState: { openAdjust(value: typeof item): void; save(): Promise<void>; form: { businessDate: string; amount: number } } }).setupState
state.openAdjust(item); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('调整已审核费用'))!
expect(dialog.props('title')).toBe('调整已审核费用 · CAB-9')
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
expect(fields.find(field => field.props('label') === '费用项目')!.findComponent({ name: 'ElSelect' }).exists()).toBe(false)
expect(fields.find(field => field.props('label') === '供应商')!.text()).toContain('承运商A')
state.form.businessDate = '2026-08-13'; state.form.amount = 120
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
expect(state.form.amount).toBe(120)
await state.save()
expect(adjustContainerFee).toHaveBeenCalledWith('9', '11', '2', { expectedSourceVersion: 3, businessDate: '2026-08-13', amount: 120 })
expect(wrapper.text()).toContain('已审核费用仍可调整日期与金额')
})
it('shows only server allocations and supplier snapshots', () => {
expect(panel).toContain('getContainerSettlementDetails')
expect(panel).toContain('仅展示 Finance 已持久化结果')
expect(panel).toContain("cost.supplierName || '服务端未返回'")
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
expect(panel).toContain("orderNumbers.get(row.orderId) || '订单编码待补充'")
expect(panel).toContain("inboundCodes.get(row.inboundId) || '入仓码待补充'")
expect(panel).not.toContain("prop: 'orderId', label: '订单'")
expect(panel).not.toContain("prop: 'inboundId', label: '入库单'")
})
it('clears the previous customer context and ignores a late response', async () => {
let resolveFirst!: (value: Awaited<ReturnType<typeof listContainerFees>>) => void
vi.mocked(listContainerFees).mockImplementation((planId, accountId) => accountId === '11'
? new Promise((resolve) => { resolveFirst = resolve })
: Promise.resolve([]))
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }])
const state = (wrapper.vm.$ as unknown as { setupState: { accountId: string; fees: unknown[]; costs: unknown[]; editorOpen: boolean } }).setupState
state.accountId = '11'; await flushPromises(); state.editorOpen = true
state.accountId = '22'; await flushPromises()
expect(state.fees).toEqual([]); expect(state.costs).toEqual([]); expect(state.editorOpen).toBe(false)
resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
await flushPromises()
expect(state.fees).toEqual([])
expect(getContainerSettlementDetails).not.toHaveBeenCalledWith('11', expect.anything())
})
it('does not request any Finance data when container fees are empty', async () => {
vi.mocked(listContainerFees).mockResolvedValue([])
await mountPanel(['firstleg:inbound:fee-detail:view'])
expect(getContainerSettlementDetails).not.toHaveBeenCalled()
})
it('loads Finance data only on an explicit single request when fee rows agree', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '费用A', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
expect(getContainerSettlementDetails).not.toHaveBeenCalled()
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
await flushPromises()
expect(getContainerSettlementDetails).toHaveBeenCalledWith('11', '99')
expect(getContainerSettlementDetails).toHaveBeenCalledTimes(1)
})
it('fails closed when fee rows disagree with the summary container', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '2', loadingPlanId: '9', containerBookingId: '100', containerNo: null, feeItemId: '2', feeItemName: '费用B', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 20, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:fee-item:view'])
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('财务状态加载失败')
expect(getContainerSettlementDetails).not.toHaveBeenCalled()
})
it('blocks write entry points without a selected customer or edit permission', async () => {
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; editorOpen: boolean } }).setupState
state.openCreate(); await state.save()
expect(state.editorOpen).toBe(false)
expect(createContainerFee).not.toHaveBeenCalled()
})
it('hides all mutation buttons when permissions are missing', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 20, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
})
it('hides adjustment and void actions for settled fees', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['1'], costs: [] })
const wrapper = await mountPanel([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review',
])
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
await flushPromises()
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
expect(getContainerSettlementDetails).toHaveBeenCalledTimes(1)
})
it('hides adjustment and void actions for directly paid payable fees', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review'])
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
})
it('keeps receivable usable when supplier permission is missing', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; supplierId: string | null } } }).setupState
state.openCreate(); await flushPromises()
expect(wrapper.text()).toContain('当前账号缺少供应商查看权限,仅可维护应收费用')
expect(listApplicableFeeItems).toHaveBeenCalledTimes(1)
expect(listContainerFeeSupplierOptions).not.toHaveBeenCalled()
expect(state.form.direction).toBe('RECEIVABLE')
})
it('isolates a supplier load failure and clears supplier fields for receivable', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockRejectedValue(new Error('suppliers unavailable'))
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; feeItemId: string; supplierId: string | null }; feeItems: unknown[] } }).setupState
state.openCreate(); await flushPromises()
expect(state.feeItems).toHaveLength(1)
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises()
expect(wrapper.text()).toContain('供应商加载失败')
state.form.supplierId = '41'; state.form.direction = 'RECEIVABLE'; await flushPromises()
expect(state.form.supplierId).toBeNull()
})
it('submits only the selected supplier ID for payable fees', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
state.openCreate(); await flushPromises()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100
await state.save()
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }))
expect(createContainerFee).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ partnerId: expect.anything() }))
})
it('allows any loaded ACTIVE supplier when the payable fee item has no supplier category', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', defaultPrice: null, supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
state.openCreate(); await flushPromises()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100
await state.save(); await flushPromises()
expect(listContainerFeeSupplierOptions).toHaveBeenCalledWith(null)
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }))
})
it('fails closed when a supplier candidate does not match the fee-item category', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '拖车费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
state.openCreate(); await flushPromises()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100
await state.save(); await flushPromises()
expect(createContainerFee).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('所选供应商与费用项目要求的供应商类别不匹配')
})
it('requires fee-item permission before opening a write form', async () => {
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; editorOpen: boolean } }).setupState
state.openCreate(); await flushPromises()
expect(state.editorOpen).toBe(false)
expect(wrapper.text()).toContain('当前账号缺少费用项目查看权限')
expect(listApplicableFeeItems).not.toHaveBeenCalled()
})
it('clears paid inputs for RECEIVABLE and ON_ACCOUNT and shows backend settlement semantics', async () => {
vi.mocked(listContainerFees).mockResolvedValue([{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '已付拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-1')
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState
state.openCreate(); await flushPromises(); Object.assign(state.form, { direction: 'PAYABLE', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'CASH', paymentReferenceNo: 'CASH-1', paymentVoucherFileIds: ['9001'] }); await flushPromises()
state.form.settlementMode = 'ON_ACCOUNT'; await flushPromises(); expect(state.form).toMatchObject({ settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })
state.form.direction = 'RECEIVABLE'; await flushPromises(); expect(state.form.settlementMode).toBeNull()
})
it('fails closed without DIRECT_PAID evidence and submits a scoped uploaded voucher', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; selectVoucher(event: Event): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState
state.openCreate(); await flushPromises(); Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '7' }); await flushPromises()
Object.assign(state.form, { supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: null, amount: 100 }); await flushPromises()
await state.save(); expect(createContainerFee).not.toHaveBeenCalled()
const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '付款凭证.pdf')] })
await state.selectVoucher({ target: input } as unknown as Event); await state.save()
expect(uploadContainerFeePaymentVoucher).toHaveBeenCalledWith('11', expect.objectContaining({ name: '付款凭证.pdf' }))
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ settlementMode: 'DIRECT_PAID', paymentMethod: 'ONLINE', paymentVoucherFileIds: ['9001'] }))
})
})