This commit is contained in:
2026-10-19 17:56:31 +08:00
parent 5a46198bf0
commit 5c28cd7c79
84 changed files with 3356 additions and 3630 deletions
@@ -1,12 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
import { listSuppliers } from '@/modules/suppliers'
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
describe('container fee API', () => {
beforeEach(() => vi.restoreAllMocks())
it('retains configured, zero and empty default prices when loading fee choices', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { records: [
{ id: 1, feeTypeName: '操作费', defaultPrice: '60.25' },
{ id: 2, feeTypeName: '零元费', defaultPrice: 0 },
{ id: 3, feeTypeName: '手工费', defaultPrice: null },
] } })
await expect(listApplicableFeeItems()).resolves.toMatchObject([
{ id: '1', defaultPrice: 60.25 }, { id: '2', defaultPrice: 0 }, { id: '3', defaultPrice: null },
])
})
it('passes accountId and exact controller DTOs', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, loadingPlanId: 9, containerBookingId: 99, feeItemId: 7, feeItemNameSnapshot: '操作费', direction: 'PAYABLE', supplierId: 41, supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-7', paymentVoucherFileIds: [9001], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1 } })
@@ -84,6 +84,7 @@ export interface ContainerFeeAllocationGroupAdjustRequest {
}
export interface ApplicableFeeItem {
defaultPrice: number | null
id: string
name: string
feeCode: string
@@ -224,7 +225,7 @@ export async function voidContainerFee(planId: string, accountId: string, feeId:
export async function listApplicableFeeItems() {
const response = await http.get<ApiEnvelope<{ records: Record<string, unknown>[] }>, ApiEnvelope<{ records: Record<string, unknown>[] }>>('/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } })
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), feeCode: String(raw.feeCode || ''), supplierCategory: supplierCategory(raw.supplierCategory), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
return (unwrapApiResponse(response).records || []).map((raw): ApplicableFeeItem => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), defaultPrice: raw.defaultPrice == null ? null : Number(raw.defaultPrice), feeCode: String(raw.feeCode || ''), supplierCategory: supplierCategory(raw.supplierCategory), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
}
export async function listContainerFeeSupplierOptions(category: SupplierCategory | null): Promise<ContainerFeeSupplierOption[]> {
@@ -39,6 +39,90 @@ beforeEach(() => {
})
describe('ContainerFeesPanel contract', () => {
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"')
@@ -179,7 +263,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('keeps receivable usable when supplier permission is missing', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
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()
@@ -190,7 +274,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('isolates a supplier load failure and clears supplier fields for receivable', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
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
@@ -203,7 +287,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('submits only the selected supplier ID for payable fees', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
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
@@ -215,7 +299,7 @@ describe('ContainerFeesPanel contract', () => {
})
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', supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
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
@@ -227,7 +311,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('fails closed when a supplier candidate does not match the fee-item category', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
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
@@ -249,7 +333,7 @@ describe('ContainerFeesPanel contract', () => {
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', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
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
@@ -259,7 +343,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('fails closed without DIRECT_PAID evidence and submits a scoped uploaded voucher', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
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
@@ -55,6 +55,7 @@ const voucherUploading = ref(false)
const voucherError = ref<ApiErrorDetails | null>(null)
const rejection = ref('')
let loadSequence = 0
let feeItemLoadSequence = 0
let supplierLoadSequence = 0
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
@@ -122,6 +123,11 @@ function clearPaidFields() {
form.paidAt = null; form.paymentMethod = null; form.paymentReferenceNo = null; form.paymentVoucherFileIds = null
voucherFiles.value = []; voucherError.value = null
}
// 仅在用户主动选费时带价,保留已有费用回显金额与人工修改。
function applyFeeItemDefaultPrice() {
form.amount = selectedFeeItem.value?.defaultPrice ?? 0
}
function settlementLabel(item: ContainerFeeResource) { return item.direction !== 'PAYABLE' ? '—' : item.settlementMode === 'DIRECT_PAID' ? '已付成本' : item.settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' }
function paymentMethodLabel(value: string | null | undefined) { return ({ ONLINE: '线上支付', BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[value || ''] || '—' }
function customerLabel(customerId: string) { return customerOptions.value.find((item) => item.value === customerId)?.label || '客户信息缺失' }
@@ -153,12 +159,17 @@ function removeVoucher(fileId: string) {
}
async function loadFeeItems() {
const sequence = ++feeItemLoadSequence
feeItems.value = []; feeItemsError.value = null
if (!canViewFeeItems.value) return
feeItemsLoading.value = true
try { feeItems.value = await listApplicableFeeItems() }
catch (cause) { feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
finally { feeItemsLoading.value = false }
try {
const items = await listApplicableFeeItems()
// 只接收当前窗口最新请求,旧响应不得覆盖价格或清除新请求的加载状态。
if (sequence === feeItemLoadSequence) feeItems.value = items
}
catch (cause) { if (sequence === feeItemLoadSequence) feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
finally { if (sequence === feeItemLoadSequence) feeItemsLoading.value = false }
}
async function loadSuppliers() {
@@ -340,6 +351,12 @@ function clearCustomerContext() {
}
watch(customerOptions, initializeCustomer, { immediate: true })
watch(accountId, () => { clearCustomerContext(); void load() })
watch(editorOpen, (open) => {
if (open) return
// 同步失效旧窗口请求,覆盖关闭后立即重开及切换客户的情况。
feeItemLoadSequence += 1
feeItems.value = []; feeItemsError.value = null; feeItemsLoading.value = false
}, { flush: 'sync' })
watch(() => form.direction, (direction) => {
if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = ''
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; form.settlementMode = null; clearPaidFields(); suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; suppliersLoading.value = false }
@@ -401,7 +418,7 @@ onMounted(() => void load())
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form density-form">
<template v-if="!target">
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item>
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="供应商" required><el-select v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="结算方式" required><el-radio-group v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item>
<template v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'">