优化
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee } from './containerFees'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
||||
@@ -9,8 +9,8 @@ describe('container fee API', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
it('passes accountId and exact controller DTOs', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] })
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, loadingPlanId: 9, containerBookingId: 99, feeItemId: 7, feeItemNameSnapshot: '操作费', direction: 'PAYABLE', supplierId: 41, supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1 } })
|
||||
const values = { feeItemId: '7', direction: 'PAYABLE' as const, supplierId: '41', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, remark: null }
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, loadingPlanId: 9, containerBookingId: 99, feeItemId: 7, feeItemNameSnapshot: '操作费', direction: 'PAYABLE', supplierId: 41, supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-7', paymentVoucherFileIds: [9001], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1 } })
|
||||
const values = { feeItemId: '7', direction: 'PAYABLE' as const, supplierId: '41', settlementMode: 'DIRECT_PAID' as const, paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER' as const, paymentReferenceNo: 'BANK-7', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, remark: null }
|
||||
await listContainerFees('9', '11'); const created = await createContainerFee('9', '11', values)
|
||||
await approveContainerFee('9', '11', '3'); await rejectContainerFee('9', '11', '3', '资料不全')
|
||||
await adjustContainerFee('9', '11', '3', { expectedSourceVersion: 2, businessDate: '2026-08-13', amount: 120 }); await voidContainerFee('9', '11', '3', 3)
|
||||
@@ -18,7 +18,15 @@ describe('container fee API', () => {
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', values, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/adjust', { expectedSourceVersion: 2, businessDate: '2026-08-13', amount: 120 }, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/void', { expectedSourceVersion: 3 }, { params: { accountId: '11' } })
|
||||
expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING' })
|
||||
expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paymentVoucherFileIds: ['9001'] })
|
||||
})
|
||||
|
||||
it('uploads a payment voucher in the current customer scope and rejects a mismatched echo', async () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValueOnce({ code: '200', data: { fileId: 9001, tenantId: 11, fileName: '付款凭证.png' } })
|
||||
await expect(uploadContainerFeePaymentVoucher('11', new File(['x'], '付款凭证.png'))).resolves.toEqual({ fileId: '9001', fileName: '付款凭证.png' })
|
||||
expect(post).toHaveBeenCalledWith('/platform/finance/v1/vouchers', expect.any(FormData), { params: { customerId: '11' }, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
post.mockResolvedValueOnce({ code: '200', data: { fileId: 9002, tenantId: 12, fileName: '错客户.png' } })
|
||||
await expect(uploadContainerFeePaymentVoucher('11', new File(['x'], '错客户.png'))).rejects.toThrow('付款凭证与当前客户不匹配')
|
||||
})
|
||||
|
||||
it('loads only ACTIVE suppliers in the requested category', async () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { listSuppliers, type SupplierCategory } from '@/modules/suppliers'
|
||||
|
||||
export type ContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
export type ContainerFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
export type ContainerFeeSettlementMode = 'ON_ACCOUNT' | 'DIRECT_PAID'
|
||||
export type ContainerFeePaymentMethod = 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
||||
|
||||
export interface ContainerFeeResource {
|
||||
id: string
|
||||
@@ -15,6 +17,11 @@ export interface ContainerFeeResource {
|
||||
supplierId: string | null
|
||||
supplierName: string | null
|
||||
supplierCategory: SupplierCategory | null
|
||||
settlementMode?: ContainerFeeSettlementMode | null
|
||||
paidAt?: string | null
|
||||
paymentMethod?: ContainerFeePaymentMethod | null
|
||||
paymentReferenceNo?: string | null
|
||||
paymentVoucherFileIds?: string[] | null
|
||||
businessDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
@@ -31,6 +38,11 @@ export interface ContainerFeeSaveRequest {
|
||||
feeItemId: string
|
||||
direction: ContainerFeeDirection
|
||||
supplierId: string | null
|
||||
settlementMode: ContainerFeeSettlementMode | null
|
||||
paidAt: string | null
|
||||
paymentMethod: ContainerFeePaymentMethod | null
|
||||
paymentReferenceNo: string | null
|
||||
paymentVoucherFileIds: string[] | null
|
||||
businessDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
@@ -98,6 +110,10 @@ function normalizeFee(raw: Record<string, unknown>): ContainerFeeResource {
|
||||
feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection,
|
||||
supplierId: raw.supplierId == null ? null : id(raw.supplierId), supplierName: raw.supplierName == null ? null : String(raw.supplierName),
|
||||
supplierCategory: supplierCategory(raw.supplierCategory),
|
||||
settlementMode: raw.settlementMode == null ? null : raw.settlementMode as ContainerFeeSettlementMode,
|
||||
paidAt: raw.paidAt == null ? null : String(raw.paidAt), paymentMethod: raw.paymentMethod == null ? null : raw.paymentMethod as ContainerFeePaymentMethod,
|
||||
paymentReferenceNo: raw.paymentReferenceNo == null ? null : String(raw.paymentReferenceNo),
|
||||
paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(id) : null,
|
||||
businessDate: String(raw.businessDate || ''), amount: numberValue(raw.amount), currency: 'CNY',
|
||||
reviewStatus: raw.reviewStatus as ContainerFeeReviewStatus,
|
||||
sourceStatus: raw.sourceStatus as ContainerFeeResource['sourceStatus'], sourceVersion: numberValue(raw.sourceVersion),
|
||||
@@ -143,6 +159,17 @@ export async function listContainerFeeSupplierOptions(category: SupplierCategory
|
||||
return page.records.filter((supplier) => supplier.status === 'ACTIVE' && (!category || supplier.category === category)).map((supplier) => ({ id: supplier.id, supplierName: supplier.name, category: supplier.category }))
|
||||
}
|
||||
|
||||
export async function uploadContainerFeePaymentVoucher(customerId: string, file: File): Promise<{ fileId: string; fileName: string }> {
|
||||
const body = new FormData(); body.append('file', file)
|
||||
const raw = unwrapApiResponse(await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>('/platform/finance/v1/vouchers', body, {
|
||||
params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}))
|
||||
const fileId = raw.fileId == null ? '' : id(raw.fileId)
|
||||
const echoedCustomerId = raw.tenantId == null ? id(raw.customerId ?? customerId) : id(raw.tenantId)
|
||||
if (!fileId || echoedCustomerId !== customerId) throw new Error('付款凭证与当前客户不匹配')
|
||||
return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) }
|
||||
}
|
||||
|
||||
export async function listContainerCostDetails(customerId: string, containerId: string): Promise<ContainerCostDetail[]> {
|
||||
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>('/platform/finance/v1/cost-entries', { params: { customerId } })
|
||||
const costs = unwrapApiResponse(response).filter((raw) => id(raw.customerId) === customerId && id(raw.containerId) === containerId)
|
||||
|
||||
@@ -6,12 +6,12 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees } from '../api/containerFees'
|
||||
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
|
||||
vi.mock('../api/containerFees', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/containerFees')>()
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
})
|
||||
|
||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||
@@ -33,6 +33,7 @@ beforeEach(() => {
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
||||
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
@@ -167,11 +168,11 @@ describe('ContainerFeesPanel contract', () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100
|
||||
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100
|
||||
await state.save()
|
||||
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' }))
|
||||
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }))
|
||||
expect(createContainerFee).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ partnerId: expect.anything() }))
|
||||
})
|
||||
|
||||
@@ -179,12 +180,12 @@ describe('ContainerFeesPanel contract', () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100
|
||||
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100
|
||||
await state.save(); await flushPromises()
|
||||
expect(listContainerFeeSupplierOptions).toHaveBeenCalledWith(null)
|
||||
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' }))
|
||||
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }))
|
||||
})
|
||||
|
||||
it('fails closed when a supplier candidate does not match the fee-item category', async () => {
|
||||
@@ -207,4 +208,28 @@ describe('ContainerFeesPanel contract', () => {
|
||||
expect(wrapper.text()).toContain('当前账号缺少费用项目查看权限')
|
||||
expect(listApplicableFeeItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears paid inputs for RECEIVABLE and ON_ACCOUNT and shows backend settlement semantics', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '已付拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-1')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState
|
||||
state.openCreate(); await flushPromises(); 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.settlementMode).toBeNull()
|
||||
})
|
||||
|
||||
it('fails closed without DIRECT_PAID evidence and submits a scoped uploaded voucher', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; 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
|
||||
state.openCreate(); await flushPromises(); Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '7', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: null, amount: 100 }); await flushPromises()
|
||||
await state.save(); expect(createContainerFee).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(uploadContainerFeePaymentVoucher).toHaveBeenCalledWith('11', expect.objectContaining({ name: '付款凭证.pdf' }))
|
||||
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ settlementMode: 'DIRECT_PAID', paymentMethod: 'ONLINE', paymentVoucherFileIds: ['9001'] }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Check, CircleX, Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
import { Check, CircleX, FileUp, Pencil, Plus, Trash2, X } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -9,7 +9,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||
|
||||
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -31,7 +31,10 @@ const rejectOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const target = ref<ContainerFeeResource | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', supplierId: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
|
||||
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
|
||||
const voucherFiles = ref<Array<{ fileId: string; fileName: string }>>([])
|
||||
const voucherUploading = ref(false)
|
||||
const voucherError = ref<ApiErrorDetails | null>(null)
|
||||
const rejection = ref('')
|
||||
let loadSequence = 0
|
||||
let supplierLoadSequence = 0
|
||||
@@ -54,6 +57,8 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 270, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
|
||||
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
|
||||
{ key: 'settlementMode', label: '结算方式', width: 120, slotName: 'settlementMode' },
|
||||
{ key: 'paidSummary', label: '已付摘要', minWidth: 290, slotName: 'paidSummary' },
|
||||
{ key: 'supplier', label: '供应商/客户', minWidth: 170, slotName: 'supplier' },
|
||||
{ key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' },
|
||||
{ key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 },
|
||||
@@ -72,8 +77,40 @@ const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费
|
||||
|
||||
function resetForm(item?: ContainerFeeResource) {
|
||||
target.value = item || null
|
||||
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, supplierId: item.supplierId, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', remark: item.remark }
|
||||
: { feeItemId: '', direction: 'RECEIVABLE', supplierId: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
|
||||
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, supplierId: item.supplierId, settlementMode: item.settlementMode ?? null, paidAt: item.paidAt ?? null, paymentMethod: item.paymentMethod ?? null, paymentReferenceNo: item.paymentReferenceNo ?? null, paymentVoucherFileIds: item.paymentVoucherFileIds ?? null, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', remark: item.remark }
|
||||
: { feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
|
||||
voucherFiles.value = (item?.paymentVoucherFileIds || []).map((fileId) => ({ fileId, fileName: `凭证 ${fileId}` }))
|
||||
voucherError.value = null
|
||||
}
|
||||
|
||||
function clearPaidFields() {
|
||||
form.paidAt = null; form.paymentMethod = null; form.paymentReferenceNo = null; form.paymentVoucherFileIds = null
|
||||
voucherFiles.value = []; voucherError.value = null
|
||||
}
|
||||
function settlementLabel(item: ContainerFeeResource) { return item.direction !== 'PAYABLE' ? '—' : item.settlementMode === 'DIRECT_PAID' ? '已付成本' : item.settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' }
|
||||
function paymentMethodLabel(value: string | null | undefined) { return ({ ONLINE: '线上支付', BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[value || ''] || '—' }
|
||||
function paidSummary(item: ContainerFeeResource) {
|
||||
if (item.settlementMode !== 'DIRECT_PAID') return '—'
|
||||
const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
|
||||
return `${item.paidAt?.replace('T', ' ').slice(0, 16) || '时间缺失'} · ${paymentMethodLabel(item.paymentMethod)} · ${evidence}`
|
||||
}
|
||||
async function selectVoucher(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || voucherUploading.value || !accountId.value) return
|
||||
if (voucherFiles.value.length >= 20) { voucherError.value = { message: '付款凭证最多上传 20 个' }; return }
|
||||
voucherUploading.value = true; voucherError.value = null
|
||||
try {
|
||||
const uploaded = await uploadContainerFeePaymentVoucher(accountId.value, file)
|
||||
if (!voucherFiles.value.some((item) => item.fileId === uploaded.fileId)) voucherFiles.value.push(uploaded)
|
||||
form.paymentVoucherFileIds = voucherFiles.value.map((item) => item.fileId)
|
||||
} catch (cause) { voucherError.value = getApiErrorDetails(cause, '付款凭证上传或校验失败') }
|
||||
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 loadFeeItems() {
|
||||
@@ -154,7 +191,15 @@ async function save() {
|
||||
if (form.direction === 'PAYABLE' && !form.supplierId) { ElMessage.warning('应付费用必须选择供应商'); return }
|
||||
const supplier = suppliers.value.find((item) => item.id === String(form.supplierId))
|
||||
if (form.direction === 'PAYABLE' && (!supplier || (selectedFeeItem.value?.supplierCategory && supplier.category !== selectedFeeItem.value.supplierCategory))) { ElMessage.warning('所选供应商与费用项目要求的供应商类别不匹配'); return }
|
||||
const payload: ContainerFeeSaveRequest = { ...form, supplierId: form.direction === 'PAYABLE' ? form.supplierId : null }
|
||||
if (form.direction === 'PAYABLE' && !form.settlementMode) { ElMessage.warning('应付费用必须选择结算方式'); return }
|
||||
if (voucherUploading.value || voucherError.value) { ElMessage.warning(voucherUploading.value ? '付款凭证正在上传,请稍候' : '付款凭证上传或校验失败,请处理后再保存'); return }
|
||||
if (form.settlementMode === 'DIRECT_PAID' && (!form.paidAt || !form.paymentMethod || (!form.paymentReferenceNo?.trim() && !voucherFiles.value.length))) { ElMessage.warning('直接已付必须填写已付时间、付款方式,并提供流水号或付款凭证'); return }
|
||||
const directPaid = form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'
|
||||
const payload: ContainerFeeSaveRequest = { ...form, 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 }
|
||||
saving.value = true
|
||||
try {
|
||||
await createContainerFee(props.planId, accountId.value, payload)
|
||||
@@ -189,9 +234,10 @@ watch(customerOptions, initializeCustomer, { immediate: true })
|
||||
watch(accountId, () => { clearCustomerContext(); void load() })
|
||||
watch(() => form.direction, (direction) => {
|
||||
if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = ''
|
||||
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>
|
||||
@@ -215,6 +261,8 @@ onMounted(() => void load())
|
||||
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
|
||||
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
|
||||
<template #supplier="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template>
|
||||
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
|
||||
</DataTable>
|
||||
@@ -229,6 +277,8 @@ onMounted(() => void load())
|
||||
<el-descriptions-item label="方向">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用项目">{{ target.feeItemName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商">{{ target.supplierName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结算方式">{{ settlementLabel(target) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已付摘要">{{ paidSummary(target) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
@@ -243,6 +293,13 @@ onMounted(() => void load())
|
||||
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :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>
|
||||
</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="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||||
@@ -266,6 +323,7 @@ onMounted(() => void load())
|
||||
.fees-panel :deep(.data-table__toolbar) { min-height: 48px; padding: 6px 12px; }
|
||||
.fees-panel :deep(.el-table th.el-table__cell) { padding: 7px 0; }.fees-panel :deep(.el-table td.el-table__cell) { padding: 6px 0; }
|
||||
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { padding-top: 16px; border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 14px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.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; }
|
||||
.adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; }
|
||||
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user