优化
This commit is contained in:
@@ -44,12 +44,12 @@ describe('inbound fee-detail API', () => {
|
||||
|
||||
it('keeps an empty supplier category as null and rejects an unknown non-empty category', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [
|
||||
{ id: 31, feeTypeName: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehousePayable: true },
|
||||
{ id: 31, feeTypeName: '拖车费', feeCode: 'TRUCKING', defaultPrice: '60.25', supplierCategory: 'TRUCKING', warehousePayable: true },
|
||||
{ id: 33, feeTypeName: '缺少类别', feeCode: 'MISSING', warehousePayable: true },
|
||||
] } })
|
||||
await expect(listInboundFeeItemOptions()).resolves.toMatchObject([
|
||||
{ id: 31, feeCode: 'TRUCKING', supplierCategory: 'TRUCKING' },
|
||||
{ id: 33, feeCode: 'MISSING', supplierCategory: null },
|
||||
{ id: 31, feeCode: 'TRUCKING', defaultPrice: 60.25, supplierCategory: 'TRUCKING' },
|
||||
{ id: 33, feeCode: 'MISSING', defaultPrice: null, supplierCategory: null },
|
||||
])
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [
|
||||
{ id: 32, feeTypeName: '未知费用', feeCode: 'UNMAPPED', supplierCategory: 'UNKNOWN', warehousePayable: true },
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface SaveInboundFeeDetailRequest {
|
||||
}
|
||||
|
||||
export interface InboundFeeItemOption {
|
||||
defaultPrice: number | null
|
||||
id: string | number
|
||||
feeTypeName: string
|
||||
feeCode: string
|
||||
@@ -123,6 +124,7 @@ export async function listInboundFeeItemOptions() {
|
||||
return (unwrapApiResponse(response).records || []).map((raw): InboundFeeItemOption => ({
|
||||
id: raw.id as string | number,
|
||||
feeTypeName: String(raw.feeTypeName || ''),
|
||||
defaultPrice: raw.defaultPrice == null ? null : Number(raw.defaultPrice),
|
||||
feeCode: String(raw.feeCode || ''),
|
||||
supplierCategory: supplierCategory(raw.supplierCategory),
|
||||
warehousePayable: Boolean(raw.warehousePayable),
|
||||
|
||||
@@ -42,10 +42,106 @@ function mountManager(permissions: string[]) {
|
||||
}
|
||||
|
||||
describe('InboundFeeDetailManager', () => {
|
||||
it.each([80, null])('refreshes the default price on reopening the editor: %s', async (defaultPrice) => {
|
||||
const item = { id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false }
|
||||
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([item])
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
await flushPromises()
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
openEditor(mode: string, row?: InboundFeeDetail): Promise<void>
|
||||
editorOpen: boolean
|
||||
form: { feeItemId: string; unitPrice: number; amount: number }
|
||||
} }).setupState
|
||||
await state.openEditor('create'); await flushPromises()
|
||||
state.editorOpen = false; await flushPromises()
|
||||
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ ...item, defaultPrice }])
|
||||
await state.openEditor('create'); await flushPromises()
|
||||
const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((field) => field.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
|
||||
select.vm.$emit('update:modelValue', '31'); select.vm.$emit('change', '31')
|
||||
await flushPromises()
|
||||
expect(state.form).toMatchObject({ unitPrice: defaultPrice ?? 0, amount: defaultPrice ?? 0 })
|
||||
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(2)
|
||||
state.editorOpen = false; await flushPromises()
|
||||
await state.openEditor('edit', pending); await flushPromises()
|
||||
expect(state.form).toMatchObject({ unitPrice: 10, amount: 100 })
|
||||
})
|
||||
|
||||
it('clears cached choices and blocks saving while refresh is pending or has failed', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
await flushPromises()
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
openEditor(mode: string, row?: InboundFeeDetail): Promise<void>
|
||||
save(): Promise<void>
|
||||
editorOpen: boolean
|
||||
feeItems: unknown[]
|
||||
feeItemsLoading: boolean
|
||||
feeItemsError: { message: string } | null
|
||||
actionError: { message: string } | null
|
||||
form: { unitPrice: number; amount: number }
|
||||
} }).setupState
|
||||
await state.openEditor('edit', pending); await flushPromises()
|
||||
expect(state.feeItems).toHaveLength(1)
|
||||
state.editorOpen = false; await flushPromises()
|
||||
let rejectRefresh!: (reason: Error) => void
|
||||
vi.mocked(listInboundFeeItemOptions).mockImplementationOnce(() => new Promise((_, reject) => { rejectRefresh = reject }))
|
||||
const opening = state.openEditor('edit', pending)
|
||||
await flushPromises()
|
||||
expect(state.feeItems).toEqual([])
|
||||
expect(state.feeItemsLoading).toBe(true)
|
||||
const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((field) => field.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
|
||||
expect(select.props('disabled')).toBe(true)
|
||||
await state.save()
|
||||
expect(state.actionError?.message).toContain('正在加载')
|
||||
rejectRefresh(new Error('加载失败'))
|
||||
await opening; await flushPromises()
|
||||
expect(state.feeItems).toEqual([])
|
||||
expect(state.feeItemsError).not.toBeNull()
|
||||
expect(state.form).toMatchObject({ unitPrice: 10, amount: 100 })
|
||||
await state.save()
|
||||
expect(updateInboundFeeDetail).not.toHaveBeenCalled()
|
||||
state.feeItemsError = null
|
||||
await state.save()
|
||||
expect(updateInboundFeeDetail).not.toHaveBeenCalled()
|
||||
expect(state.actionError?.message).toContain('不可用')
|
||||
})
|
||||
|
||||
it('prefills only on user selection and saves a manually overridden price', async () => {
|
||||
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([
|
||||
{ id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false },
|
||||
{ id: '32', feeTypeName: '手工费', feeCode: 'MANUAL', defaultPrice: null, supplierCategory: null, warehouseReceivable: true, warehousePayable: false },
|
||||
{ id: '33', feeTypeName: '零元费', feeCode: 'ZERO', defaultPrice: 0, supplierCategory: null, warehouseReceivable: true, warehousePayable: false },
|
||||
])
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
await flushPromises()
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
openEditor(mode: string, row?: InboundFeeDetail): Promise<void>
|
||||
save(): Promise<void>
|
||||
form: { feeItemId: string; unitPrice: number; amount: number }
|
||||
} }).setupState
|
||||
await state.openEditor('edit', pending)
|
||||
await flushPromises()
|
||||
expect(state.form).toMatchObject({ unitPrice: 10, amount: 100 })
|
||||
await state.openEditor('create')
|
||||
await flushPromises()
|
||||
const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((item) => item.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
|
||||
for (const [id, price] of [['31', 60], ['32', 0], ['33', 0]] as const) {
|
||||
select.vm.$emit('update:modelValue', id)
|
||||
select.vm.$emit('change', id)
|
||||
await flushPromises()
|
||||
expect(state.form).toMatchObject({ unitPrice: price, amount: price })
|
||||
}
|
||||
select.vm.$emit('update:modelValue', '31')
|
||||
select.vm.$emit('change', '31')
|
||||
await flushPromises()
|
||||
state.form.unitPrice = 65; state.form.amount = 75
|
||||
await state.save(); await flushPromises()
|
||||
expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ feeItemId: '31', unitPrice: 65, amount: 75 }))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); document.body.innerHTML = ''
|
||||
vi.mocked(listInboundFeeDetails).mockResolvedValue([pending, approved])
|
||||
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true }])
|
||||
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true }])
|
||||
vi.mocked(listInboundFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '测试供应商', category: 'TRUCKING' }])
|
||||
vi.mocked(createInboundFeeDetail).mockResolvedValue(pending)
|
||||
vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending)
|
||||
@@ -227,7 +323,7 @@ describe('InboundFeeDetailManager', () => {
|
||||
})
|
||||
|
||||
it('allows any loaded ACTIVE supplier when a payable fee item has no supplier category', async () => {
|
||||
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true }])
|
||||
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '未映射费用', feeCode: 'UNMAPPED', defaultPrice: null, supplierCategory: null, warehouseReceivable: false, warehousePayable: true }])
|
||||
vi.mocked(listInboundFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '测试供应商', category: 'CUSTOMS' }])
|
||||
const wrapper = mountManager([
|
||||
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
|
||||
|
||||
@@ -145,6 +145,8 @@ async function load() {
|
||||
|
||||
async function loadFeeItems() {
|
||||
if (!canViewFeeItems.value || feeItemsLoading.value) return
|
||||
// 刷新期间移除旧候选,加载失败时不能继续使用过期默认价格。
|
||||
feeItems.value = []
|
||||
feeItemsLoading.value = true; feeItemsError.value = null
|
||||
try { feeItems.value = await listInboundFeeItemOptions() }
|
||||
catch (error) { feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败') }
|
||||
@@ -181,11 +183,19 @@ function assignForm(row?: InboundFeeDetail) {
|
||||
voucherError.value = null
|
||||
}
|
||||
|
||||
// 仅在用户主动选费时带价,编辑回显不覆盖已保存的单价和金额。
|
||||
function applyFeeItemDefaultPrice() {
|
||||
const price = selectedFeeItem.value?.defaultPrice ?? 0
|
||||
form.unitPrice = price
|
||||
form.amount = price
|
||||
}
|
||||
|
||||
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
|
||||
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
|
||||
feeItemsError.value = null; suppliersError.value = null
|
||||
editorOpen.value = true
|
||||
if (!feeItems.value.length) await loadFeeItems()
|
||||
// 每次进入录入窗口都读取当前项目配置,不修改已有费用的回显金额。
|
||||
await loadFeeItems()
|
||||
if (form.direction === 'PAYABLE') await loadSuppliers()
|
||||
}
|
||||
|
||||
@@ -225,7 +235,9 @@ async function finishAction(message: string) {
|
||||
async function save() {
|
||||
if (!await formRef.value?.validate()) return
|
||||
if (!canViewFeeItems.value) { actionError.value = { message: '当前账号缺少费用项目查看权限,无法保存费用' }; return }
|
||||
if (feeItemsLoading.value || feeItemsError.value) { actionError.value = { message: feeItemsLoading.value ? '费用项目正在加载,请稍候' : '费用项目加载失败,请重新打开费用窗口后重试' }; return }
|
||||
const selectedItem = feeItems.value.find((item) => String(item.id) === String(form.feeItemId))
|
||||
if (!selectedItem) { actionError.value = { message: '所选费用项目不可用,请重新选择' }; return }
|
||||
if (form.direction === 'PAYABLE' && !canViewSuppliers.value) { actionError.value = { message: '当前账号缺少供应商查看权限,无法维护应付费用' }; return }
|
||||
if (form.direction === 'PAYABLE' && suppliersError.value) { actionError.value = { message: '供应商列表加载失败,无法校验应付费用供应商' }; return }
|
||||
if (form.direction === 'PAYABLE' && loadedSupplierCategory.value !== selectedItem?.supplierCategory) { actionError.value = { message: '供应商候选尚未按费用项目类别加载完成,无法保存' }; return }
|
||||
@@ -334,7 +346,7 @@ onMounted(() => void load())
|
||||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form density-form">
|
||||
<el-form-item label="应收/应付" prop="direction"><el-segmented v-model="form.direction" :options="directionOptions" /></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems"><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :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 || feeItemsLoading" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :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