This commit is contained in:
2026-08-12 16:40:09 +08:00
parent d8bbfe05f3
commit a44fd5f235
12 changed files with 355 additions and 72 deletions
@@ -1,10 +1,39 @@
import { describe, expect, it } from 'vitest'
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 { createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFees } from '../api/containerFees'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
vi.mock('../api/containerFees', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/containerFees')>()
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: 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')
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' }]) {
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', customers }, 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(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
document.body.innerHTML = ''
})
describe('ContainerFeesPanel contract', () => {
it('is exposed from loading-list detail and derives customer context from real items', () => {
expect(detail).toContain('<ContainerFeesPanel :plan-id="summary.planId" :customers="feeCustomers" />')
@@ -31,4 +60,38 @@ describe('ContainerFeesPanel contract', () => {
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
})
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', 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 flushPromises()
expect(state.fees).toEqual([])
expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything())
})
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', 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', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
expect(wrapper.text()).not.toContain('新增费用')
for (const label of ['通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
})
})
@@ -26,6 +26,7 @@ const target = ref<ContainerFeeResource | null>(null)
const formRef = ref<FormInstance>()
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
const rejection = ref('')
let loadSequence = 0
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit'))
@@ -67,21 +68,28 @@ function resetForm(item?: ContainerFeeResource) {
async function load() {
if (!accountId.value || !canView.value) return
const sequence = ++loadSequence
const requestedAccountId = accountId.value
loading.value = true; error.value = null
try {
const [feeRows, items] = await Promise.all([listContainerFees(props.planId, accountId.value), listApplicableFeeItems()])
const [feeRows, items] = await Promise.all([listContainerFees(props.planId, requestedAccountId), listApplicableFeeItems()])
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
fees.value = feeRows; feeItems.value = items
costs.value = []
const currentContainerId = feeRows.find((item) => item.containerBookingId)?.containerBookingId
if (currentContainerId) costs.value = await listContainerCostDetails(accountId.value, currentContainerId)
} catch (cause) { error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
finally { loading.value = false }
if (currentContainerId) {
const result = await listContainerCostDetails(requestedAccountId, currentContainerId)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
costs.value = result
}
} catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
finally { if (sequence === loadSequence) loading.value = false }
}
function openCreate() { resetForm(); editorOpen.value = true }
function openAdjust(item: ContainerFeeResource) { resetForm(item); editorOpen.value = true }
function openCreate() { if (!canEdit.value || !accountId.value) return; resetForm(); editorOpen.value = true }
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorOpen.value = true }
async function save() {
if (!accountId.value || !await formRef.value?.validate().catch(() => false)) return
if (!canEdit.value || !accountId.value || !await formRef.value?.validate().catch(() => false)) return
saving.value = true
try {
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, { ...form })
@@ -90,22 +98,30 @@ async function save() {
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) }
finally { saving.value = false }
}
async function approve(item: ContainerFeeResource) { await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
function openReject(item: ContainerFeeResource) { target.value = item; rejection.value = ''; rejectOpen.value = true }
async function approve(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
function openReject(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; target.value = item; rejection.value = ''; rejectOpen.value = true }
async function reject() {
if (!target.value || !rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
if (!canReview.value || !accountId.value || !target.value) return
if (!rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
saving.value = true
try { await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim()); rejectOpen.value = false; ElMessage.success('费用已驳回'); await load() }
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false }
}
async function voidFee(item: ContainerFeeResource) {
if (!canReview.value || !accountId.value) return
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' })
await voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion).then(() => ElMessage.success('费用已作废')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message))
}
function money(value: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' }
function clearCustomerContext() {
loadSequence += 1
fees.value = []; costs.value = []; feeItems.value = []; error.value = null; loading.value = false
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
resetForm()
}
watch(customerOptions, initializeCustomer, { immediate: true })
watch(accountId, () => { fees.value = []; costs.value = []; void load() })
watch(accountId, () => { clearCustomerContext(); void load() })
watch(() => form.direction, () => { if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' })
onMounted(() => void load())
</script>