优化
This commit is contained in:
@@ -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'">
|
||||
|
||||
Reference in New Issue
Block a user