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
@@ -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'">