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
@@ -90,7 +90,7 @@ describe('InboundFeeDetailManager', () => {
})
it('keeps currency fixed to CNY in the form and write payload', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
await flushPromises()
@@ -105,4 +105,40 @@ describe('InboundFeeDetailManager', () => {
expect(wrapper.find('.fee-table-shell').exists()).toBe(true)
expect(wrapper.find('.detail-table-wrap').exists()).toBe(false)
})
it('loads RECEIVABLE fee items without requesting counterparties', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
await flushPromises()
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
expect(listInboundFeePartnerOptions).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('缺少往来单位查看权限')
expect(document.body.textContent).toContain('应收费用不受影响')
})
it('does not expose the editor without fee-item view permission', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
await flushPromises()
expect(wrapper.findAll('button').some((button) => button.text().includes('新增费用'))).toBe(false)
expect(listInboundFeeItemOptions).not.toHaveBeenCalled()
})
it('keeps fee items usable when the independent counterparty request fails', async () => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', partnerId: '41', partnerName: '测试往来单位' }])
vi.mocked(listInboundFeePartnerOptions).mockRejectedValue(new ApiResponseError('往来单位不可用', 'A0403', 403, null, 'request-partner', 'trace-partner'))
const wrapper = mountManager([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
'firstleg:fee-item:view', 'platform:counterparty:list',
])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('编辑'))?.trigger('click')
await flushPromises()
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
expect(listInboundFeePartnerOptions).toHaveBeenCalledTimes(1)
expect(document.body.textContent).toContain('往来单位不可用')
expect(document.body.textContent).toContain('request-partner')
expect(document.body.textContent).toContain('trace-partner')
expect(document.querySelector('.fee-form .el-select')?.classList.contains('is-disabled')).toBe(false)
})
})
@@ -24,6 +24,11 @@ const access = useAccessPreviewStore()
const rows = ref<InboundFeeDetail[]>([])
const feeItems = ref<InboundFeeItemOption[]>([])
const partners = ref<InboundFeePartnerOption[]>([])
const feeItemsLoading = ref(false)
const partnersLoading = ref(false)
const feeItemsError = ref<ApiErrorDetails | null>(null)
const partnersError = ref<ApiErrorDetails | null>(null)
const partnersLoadAttempted = ref(false)
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -49,6 +54,12 @@ const rules: FormRules = {
currency: [{ required: true, pattern: /^[A-Z]{3}$/, message: '货币须为三个大写字母', trigger: 'blur' }],
}
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
const canViewPartners = computed(() => access.can('platform:counterparty:list'))
const directionOptions = computed(() => [
{ label: '应收', value: 'RECEIVABLE' },
{ label: '应付', value: 'PAYABLE', disabled: !canViewPartners.value },
])
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
function canEdit(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus !== 'APPROVED' }
@@ -67,9 +78,21 @@ async function load() {
finally { loading.value = false }
}
async function loadOptions() {
try { [feeItems.value, partners.value] = await Promise.all([listInboundFeeItemOptions(), listInboundFeePartnerOptions()]) }
catch (error) { actionError.value = getApiErrorDetails(error, '费用维护选项加载失败') }
async function loadFeeItems() {
if (!canViewFeeItems.value || feeItemsLoading.value) return
feeItemsLoading.value = true; feeItemsError.value = null
try { feeItems.value = await listInboundFeeItemOptions() }
catch (error) { feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败') }
finally { feeItemsLoading.value = false }
}
async function loadPartners() {
if (!canViewPartners.value || partnersLoading.value || partners.value.length || partnersLoadAttempted.value) return
partnersLoadAttempted.value = true
partnersLoading.value = true; partnersError.value = null
try { partners.value = await listInboundFeePartnerOptions() }
catch (error) { partnersError.value = getApiErrorDetails(error, '往来单位加载失败') }
finally { partnersLoading.value = false }
}
function assignForm(row?: InboundFeeDetail) {
@@ -82,9 +105,12 @@ function assignForm(row?: InboundFeeDetail) {
}
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
partnersLoadAttempted.value = false
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
feeItemsError.value = null; partnersError.value = null
editorOpen.value = true
if (!feeItems.value.length) await loadOptions()
if (!feeItems.value.length) await loadFeeItems()
if (form.direction === 'PAYABLE') await loadPartners()
}
function normalizePayload(): SaveInboundFeeDetailRequest {
@@ -100,6 +126,8 @@ async function finishAction(message: string) {
async function save() {
if (!await formRef.value?.validate()) return
if (!canViewFeeItems.value) { actionError.value = { message: '当前账号缺少费用项目查看权限,无法保存费用' }; return }
if (form.direction === 'PAYABLE' && !canViewPartners.value) { actionError.value = { message: '当前账号缺少往来单位查看权限,无法维护应付费用' }; return }
if (form.direction === 'PAYABLE' && !form.partnerId) { actionError.value = { message: '应付费用必须选择往来单位' }; return }
saving.value = true; actionError.value = null
try {
@@ -142,7 +170,10 @@ async function voidRow(row: InboundFeeDetail) {
}
watch(() => [props.accountId, props.inboundId], () => void load())
watch(() => form.direction, () => { if (form.direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null } })
watch(() => form.direction, (direction) => {
if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
else void loadPartners()
})
onMounted(() => void load())
</script>
@@ -152,7 +183,7 @@ onMounted(() => void load())
<span>共 {{ rows.length }} 条应收应付费用明细</span>
<div>
<el-tooltip content="刷新费用明细" placement="top"><el-button circle aria-label="刷新费用明细" :loading="loading" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="access.can('firstleg:inbound:fee-detail:edit')" type="primary" @click="openEditor('create')"><Plus :size="16" />新增费用</el-button>
<el-button v-if="access.can('firstleg:inbound:fee-detail:edit') && canViewFeeItems" type="primary" @click="openEditor('create')"><Plus :size="16" />新增费用</el-button>
</div>
</div>
<el-alert v-if="!canView" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
@@ -195,11 +226,15 @@ onMounted(() => void load())
<AppDialog v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewPartners" title="当前账号缺少往来单位查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
<el-alert v-if="partnersError" type="error" :closable="true" @close="partnersError = null"><template #title>{{ partnersError.message }}</template><ApiErrorMeta :details="partnersError" /></el-alert>
<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">
<el-form-item label="应收/应付" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }]" /></el-form-item>
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable><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.partnerId" filterable><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item>
<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 v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item>
<el-form-item label="业务发生日" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="货币" prop="currency"><el-input model-value="CNY" readonly aria-label="货币 CNY" /></el-form-item>
<el-form-item label="单价" prop="unitPrice"><el-input-number v-model="form.unitPrice" :min="0" :precision="4" /></el-form-item>