This commit is contained in:
2026-08-20 18:02:24 +08:00
parent c93eea89c8
commit b0d527b016
8 changed files with 288 additions and 32 deletions
@@ -10,7 +10,7 @@ import { ApiResponseError } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
reviewInboundFeeDetail, updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import InboundFeeDetailManager from './InboundFeeDetailManager.vue'
import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails'
@@ -18,12 +18,13 @@ import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDe
vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({
listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeeSupplierOptions: vi.fn(),
createInboundFeeDetail: vi.fn(), updateInboundFeeDetail: vi.fn(), deleteInboundFeeDetail: vi.fn(),
reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), voidInboundFeeDetail: vi.fn(),
reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), uploadInboundFeePaymentVoucher: vi.fn(), voidInboundFeeDetail: vi.fn(),
}))
const pending: InboundFeeDetail = {
id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1,
inboundCode: 'RK-88', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null,
settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null,
businessDate: '2026-08-12', customerName: '测试客户', feeTypeNameSnapshot: '操作费',
unitPrice: 10, currency: 'CNY', amount: 100, remark: 'marker', reviewStatus: 'PENDING',
reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT',
@@ -49,6 +50,7 @@ describe('InboundFeeDetailManager', () => {
vi.mocked(createInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' })
vi.mocked(uploadInboundFeePaymentVoucher).mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
vi.mocked(reviewInboundFeeDetail).mockResolvedValue(null)
vi.mocked(deleteInboundFeeDetail).mockResolvedValue(null)
vi.mocked(voidInboundFeeDetail).mockResolvedValue(null)
@@ -81,6 +83,13 @@ describe('InboundFeeDetailManager', () => {
for (const action of ['编辑', '审核', '驳回', '调整', '作废', '删除']) expect(buttons).not.toContain(action)
})
it('shows payable settlement meaning and a DIRECT_PAID summary without changing fee direction', async () => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-11', paymentVoucherFileIds: ['9001'] }])
const wrapper = mountManager(['firstleg:inbound:fee-detail:view'])
await flushPromises()
expect(wrapper.text()).toContain('应付'); expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-11')
})
it('does not request or expose fee rows without view permission', async () => {
mountManager([]); await flushPromises()
expect(listInboundFeeDetails).not.toHaveBeenCalled()
@@ -163,7 +172,7 @@ describe('InboundFeeDetailManager', () => {
})
it('keeps fee items usable but blocks payable save when the supplier request fails', async () => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', supplierCategory: 'TRUCKING' }])
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', supplierCategory: 'TRUCKING', settlementMode: 'ON_ACCOUNT' }])
vi.mocked(listInboundFeeSupplierOptions).mockRejectedValue(new ApiResponseError('供应商不可用', 'A0403', 403, null, 'request-supplier', 'trace-supplier'))
const wrapper = mountManager([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
@@ -191,11 +200,45 @@ describe('InboundFeeDetailManager', () => {
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100
const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100
await state.save(); await flushPromises()
expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith(null)
expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' }))
expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }))
})
it('clears settlement and paid fields when switching to RECEIVABLE or ON_ACCOUNT', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click'); await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: { form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState
Object.assign(state.form, { direction: 'PAYABLE', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'CASH', paymentReferenceNo: 'CASH-1', paymentVoucherFileIds: ['9001'] }); await flushPromises()
state.form.settlementMode = 'ON_ACCOUNT'; await flushPromises()
expect(state.form).toMatchObject({ settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })
state.form.direction = 'RECEIVABLE'; await flushPromises()
expect(state.form).toMatchObject({ direction: 'RECEIVABLE', settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })
})
it('requires complete DIRECT_PAID evidence and submits uploaded voucher IDs only after scoped upload succeeds', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click'); await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: { 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
Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: null, amount: 100 }); await flushPromises()
await state.save(); expect(createInboundFeeDetail).not.toHaveBeenCalled()
const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '付款凭证.pdf')] })
await state.selectVoucher({ target: input } as unknown as Event); await state.save()
expect(uploadInboundFeePaymentVoucher).toHaveBeenCalledWith('100', expect.objectContaining({ name: '付款凭证.pdf' }))
expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ settlementMode: 'DIRECT_PAID', paymentMethod: 'BANK_TRANSFER', paymentVoucherFileIds: ['9001'] }))
})
it('keeps DIRECT_PAID save closed and exposes feedback when voucher upload fails', async () => {
vi.mocked(uploadInboundFeePaymentVoucher).mockRejectedValue(new ApiResponseError('File服务不可用', 'A0503', 503, null, 'request-file', 'trace-file'))
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click'); await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: { 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
Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: 'ONLINE-1', amount: 100 }); await flushPromises()
const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '失败凭证.png')] })
await state.selectVoucher({ target: input } as unknown as Event); await state.save(); await flushPromises()
expect(createInboundFeeDetail).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('request-file'); expect(document.body.textContent).toContain('trace-file')
})
it('refreshes fee rows and the order detail after every successful write', () => {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Check, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Undo2, X } from '@lucide/vue'
import { Check, FileUp, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Undo2, X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -11,7 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
reviewInboundFeeDetail, updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import type {
InboundFeeDetail, InboundFeeItemOption,
@@ -40,12 +40,16 @@ const target = ref<InboundFeeDetail | null>(null)
const formRef = ref<FormInstance>()
const rejectOpen = ref(false)
const rejectReason = ref('')
const voucherFiles = ref<Array<{ fileId: string; fileName: string }>>([])
const voucherUploading = ref(false)
const voucherError = ref<ApiErrorDetails | null>(null)
let supplierLoadSequence = 0
const emptyForm = (): SaveInboundFeeDetailRequest => ({
feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 0, currency: 'CNY', amount: 0,
supplierId: null, remark: null,
supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null,
paymentReferenceNo: null, paymentVoucherFileIds: null, remark: null,
})
const form = reactive<SaveInboundFeeDetailRequest>(emptyForm())
const rules: FormRules = {
@@ -77,6 +81,17 @@ function canAdjust(row: InboundFeeDetail) { return isManual(row) && access.can('
function canVoid(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function directionLabel(value: string) { return value === 'RECEIVABLE' ? '应收' : '应付' }
function settlementLabel(row: InboundFeeDetail) { return row.direction !== 'PAYABLE' ? '—' : row.settlementMode === 'DIRECT_PAID' ? '已付成本' : row.settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' }
function paymentMethodLabel(value: string | null | undefined) { return ({ ONLINE: '线上支付', BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[value || ''] || '—' }
function paidSummary(row: InboundFeeDetail) {
if (row.settlementMode !== 'DIRECT_PAID') return '—'
const evidence = row.paymentReferenceNo || (row.paymentVoucherFileIds?.length ? `${row.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
return `${row.paidAt?.replace('T', ' ').slice(0, 16) || '时间缺失'} · ${paymentMethodLabel(row.paymentMethod)} · ${evidence}`
}
function clearPaidFields() {
form.paidAt = null; form.paymentMethod = null; form.paymentReferenceNo = null; form.paymentVoucherFileIds = null
voucherFiles.value = []; voucherError.value = null
}
async function load() {
if (!canView.value || !normalizedInboundId.value) {
@@ -127,8 +142,12 @@ function assignForm(row?: InboundFeeDetail) {
Object.assign(form, row ? {
feeItemId: row.feeItemId, direction: row.direction, businessDate: row.businessDate,
unitPrice: Number(row.unitPrice), currency: row.currency, amount: Number(row.amount),
supplierId: row.supplierId, remark: row.remark,
supplierId: row.supplierId, settlementMode: row.settlementMode ?? null, paidAt: row.paidAt ?? null,
paymentMethod: row.paymentMethod ?? null, paymentReferenceNo: row.paymentReferenceNo ?? null,
paymentVoucherFileIds: row.paymentVoucherFileIds ?? null, remark: row.remark,
} : emptyForm())
voucherFiles.value = (row?.paymentVoucherFileIds || []).map((fileId) => ({ fileId: String(fileId), fileName: `凭证 ${String(fileId)}` }))
voucherError.value = null
}
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
@@ -140,10 +159,34 @@ async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDe
}
function normalizePayload(): SaveInboundFeeDetailRequest {
const directPaid = form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'
return { ...form, currency: 'CNY', supplierId: form.direction === 'PAYABLE' ? form.supplierId : null,
settlementMode: form.direction === 'PAYABLE' ? form.settlementMode : null,
paidAt: directPaid ? form.paidAt : null, paymentMethod: directPaid ? form.paymentMethod : null,
paymentReferenceNo: directPaid ? form.paymentReferenceNo?.trim() || null : null,
paymentVoucherFileIds: directPaid && voucherFiles.value.length ? voucherFiles.value.map((item) => item.fileId) : null,
remark: form.remark?.trim() || null }
}
async function selectVoucher(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || voucherUploading.value) return
if (voucherFiles.value.length >= 20) { voucherError.value = { message: '付款凭证最多上传 20 个' }; return }
voucherUploading.value = true; voucherError.value = null
try {
const uploaded = await uploadInboundFeePaymentVoucher(props.accountId, file)
if (!voucherFiles.value.some((item) => item.fileId === uploaded.fileId)) voucherFiles.value.push(uploaded)
form.paymentVoucherFileIds = voucherFiles.value.map((item) => item.fileId)
} catch (error) { voucherError.value = getApiErrorDetails(error, '付款凭证上传或校验失败') }
finally { voucherUploading.value = false }
}
function removeVoucher(fileId: string) {
voucherFiles.value = voucherFiles.value.filter((item) => item.fileId !== fileId)
form.paymentVoucherFileIds = voucherFiles.value.length ? voucherFiles.value.map((item) => item.fileId) : null
}
async function finishAction(message: string) {
await load(); emit('changed'); ElMessage.success(message)
}
@@ -158,6 +201,11 @@ async function save() {
if (form.direction === 'PAYABLE' && !form.supplierId) { actionError.value = { message: '应付费用必须选择供应商' }; return }
const selectedSupplier = suppliers.value.find((supplier) => supplier.id === String(form.supplierId))
if (form.direction === 'PAYABLE' && (!selectedSupplier || (selectedItem?.supplierCategory && selectedSupplier.category !== selectedItem.supplierCategory))) { actionError.value = { message: '所选供应商与费用项目要求的供应商类别不匹配' }; return }
if (form.direction === 'PAYABLE' && !form.settlementMode) { actionError.value = { message: '应付费用必须选择结算方式' }; return }
if (voucherUploading.value || voucherError.value) { actionError.value = { message: voucherUploading.value ? '付款凭证正在上传,请稍候' : '付款凭证上传或校验失败,请处理后再保存' }; return }
if (form.settlementMode === 'DIRECT_PAID' && (!form.paidAt || !form.paymentMethod || (!form.paymentReferenceNo?.trim() && !voucherFiles.value.length))) {
actionError.value = { message: '直接已付必须填写已付时间、付款方式,并提供流水号或付款凭证' }; return
}
saving.value = true; actionError.value = null
try {
const payload = normalizePayload()
@@ -200,9 +248,10 @@ async function voidRow(row: InboundFeeDetail) {
watch(() => [props.accountId, normalizedInboundId.value], () => void load())
watch(() => form.direction, (direction) => {
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; suppliersLoading.value = false }
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; form.settlementMode = null; clearPaidFields(); suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; suppliersLoading.value = false }
else void loadSuppliers()
})
watch(() => form.settlementMode, (mode) => { if (mode !== 'DIRECT_PAID') clearPaidFields() })
watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() })
onMounted(() => void load())
</script>
@@ -240,6 +289,8 @@ onMounted(() => void load())
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
<el-table-column label="应收/应付" width="110"><template #default="{ row }">{{ directionLabel(row.direction) }}</template></el-table-column>
<el-table-column label="结算方式" width="120"><template #default="{ row }">{{ settlementLabel(row as InboundFeeDetail) }}</template></el-table-column>
<el-table-column label="已付摘要" min-width="290" show-overflow-tooltip><template #default="{ row }">{{ paidSummary(row as InboundFeeDetail) }}</template></el-table-column>
<el-table-column prop="feeTypeNameSnapshot" label="费用类型" min-width="150" />
<el-table-column label="供应商/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName : row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="130" />
@@ -265,6 +316,17 @@ onMounted(() => void load())
<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.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="form.settlementMode"><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'">
<el-form-item label="已付时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际付款时间" /></el-form-item>
<el-form-item label="付款方式" required><el-select v-model="form.paymentMethod" placeholder="请选择付款方式"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item>
<el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="流水号与付款凭证至少填写一项" /></el-form-item>
<el-form-item label="付款凭证" class="voucher-field">
<div class="voucher-control"><label class="voucher-picker" :class="{ 'is-disabled': voucherUploading }"><FileUp :size="15" /><span>{{ voucherUploading ? '上传并校验中' : '选择凭证' }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="voucherUploading" @change="selectVoucher" /></label><span class="voucher-hint">FINANCE_VOUCHER · 最多 20 个</span></div>
<el-alert v-if="voucherError" type="error" :closable="true" show-icon @close="voucherError = null"><template #title>{{ voucherError.message }}</template><ApiErrorMeta :details="voucherError" /></el-alert>
<div v-if="voucherFiles.length" class="voucher-list"><span v-for="file in voucherFiles" :key="file.fileId"><span :title="file.fileName">{{ file.fileName }}</span><button type="button" :aria-label="`移除${file.fileName}`" @click="removeVoucher(file.fileId)"><X :size="14" /></button></span></div>
</el-form-item>
</template>
<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>
@@ -288,6 +350,7 @@ onMounted(() => void load())
.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }
.fee-form :deep(.el-select), .fee-form :deep(.el-date-editor), .fee-form :deep(.el-input-number) { width: 100%; }
.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }