Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev

This commit is contained in:
2026-08-20 18:23:39 +08:00
18 changed files with 545 additions and 89 deletions
+3 -3
View File
@@ -21,7 +21,7 @@ describe('finance api', () => {
})
it('uses only the formal settlement customer summary and keeps money as decimal strings', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: '9007199254740993', currency: 'CNY', totalReceivable: '123456.780000', receivedAmount: '100000.000000', outstandingReceivable: '23456.780000', pendingBilling: '0.000000', outstandingPayable: '0.000000', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null } })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: '9007199254740993', currency: 'CNY', totalReceivable: '123456.780000', receivedAmount: '100000.000000', outstandingReceivable: '23456.780000', pendingBilling: '0.000000', totalCost: '300.000000', paidCost: '200.000000', outstandingPayable: '100.000000', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null } })
const result = await getFinanceCustomerAccountSummary('9007199254740993')
@@ -57,12 +57,12 @@ describe('finance api', () => {
})
it('uses the formal G1 account list path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
const result = await listFinanceAccounts({ page: 2, size: 20, customerId: '10001', status: 'ACTIVE' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts', { params: { page: 2, pageSize: 20, customerId: '10001', status: 'ACTIVE' } })
expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], total: 21, pages: 2 })
expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], total: 21, pages: 2 })
})
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
+4 -1
View File
@@ -9,6 +9,8 @@ export interface FinanceCustomerAccountSummaryResource {
receivedAmount: number | string
outstandingReceivable: number | string
pendingBilling: number | string
totalCost: number | string
paidCost: number | string
outstandingPayable: number | string
asOf: string
lastChangedAt: string | null
@@ -103,6 +105,7 @@ export async function getFinanceCustomerAccountSummary(customerId: string): Prom
customerId: stringValue(item.customerId), currency: 'CNY',
totalReceivable: stringValue(item.totalReceivable), receivedAmount: stringValue(item.receivedAmount),
outstandingReceivable: stringValue(item.outstandingReceivable), pendingBilling: stringValue(item.pendingBilling),
totalCost: stringValue(item.totalCost), paidCost: stringValue(item.paidCost),
outstandingPayable: stringValue(item.outstandingPayable), asOf: stringValue(item.asOf),
lastChangedAt: item.lastChangedAt == null ? null : stringValue(item.lastChangedAt),
}
@@ -121,7 +124,7 @@ export async function listFinanceAccounts(params: { page?: number; size?: number
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined, status: params.status || undefined }
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts`, { params: query })
const data = unwrapApiResponse(response)
const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), totalCost: numberValue(item.totalCost), paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
+1 -1
View File
@@ -82,7 +82,7 @@ describe('finance six-page delivery contract', () => {
const columns = page.slice(page.indexOf('const columns:'))
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'direction'"))
expect(page).toContain('<ApiErrorMeta :details="detailError"')
expect(page).toContain('<OperationErrorToast :details="reviewError || mutationError || generationError" />')
expect(page).toContain('<OperationErrorToast :details="reviewError || mutationError" />')
expect(page).not.toContain('<ApiErrorMeta :details="reviewError"')
expect(page).not.toContain('<ApiErrorMeta :details="mutationError"')
expect(page).not.toContain('接口尚未提供')
@@ -126,7 +126,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.stubGlobal('sessionStorage', memoryStorage())
vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 })
vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, totalCost: 90, paidCost: 50, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 })
vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [{ ledgerId: '1', occurredAt: '2026-08-13T10:00:00', businessType: 'REC', documentType: 'RECEIPT', documentId: '2', documentNo: 'REC-2', receivableChange: -10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '收款' }], total: 1, pages: 1 })
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-1', taskNo: 'EXP-1', taskName: '客户账户', status: 'PENDING' } as never)
@@ -155,7 +155,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
})
it('shows explicit master-data empty states without exposing the customer id', async () => {
vi.mocked(listFinanceAccounts).mockResolvedValue({ records: [{ customerId: '10001', customerCode: '', customerName: '客户A', contactName: '', contactPhone: '', outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, outstandingPayable: 0, status: 'ACTIVE', lastChangedAt: '' }], total: 1, pages: 1 })
vi.mocked(listFinanceAccounts).mockResolvedValue({ records: [{ customerId: '10001', customerCode: '', customerName: '客户A', contactName: '', contactPhone: '', outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0, status: 'ACTIVE', lastChangedAt: '' }], total: 1, pages: 1 })
const wrapper = await mountAccountsView(['platform:finance:customer-account:list'])
expect(wrapper.text()).toContain('客户编号')
expect(wrapper.text()).toContain('联系人')
@@ -68,6 +68,8 @@ const columns: DataTableColumn[] = [
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
{ key: 'totalCost', label: '总成本(CNY)', width: 140 },
{ key: 'paidCost', label: '已付成本(CNY)', width: 150 },
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
@@ -89,8 +91,10 @@ const summary = computed(() => rows.value.reduce((result, row) => ({
receivable: result.receivable + row.outstandingReceivable,
pending: result.pending + row.pendingBilling,
received: result.received + row.receivedInPeriod,
totalCost: result.totalCost + row.totalCost,
paidCost: result.paidCost + row.paidCost,
payable: result.payable + row.outstandingPayable,
}), { receivable: 0, pending: 0, received: 0, payable: 0 }))
}), { receivable: 0, pending: 0, received: 0, totalCost: 0, paidCost: 0, payable: 0 }))
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
@@ -263,7 +267,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 总成本 {{ money(summary.totalCost) }} · 已付成本 {{ money(summary.paidCost) }} · 待付 {{ money(summary.payable) }} CNY</span></template>
</DataTable>
</section>
</ListPageLayout>
@@ -271,7 +275,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<section class="surface-panel finance-account-detail__surface">
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · 客户财务往来总览</p></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户汇总"><div class="account-metric is-receivable"><span>待收金额</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-pending"><span>待开账金额</span><strong>{{ money(selected.pendingBilling) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>本期已收</span><strong>{{ money(selected.receivedInPeriod) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付金额</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div></section></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户汇总"><div class="account-metric is-receivable"><span>待收金额</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-pending"><span>待开账金额</span><strong>{{ money(selected.pendingBilling) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>本期已收</span><strong>{{ money(selected.receivedInPeriod) }}</strong><small>CNY</small></div><div class="account-metric"><span>总成本</span><strong>{{ money(selected.totalCost) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>已付成本</span><strong>{{ money(selected.paidCost) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付金额</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div></section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section" title="客户信息" :bordered="false"><el-descriptions :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
@@ -377,7 +381,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
.account-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
padding: 0 0 12px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
@@ -11,7 +11,7 @@ import { createPlatformExportTask } from '@/modules/downloads'
import { ApiResponseError } from '@/shared/api/http'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import type { FinanceFeeSourceDetail, FinanceFeeSourceSummary } from '../types'
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
@@ -23,7 +23,6 @@ vi.mock('../api/finance', () => ({
getFinanceFeeSource: vi.fn(),
reviewFinanceFeeSource: vi.fn(),
editFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(),
createReconciliationStatement: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
@@ -62,6 +61,7 @@ async function mountView(permissions: string[], initialPath = '/finance/customer
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: { template: '<div>客户对账单</div>' } },
] })
await router.push(initialPath); await router.isReady()
const wrapper = mount(FinanceCustomerArapView, {
@@ -96,7 +96,6 @@ describe('FinanceCustomerArapView G2 operations', () => {
vi.mocked(submitFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'PENDING', operationVersion: 4 })
vi.mocked(adjustFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'DRAFT', operationVersion: 4 })
vi.mocked(voidFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, sourceStatus: 'VOIDED', status: 'VOIDED', operationVersion: 4 })
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({} as never)
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-3', taskNo: 'EXP-3', taskName: '客户应收应付费用来源', status: 'PENDING' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
@@ -254,18 +253,19 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.text()).not.toContain(customerName)
})
it('creates a reconciliation draft from selected eligible receivables', async () => {
it('continues selected eligible receivables in the customer statement generation center', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:reconciliation:create'])
await loadRows(wrapper)
const vm = wrapper.vm as unknown as {
selectedRows: Array<Record<string, unknown>>
generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string }
submitGeneration(): Promise<void>
continueSelectedGeneration(): Promise<void>
}
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', status: 'AVAILABLE', statementGenerated: false, amount: 100 }]
Object.assign(vm.generationForm, { mode: 'SELECTED', customerId: '10001', period: ['2026-08-01', '2026-08-31'], remark: '八月费用' })
await vm.submitGeneration()
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', status: 'AVAILABLE', statementGenerated: false, amount: 100, businessDate: '2026-08-11' }]
await vm.continueSelectedGeneration()
expect(wrapper.router.currentRoute.value).toMatchObject({
name: 'finance-customer-statements',
query: { createMode: 'SELECTED', customerId: '10001', feeSourceIds: '42', periodStart: '2026-08-11', periodEnd: '2026-08-11' },
})
})
it('creates a fee-source export task with the complete active filters and typed booleans', async () => {
@@ -18,7 +18,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
@@ -72,10 +72,6 @@ const mutationError = ref<ApiErrorDetails | null>(null)
const mutationMode = ref<'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID'>('EDIT')
const mutationForm = reactive({ amount: 0, reason: '' })
const mutationTitle = computed(() => ({ EDIT: '编辑费用', ADJUST: '调整费用', SUBMIT: '提交审核', VOID: '作废费用' } as const)[mutationMode.value])
const generationOpen = ref(false)
const generationSaving = ref(false)
const generationError = ref<ApiErrorDetails | null>(null)
const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; cutoffDate: string; remark: string }>({ mode: 'SELECTED', customerId: '', period: [], cutoffDate: '', remark: '' })
const selectedRows = ref<ArapRow[]>([])
const page = ref(1)
const pageSize = ref(20)
@@ -101,7 +97,7 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: (row: ArapRow) => row.direction === 'RECEIVABLE' && Boolean(row.feeSourceId) }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
@@ -247,22 +243,31 @@ async function submitMutation() {
function statusLabel(status: string) { return financeStatusLabel(status) }
function money(value: unknown) { return Number(value || 0).toFixed(2) }
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') {
generationForm.mode = mode
generationForm.customerId = ''
generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
generationForm.cutoffDate = generationForm.period[1] || ''
generationForm.remark = ''
generationError.value = null
generationOpen.value = true
function isSelectableReceivable(row: ArapRow) {
return Boolean(row.feeSourceId)
&& row.direction === 'RECEIVABLE'
&& row.reviewStatus === 'APPROVED'
&& row.status === 'AVAILABLE'
&& row.amount > 0
&& row.statementGenerated === false
}
async function submitGeneration() {
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && row.amount > 0 && row.statementGenerated === false && row.customerId === generationForm.customerId)
const period = generationForm.period
if (!generationForm.customerId || !period[0] || !period[1]) { ElMessage.warning('请选择客户和业务期间'); return }
if (generationForm.mode === 'SELECTED' && !eligible.length) { ElMessage.warning('请选择审核通过、有效且未出账的应收费用'); return }
generationSaving.value = true; generationError.value = null
try { await createReconciliationStatement({ customerId: generationForm.customerId, currency: 'CNY', generationMode: generationForm.mode, feeSourceIds: generationForm.mode === 'SELECTED' ? eligible.map((row) => row.feeSourceId as string) : undefined, periodStart: period[0], periodEnd: period[1], remark: generationForm.remark.trim() }); generationOpen.value = false; selectedRows.value = []; ElMessage.success('对账单草稿已生成'); await load() } catch (error) { generationError.value = getApiErrorDetails(error, '对账单生成失败,请重试') } finally { generationSaving.value = false }
async function continueSelectedGeneration() {
if (!selectedRows.value.length) return ElMessage.warning('请先勾选需要生成对账单的应收费用')
if (selectedRows.value.some((row) => !isSelectableReceivable(row))) return ElMessage.warning('所选费用中存在不可生成对账单的记录,请刷新后重新选择')
const customerIds = [...new Set(selectedRows.value.map((row) => row.customerId))]
if (customerIds.length !== 1) return ElMessage.warning('一次只能选择同一客户的费用生成对账单')
const businessDates = selectedRows.value.map((row) => row.businessDate).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value)).sort()
if (businessDates.length !== selectedRows.value.length) return ElMessage.warning('所选费用缺少有效业务日期,暂不能生成对账单')
await router.push({
name: 'finance-customer-statements',
query: {
createMode: 'SELECTED',
customerId: customerIds[0],
feeSourceIds: selectedRows.value.map((row) => row.feeSourceId as string).join(','),
periodStart: businessDates[0],
periodEnd: businessDates.at(-1),
},
})
}
async function exportFeeSources() {
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
@@ -315,14 +320,14 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template>
<AdminPage as="div" class="finance-arap-page" spacing="normal">
<OperationErrorToast :details="reviewError || mutationError || generationError" />
<OperationErrorToast :details="reviewError || mutationError" />
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><template v-if="canCreateStatement"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></template></div></template>
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && row.sourceSystem !== 'FIRSTLEG' && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && row.sourceSystem !== 'FIRSTLEG' && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :size="15" />作废</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
@@ -347,9 +352,6 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<AppDialog v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
<el-form label-position="top"><el-form-item v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'" label="金额(CNY)"><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" style="width:100%" /></el-form-item><el-form-item label="操作原因"><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="generationOpen" :title="generationForm.mode === 'BY_CUTOFF' ? '按截止日期生成对账单' : '按勾选生成对账单'" confirm-text="生成草稿" :confirm-loading="generationSaving" @confirm="submitGeneration">
<el-form label-position="top"><el-form-item label="客户"><el-select v-model="generationForm.customerId" filterable placeholder="请选择客户" style="width:100%"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item><el-form-item label="业务期间"><el-date-picker v-model="generationForm.period" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="截止日期" style="width:100%" /></el-form-item><el-form-item v-if="generationForm.mode === 'SELECTED'" label="已选费用"><span>{{ selectedRows.length }} 笔,合计 {{ money(selectedAmount) }} CNY</span></el-form-item><el-form-item label="备注"><el-input v-model="generationForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
@@ -10,8 +10,8 @@ import { useAuthStore } from '@/core/auth/auth'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
@@ -19,7 +19,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
getReconciliationStatement: vi.fn(), listFinanceFeeSources: vi.fn(), listFinanceStatements: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -44,6 +44,13 @@ const detail: FinanceReconciliationDetail = {
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: ['9001'], createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }],
}
const eligibleFeeSource: FinanceFeeSourceSummary = {
poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IN-21',
sourceId: '81', sourceVersion: 2, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null,
supplierName: '', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: null, containerNo: '', businessDate: '2026-08-15',
feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 320, currency: 'CNY', reviewStatus: 'APPROVED',
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15',
}
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') {
const pinia = createPinia(); setActivePinia(pinia)
@@ -73,6 +80,8 @@ describe('FinanceCustomerStatementsView', () => {
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [eligibleFeeSource], total: 1, pages: 1 })
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({ ...detail, id: '502', billNo: 'REC-502', status: 'DRAFT' })
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
@@ -255,6 +264,34 @@ describe('FinanceCustomerStatementsView', () => {
expect(listPlatformTenants).not.toHaveBeenCalled()
})
it('accepts selected fee sources from customer arap and creates them in the statement page', async () => {
const permissions = [
'platform:finance:customer-statement:list',
'platform:finance:customer-arap:list',
'platform:finance:reconciliation:create',
'platform:customer:list',
]
const { wrapper, router } = await mountPage(permissions, '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15')
const vm = wrapper.vm as unknown as {
createOpen: boolean
createForm: { generationMode: string; customerId: string; feeSourceIds: string[]; periodStart: string; periodEnd: string }
createStatement(): Promise<void>
}
expect(vm.createOpen).toBe(true)
expect(vm.createForm).toMatchObject({ generationMode: 'SELECTED', customerId: '10001', feeSourceIds: ['42'], periodStart: '2026-08-15', periodEnd: '2026-08-15' })
expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({
customerId: '10001', direction: 'RECEIVABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false,
businessDateFrom: '2026-08-15', businessDateTo: '2026-08-15',
}))
await vm.createStatement()
expect(createReconciliationStatement).toHaveBeenCalledWith({
customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'],
periodStart: '2026-08-15', periodEnd: '2026-08-15', remark: undefined,
})
expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
})
it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => {
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
@@ -17,9 +17,9 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
@@ -61,14 +61,20 @@ const activeDetailTab = ref<'lines' | 'payments'>('lines')
const detailCache = new Map<string, FinanceReconciliationDetail>()
const createOpen = ref(false)
const createBusy = ref(false)
const createSourcesLoading = ref(false)
const createSourcesError = ref<ApiErrorDetails | null>(null)
const createSourceOptions = ref<FinanceFeeSourceSummary[]>([])
const exportBusy = ref(false)
const actionBusy = ref(false)
const createForm = ref({
generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
customerId: '',
periodStart: new Date().toISOString().slice(0, 8) + '01',
periodEnd: new Date().toISOString().slice(0, 10),
feeSourceIds: [] as string[],
remark: '',
})
let createSourceLoadSequence = 0
const adjustOpen = ref(false)
const adjustBusy = ref(false)
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
@@ -78,6 +84,7 @@ const adjustLines = ref<AdjustmentRow[]>([])
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canViewFeeSource = computed(() => access.can('platform:finance:customer-statement:list'))
const canSelectSources = computed(() => access.can('platform:finance:customer-arap:list'))
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
@@ -86,6 +93,8 @@ const canAdjust = computed(() => access.can('platform:finance:reconciliation:adj
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
const selectedCreateSources = computed(() => createSourceOptions.value.filter((item) => createForm.value.feeSourceIds.includes(item.feeSourceId)))
const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((total, item) => total + Number(item.amount || 0), 0))
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
const fields = computed<QueryField[]>(() => [
@@ -366,22 +375,120 @@ async function exportStatements() {
}
}
function defaultCreateForm() {
return {
generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
customerId: '',
periodStart: new Date().toISOString().slice(0, 8) + '01',
periodEnd: new Date().toISOString().slice(0, 10),
feeSourceIds: [] as string[],
remark: '',
}
}
function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
createForm.value = { ...createForm.value, customerId: '' }
createForm.value = defaultCreateForm()
createSourceOptions.value = []
createSourcesError.value = null
createOpen.value = true
}
async function loadEligibleCreateSources(preferredIds: string[] = createForm.value.feeSourceIds) {
const sequence = ++createSourceLoadSequence
createSourcesError.value = null
createSourceOptions.value = []
if (createForm.value.generationMode !== 'SELECTED' || !createForm.value.customerId
|| !createForm.value.periodStart || !createForm.value.periodEnd || !canSelectSources.value) {
createForm.value.feeSourceIds = []
return
}
createSourcesLoading.value = true
try {
const records: FinanceFeeSourceSummary[] = []
let currentPage = 1
let pages = 1
do {
const result = await listFinanceFeeSources({
page: currentPage,
size: 200,
customerId: createForm.value.customerId,
direction: 'RECEIVABLE',
status: 'AVAILABLE',
reviewStatus: 'APPROVED',
statementGenerated: false,
businessDateFrom: createForm.value.periodStart,
businessDateTo: createForm.value.periodEnd,
})
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
if (sequence !== createSourceLoadSequence) return
createSourceOptions.value = records.filter((item) => item.direction === 'RECEIVABLE'
&& item.reviewStatus === 'APPROVED' && item.status === 'AVAILABLE'
&& !item.statementGenerated && item.amount > 0)
const availableIds = new Set(createSourceOptions.value.map((item) => item.feeSourceId))
createForm.value.feeSourceIds = preferredIds.filter((id) => availableIds.has(id))
if (preferredIds.length && createForm.value.feeSourceIds.length !== preferredIds.length) {
ElMessage.warning('部分带入费用已被占用或不再符合生成条件,请重新核对')
}
} catch (cause) {
if (sequence !== createSourceLoadSequence) return
createSourcesError.value = getApiErrorDetails(cause, '可生成费用加载失败')
createForm.value.feeSourceIds = []
} finally {
if (sequence === createSourceLoadSequence) createSourcesLoading.value = false
}
}
function queryText(value: unknown) {
return Array.isArray(value) ? String(value[0] || '') : String(value || '')
}
async function openSelectedCreateFromRoute() {
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
if (!canGenerate.value || !canSelectSources.value) {
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
return
}
const feeSourceIds = queryText(route.query.feeSourceIds).split(',').map((value) => value.trim()).filter((value) => /^[1-9]\d*$/.test(value))
const customerId = queryText(route.query.customerId)
const periodStart = queryText(route.query.periodStart)
const periodEnd = queryText(route.query.periodEnd)
if (!customerId || !feeSourceIds.length || !/^\d{4}-\d{2}-\d{2}$/.test(periodStart) || !/^\d{4}-\d{2}-\d{2}$/.test(periodEnd)) {
ElMessage.warning('带入的对账单生成参数不完整,请重新选择费用')
return
}
if (!activeCustomers.value.some((item) => String(item.id) === customerId)) {
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
return
}
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
await loadEligibleCreateSources(feeSourceIds)
createOpen.value = true
await router.replace({ name: 'finance-customer-statements' })
}
async function createStatement() {
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
if (createForm.value.generationMode === 'SELECTED' && !createForm.value.feeSourceIds.length) return ElMessage.warning('请选择至少一笔应收费用')
createBusy.value = true
try {
await createReconciliationStatement({ customerId: String(createForm.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
const created = await createReconciliationStatement({
customerId: String(createForm.value.customerId),
currency: 'CNY',
generationMode: createForm.value.generationMode,
feeSourceIds: createForm.value.generationMode === 'SELECTED' ? createForm.value.feeSourceIds : undefined,
periodStart: createForm.value.periodStart,
periodEnd: createForm.value.periodEnd,
remark: createForm.value.remark || undefined,
})
createOpen.value = false
ElMessage.success('对账单已生成')
await load(filters.value)
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: created.id } })
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') }
finally { createBusy.value = false }
}
@@ -431,7 +538,17 @@ async function submitAdjustment() {
finally { adjustBusy.value = false }
}
onMounted(() => { if (canListCustomers.value) void loadCustomers() })
onMounted(async () => {
if (canListCustomers.value) await loadCustomers()
await openSelectedCreateFromRoute()
})
watch(
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
([open, mode]) => {
if (!open || mode !== 'SELECTED') return
void loadEligibleCreateSources()
},
)
watch(detailRouteId, (statementId) => {
if (statementId) void loadDetail(statementId)
else {
@@ -568,7 +685,29 @@ watch(detailRouteId, (statementId) => {
</template>
</ListPageLayout>
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
<el-form label-position="top">
<el-form-item label="生成方式">
<el-radio-group v-model="createForm.generationMode">
<el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button>
<el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item>
<div class="create-period-row"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
<template v-if="createForm.generationMode === 'SELECTED'">
<el-alert v-if="!canSelectSources" type="warning" :closable="false" title="当前账号没有查看费用来源的权限,不能指定费用生成。" />
<el-alert v-else-if="createSourcesError" type="error" :closable="false"><template #title>可生成费用加载失败</template><p>{{ createSourcesError.message }}</p><ApiErrorMeta :details="createSourcesError" /><el-button size="small" @click="loadEligibleCreateSources()">重试</el-button></el-alert>
<el-form-item label="应收费用">
<el-select v-model="createForm.feeSourceIds" multiple filterable collapse-tags :max-collapse-tags="3" :loading="createSourcesLoading" placeholder="请选择审核通过、已发布且未出账的费用">
<el-option v-for="item in createSourceOptions" :key="item.feeSourceId" :label="`${item.feeItemName} · ${item.businessDate} · ${Number(item.amount).toFixed(2)} CNY`" :value="item.feeSourceId" />
</el-select>
<div class="selected-source-summary">已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY</div>
</el-form-item>
</template>
<el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<el-form label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit /></el-form-item></el-form>
@@ -580,6 +719,8 @@ watch(detailRouteId, (statementId) => {
<style scoped>
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.create-period-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.selected-source-summary { width: 100%; margin-top: 6px; color: var(--cc-text-secondary); font-size: 12px; text-align: right; }
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
.statement-detail-page :deep(.detail-page-layout) { gap: 0; }
.statement-detail-page__header { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
+2
View File
@@ -183,6 +183,8 @@ export interface FinanceAccountSummary {
outstandingReceivable: number
pendingBilling: number
receivedInPeriod: number
totalCost: number
paidCost: number
outstandingPayable: number
status: string
lastChangedAt: string
@@ -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,29 @@ 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' }); await flushPromises()
Object.assign(state.form, { 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,14 @@ 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 settlementModeModel = computed<string>({
get: () => form.settlementMode ?? '',
set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : 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 +61,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 +81,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 +195,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 +238,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 +265,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 +281,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 +297,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="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item>
<template v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'">
<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 +327,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>
@@ -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,47 @@ 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' }); await flushPromises()
Object.assign(state.form, { 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' }); await flushPromises()
Object.assign(state.form, { 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,14 +40,22 @@ 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 settlementModeModel = computed<string>({
get: () => form.settlementMode ?? '',
set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
})
const rules: FormRules = {
feeItemId: [{ required: true, message: '请选择费用项目', trigger: 'change' }],
businessDate: [{ required: true, message: '请选择业务发生日', trigger: 'change' }],
@@ -77,6 +85,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 +146,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 +163,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 +205,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 +252,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 +293,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 +320,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="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item>
<template v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'">
<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 +354,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); } }