优化
This commit is contained in:
@@ -3,7 +3,7 @@ import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails,
|
||||
listInboundFeeItemOptions, listInboundFeeSupplierOptions, reviewInboundFeeDetail,
|
||||
updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
|
||||
updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
|
||||
} from './inboundFeeDetails'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
@@ -19,7 +19,7 @@ describe('inbound fee-detail API', () => {
|
||||
})
|
||||
|
||||
it('maps the complete write contract and optimistic version', async () => {
|
||||
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, supplierId: '41', remark: '验收' }
|
||||
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-31', paymentVoucherFileIds: ['9001'], remark: '验收' }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: payload })
|
||||
@@ -34,6 +34,14 @@ describe('inbound fee-detail API', () => {
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11', { params: { accountId: '100' } })
|
||||
})
|
||||
|
||||
it('reuses the scoped FINANCE_VOUCHER upload and fails closed on customer mismatch', async () => {
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { fileId: 9001, tenantId: 100, fileName: '付款凭证.pdf' } })
|
||||
await expect(uploadInboundFeePaymentVoucher('100', new File(['x'], '付款凭证.pdf'))).resolves.toEqual({ fileId: '9001', fileName: '付款凭证.pdf' })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/finance/v1/vouchers', expect.any(FormData), { params: { customerId: '100' }, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { fileId: 9002, tenantId: 101, fileName: '错客户.pdf' } })
|
||||
await expect(uploadInboundFeePaymentVoucher('100', new File(['x'], '错客户.pdf'))).rejects.toThrow('付款凭证与当前客户不匹配')
|
||||
})
|
||||
|
||||
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 },
|
||||
|
||||
@@ -3,6 +3,8 @@ import { listSuppliers, type SupplierCategory } from '@/modules/suppliers'
|
||||
|
||||
export type InboundFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
export type InboundFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
export type FeeSettlementMode = 'ON_ACCOUNT' | 'DIRECT_PAID'
|
||||
export type FeePaymentMethod = 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
||||
|
||||
export interface InboundFeeDetail {
|
||||
id: string | number
|
||||
@@ -15,6 +17,11 @@ export interface InboundFeeDetail {
|
||||
supplierId: string | number | null
|
||||
supplierName: string | null
|
||||
supplierCategory: SupplierCategory | null
|
||||
settlementMode?: FeeSettlementMode | null
|
||||
paidAt?: string | null
|
||||
paymentMethod?: FeePaymentMethod | null
|
||||
paymentReferenceNo?: string | null
|
||||
paymentVoucherFileIds?: Array<string | number> | null
|
||||
businessDate: string
|
||||
customerName: string | null
|
||||
feeTypeNameSnapshot: string
|
||||
@@ -37,6 +44,11 @@ export interface SaveInboundFeeDetailRequest {
|
||||
currency: string
|
||||
amount: number
|
||||
supplierId: string | number | null
|
||||
settlementMode: FeeSettlementMode | null
|
||||
paidAt: string | null
|
||||
paymentMethod: FeePaymentMethod | null
|
||||
paymentReferenceNo: string | null
|
||||
paymentVoucherFileIds: Array<string | number> | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
@@ -50,6 +62,7 @@ export interface InboundFeeItemOption {
|
||||
}
|
||||
|
||||
export interface InboundFeeSupplierOption { id: string; supplierName: string; category: SupplierCategory }
|
||||
export interface FeePaymentVoucher { fileId: string; fileName: string }
|
||||
|
||||
const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
|
||||
|
||||
@@ -123,3 +136,14 @@ export async function listInboundFeeSupplierOptions(category: SupplierCategory |
|
||||
id: supplier.id, supplierName: supplier.name, category: supplier.category,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function uploadInboundFeePaymentVoucher(customerId: string, file: File): Promise<FeePaymentVoucher> {
|
||||
const body = new FormData(); body.append('file', file)
|
||||
const raw = unwrapApiResponse(await http.post<unknown, ApiEnvelope<Record<string, unknown>>>('/platform/finance/v1/vouchers', body, {
|
||||
params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}))
|
||||
const fileId = raw.fileId == null ? '' : String(raw.fileId)
|
||||
const echoedCustomerId = raw.tenantId == null ? String(raw.customerId ?? customerId) : String(raw.tenantId)
|
||||
if (!fileId || echoedCustomerId !== customerId) throw new Error('付款凭证与当前客户不匹配')
|
||||
return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) }
|
||||
}
|
||||
|
||||
@@ -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); } }
|
||||
|
||||
Reference in New Issue
Block a user