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

186 lines
14 KiB
TypeScript
Raw Normal View History

2026-08-12 16:40:09 +08:00
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'
2026-08-12 16:31:46 +08:00
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
2026-08-12 16:40:09 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
2026-08-18 17:40:40 +08:00
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees } from '../api/containerFees'
2026-08-12 16:40:09 +08:00
import ContainerFeesPanel from './ContainerFeesPanel.vue'
vi.mock('../api/containerFees', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/containerFees')>()
2026-08-12 16:58:52 +08:00
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeePartnerOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
2026-08-12 16:40:09 +08:00
})
2026-08-12 16:31:46 +08:00
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')
2026-08-12 16:40:09 +08:00
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) } }
2026-08-12 17:29:35 +08:00
async function mountPanel(permissions: string[], customers = [{ accountId: '11', customerName: '客户A' }], containerBookingId = '99') {
2026-08-12 16:40:09 +08:00
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
2026-08-12 17:29:35 +08:00
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers }, global: { plugins: [pinia, ElementPlus] } })
2026-08-12 16:40:09 +08:00
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([])
2026-08-12 16:58:52 +08:00
vi.mocked(listContainerFeePartnerOptions).mockReset().mockResolvedValue([])
2026-08-12 16:40:09 +08:00
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
2026-08-18 17:40:40 +08:00
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
2026-08-12 16:40:09 +08:00
document.body.innerHTML = ''
})
2026-08-12 16:31:46 +08:00
describe('ContainerFeesPanel contract', () => {
2026-08-18 16:39:44 +08:00
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('.fees-panel :deep(.data-table__toolbar) { min-height: 48px;')
})
2026-08-12 16:31:46 +08:00
it('is exposed from loading-list detail and derives customer context from real items', () => {
2026-08-12 17:29:35 +08:00
expect(detail).toContain(':container-booking-id="summary.containerBookingId"')
2026-08-12 16:31:46 +08:00
expect(detail).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')
2026-08-18 17:40:40 +08:00
expect(panel).toContain('调整仅允许修改业务日期和金额')
expect(panel).toContain('请先作废后新增')
2026-08-12 16:58:52 +08:00
expect(panel).toContain("row.partnerName || '服务端未返回'")
2026-08-12 16:31:46 +08:00
})
2026-08-18 17:40:40 +08:00
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, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, 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()
state.form.businessDate = '2026-08-13'; state.form.amount = 120
await state.save()
expect(adjustContainerFee).toHaveBeenCalledWith('9', '11', '2', { expectedSourceVersion: 3, businessDate: '2026-08-13', amount: 120 })
expect(wrapper.text()).toContain('已审核费用仍可调整日期与金额')
})
2026-08-12 16:31:46 +08:00
it('shows only server allocations and supplier snapshots', () => {
expect(panel).toContain('listContainerCostDetails')
expect(panel).toContain('仅展示 Finance 已持久化结果')
expect(panel).toContain("cost.supplierName || '服务端未返回'")
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
})
2026-08-12 16:40:09 +08:00
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)
2026-08-12 16:58:52 +08:00
resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
2026-08-12 16:40:09 +08:00
await flushPromises()
expect(state.fees).toEqual([])
expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything())
})
2026-08-12 17:29:35 +08:00
it('uses the summary container ID when fee rows are empty', async () => {
vi.mocked(listContainerFees).mockResolvedValue([])
await mountPanel(['firstleg:inbound:fee-detail:view'])
expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99')
})
it('uses the summary container ID when fee rows agree', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '费用A', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
await mountPanel(['firstleg:inbound:fee-detail:view'])
expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99')
})
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', partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, 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'])
expect(wrapper.text()).toContain('柜次费用加载失败')
expect(listContainerCostDetails).not.toHaveBeenCalled()
})
2026-08-12 16:40:09 +08:00
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([
2026-08-12 16:58:52 +08:00
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, 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', partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
2026-08-12 16:40:09 +08:00
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
2026-08-18 17:40:40 +08:00
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
2026-08-12 16:40:09 +08:00
})
2026-08-12 16:58:52 +08:00
it('keeps receivable usable when counterparty permission is missing', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', 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; partnerId: string | null; partnerName: string | null } } }).setupState
state.openCreate(); await flushPromises()
expect(wrapper.text()).toContain('当前账号缺少往来单位查看权限,仅可维护应收费用')
expect(listApplicableFeeItems).toHaveBeenCalledTimes(1)
expect(listContainerFeePartnerOptions).not.toHaveBeenCalled()
expect(state.form.direction).toBe('RECEIVABLE')
})
it('isolates a counterparty load failure and clears partner fields for receivable', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeePartnerOptions).mockRejectedValue(new Error('partners unavailable'))
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; partnerId: string | null; partnerName: string | null }; feeItems: unknown[] } }).setupState
state.openCreate(); await flushPromises()
expect(state.feeItems).toHaveLength(1)
expect(wrapper.text()).toContain('往来单位加载失败')
state.form.direction = 'PAYABLE'; await flushPromises()
state.form.partnerId = '41'; state.form.partnerName = '旧快照'; state.form.direction = 'RECEIVABLE'; await flushPromises()
expect(state.form.partnerId).toBeNull(); expect(state.form.partnerName).toBeNull()
})
it('submits the selected ACTIVE counterparty snapshot for payable fees', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '承运商A' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; partnerId: string | null; amount: number } } }).setupState
state.openCreate(); await flushPromises()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; state.form.partnerId = '41'; state.form.amount = 100; await flushPromises()
await state.save()
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A' }))
})
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()
})
2026-08-12 16:31:46 +08:00
})