优化
This commit is contained in:
@@ -7,6 +7,7 @@ import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listFinanceAccountLedger, listFinanceAccounts } from '../api/finance'
|
||||
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
|
||||
|
||||
@@ -16,6 +17,7 @@ vi.mock('../api/finance', () => ({
|
||||
getFinanceCustomerTerms: vi.fn(),
|
||||
updateFinanceCustomerTerms: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
|
||||
@@ -62,7 +64,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
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(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [], total: 0, 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 })
|
||||
})
|
||||
|
||||
it('restores account summary and ledger after opening the detail URL directly', async () => {
|
||||
@@ -82,6 +85,25 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('formats known ledger codes, operator ids and timestamps for display', async () => {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['platform:finance:customer-account:list']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView },
|
||||
{ path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView },
|
||||
] })
|
||||
await router.push('/finance/customer-accounts/10001'); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('收款')
|
||||
expect(wrapper.text()).toContain('操作人信息缺失')
|
||||
expect(wrapper.text()).not.toContain('operator-1000001')
|
||||
expect(wrapper.text()).not.toContain('1000001')
|
||||
expect(wrapper.text()).not.toContain('2026-08-13T10:00:00')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
@@ -55,7 +55,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180 },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||||
{ key: 'customerCode', label: '客户编号', width: 150 },
|
||||
{ key: 'contactName', label: '联系人', width: 120 },
|
||||
{ key: 'contactPhone', label: '联系电话', width: 140 },
|
||||
@@ -73,9 +73,9 @@ const ledgerQueryFields: QueryField[] = [
|
||||
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
|
||||
]
|
||||
const ledgerColumns: DataTableColumn[] = [
|
||||
{ key: 'ledgerId', label: '流水号', minWidth: 160 }, { key: 'occurredAt', label: '发生时间', width: 170 }, { key: 'businessType', label: '业务类型', width: 130, slotName: 'businessType' },
|
||||
{ key: 'ledgerId', label: '流水号', minWidth: 160 }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 130, slotName: 'businessType' },
|
||||
{ key: 'documentNo', label: '关联单据', minWidth: 170 }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
|
||||
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 120 }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const summary = computed(() => rows.value.reduce((result, row) => ({
|
||||
receivable: result.receivable + row.outstandingReceivable,
|
||||
@@ -147,14 +147,36 @@ async function saveTerms() {
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
function customerName(customerId: string, snapshot?: string) {
|
||||
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
|
||||
const candidate = String(snapshot || '').trim()
|
||||
const placeholder = [String(customerId), `客户 ${customerId}`, `客户${customerId}`].includes(candidate)
|
||||
return masterName || (candidate && !placeholder ? candidate : '客户名称暂不可用')
|
||||
}
|
||||
function ledgerBusinessType(value: unknown) {
|
||||
const code = String(value || '').trim().toUpperCase()
|
||||
const known: Record<string, string> = { REC: '收款', RECEIPT: '收款', PAYMENT: '付款', COST: '成本', BILL: '对账单', RECONCILIATION: '对账单', SETTLEMENT: '结算', FEE_SOURCE: '费用来源', ADJUSTMENT: '账务调整' }
|
||||
return known[code] || financeBusinessTypeLabel(code)
|
||||
}
|
||||
function displayOperator(value: unknown) {
|
||||
const operator = String(value || '').trim()
|
||||
if (!operator) return '系统'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '操作人信息缺失' : operator
|
||||
}
|
||||
function formatDateTime(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return '时间暂不可用'
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||||
function downloadCsv(fileName: string, headers: string[], data: unknown[][]) {
|
||||
const content = `\uFEFF${[headers, ...data].map((line) => line.map(csvCell).join(',')).join('\r\n')}`
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }))
|
||||
const link = document.createElement('a'); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url)
|
||||
}
|
||||
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [row.customerName, row.customerCode, row.contactName, row.contactPhone, row.outstandingReceivable, row.pendingBilling, row.receivedInPeriod, row.outstandingPayable, row.status, row.lastChangedAt])) }
|
||||
function exportLedger() { downloadCsv(`${selected.value?.customerName || '客户'}-账户流水.csv`, ledgerColumns.map((item) => String(item.label)), ledger.value.map((row) => [row.ledgerId, row.occurredAt, financeBusinessTypeLabel(row.businessType), row.documentNo, row.receivableChange, row.payableChange, row.outstandingReceivableAfter, row.operatorName, row.remark])) }
|
||||
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [customerName(row.customerId, row.customerName), row.customerCode, row.contactName, row.contactPhone, row.outstandingReceivable, row.pendingBilling, row.receivedInPeriod, row.outstandingPayable, row.status, row.lastChangedAt])) }
|
||||
function exportLedger() { downloadCsv(`${selected.value ? customerName(selected.value.customerId, selected.value.customerName) : '客户'}-账户流水.csv`, ledgerColumns.map((item) => String(item.label)), ledger.value.map((row) => [row.ledgerId, formatDateTime(row.occurredAt), ledgerBusinessType(row.businessType), row.documentNo, row.receivableChange, row.payableChange, row.outstandingReceivableAfter, displayOperator(row.operatorName), row.remark])) }
|
||||
async function closeDetail() {
|
||||
detailOpen.value = false; selected.value = null; editing.value = false; ledger.value = []
|
||||
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
|
||||
@@ -174,7 +196,7 @@ async function restoreAccountDetail(customerId: string) {
|
||||
}
|
||||
onMounted(() => {
|
||||
const customerId = String(route.params.customerId || '')
|
||||
if (route.name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(customerId)
|
||||
if (route.name === 'finance-customer-account-detail' && customerId) { if (canListCustomers.value) void loadCustomers(); void restoreAccountDetail(customerId) }
|
||||
else { if (canListCustomers.value) void loadCustomers(); void load() }
|
||||
})
|
||||
watch(() => [route.name, String(route.params.customerId || '')], ([name, customerId], previous) => {
|
||||
@@ -194,17 +216,18 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button :disabled="loading || !rows.length" @click="exportAccounts"><Download :size="16" />导出</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</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>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AdminPage v-else as="div" class="finance-account-detail" spacing="normal">
|
||||
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><div><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ selected.customerName }} · {{ selected.customerCode }}</p></div></div>
|
||||
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><div><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · {{ selected.customerCode }}</p></div></div>
|
||||
<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">
|
||||
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
|
||||
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><QueryToolbar v-model="ledgerFilters" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" /><div class="detail-toolbar"><span>共 {{ ledgerTotal }} 条</span><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></div><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #businessType="{ row }">{{ financeBusinessTypeLabel(row.businessType) }}</template></DataTable></FormSection>
|
||||
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
|
||||
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><QueryToolbar v-model="ledgerFilters" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" /><div class="detail-toolbar"><span>共 {{ ledgerTotal }} 条</span><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></div><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></FormSection>
|
||||
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
|
||||
</template>
|
||||
<div v-if="editing" class="detail-footer"><el-button @click="closeDetail">关闭</el-button><el-button v-if="canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div>
|
||||
|
||||
@@ -50,7 +50,10 @@ async function mountView(permissions: string[], initialPath = '/finance/customer
|
||||
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
|
||||
] })
|
||||
await router.push(initialPath); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
const wrapper = mount(FinanceCustomerArapView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus, router], stubs: { LayoutPreferenceList: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
return Object.assign(wrapper, { router })
|
||||
}
|
||||
@@ -164,6 +167,23 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail')
|
||||
})
|
||||
|
||||
it('resolves blank detail customer names from master data and preserves unknown source codes', async () => {
|
||||
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName: '', sourceType: 'NEW_SOURCE' })
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
|
||||
expect(wrapper.text()).toContain('运费 · 客户A')
|
||||
expect(wrapper.text()).toContain('未识别来源(NEW_SOURCE)')
|
||||
expect(wrapper.text()).not.toContain('其他来源')
|
||||
expect(wrapper.text()).not.toContain('42 ·')
|
||||
})
|
||||
|
||||
it.each(['10001', '客户 10001', '客户10001'])('never displays customer id placeholder %s as a customer name', async (customerName) => {
|
||||
vi.mocked(listPlatformTenants).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName })
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
|
||||
expect(wrapper.text()).toContain('客户名称暂不可用')
|
||||
expect(wrapper.text()).not.toContain(customerName)
|
||||
})
|
||||
|
||||
it('creates a reconciliation draft from selected eligible receivables', async () => {
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:reconciliation:create'])
|
||||
await loadRows(wrapper)
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { FinanceFeeSourceDetail } from '../types'
|
||||
interface ArapRow {
|
||||
id: string
|
||||
feeSourceId: string | null
|
||||
customerId: string
|
||||
customerName: string
|
||||
direction: 'RECEIVABLE' | 'PAYABLE'
|
||||
documentNo: string
|
||||
@@ -88,10 +89,32 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
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: 'customerName', label: '客户', minWidth: 180 }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
|
||||
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
|
||||
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'settlementIncluded', label: '参与结算', width: 110, slotName: 'included' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 130 },
|
||||
]
|
||||
function customerName(customerId: string, snapshot?: string) {
|
||||
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
|
||||
const candidate = String(snapshot || '').trim()
|
||||
const placeholder = [String(customerId), `客户 ${customerId}`, `客户${customerId}`].includes(candidate)
|
||||
return masterName || (candidate && !placeholder ? candidate : '客户名称暂不可用')
|
||||
}
|
||||
function sourceTypeDisplay(value: unknown) {
|
||||
const code = String(value || '').trim().toUpperCase()
|
||||
const label = financeSourceTypeLabel(code)
|
||||
return label === '其他来源' ? (code ? `未识别来源(${code})` : '来源类型暂不可用') : label
|
||||
}
|
||||
function formatDateTime(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return '时间暂不可用'
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function displayOperator(value: unknown) {
|
||||
const operator = String(value || '').trim()
|
||||
if (!operator) return '系统'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '操作人信息缺失' : operator
|
||||
}
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const reviewStatus = String(filters.value.reviewStatus || '')
|
||||
const included = String(filters.value.settlementIncluded || ''); const generated = String(filters.value.statementGenerated || ''); const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||||
@@ -114,7 +137,7 @@ async function load(model: QueryModel = filters.value) {
|
||||
try {
|
||||
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
|
||||
const items = await listFinanceFeePool(customerId || undefined, direction || undefined)
|
||||
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName: item.customerName || customers.value.find((customer) => String(customer.id) === item.customerId)?.tenantName || `客户 ${item.customerId}`, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
|
||||
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -181,14 +204,14 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
|
||||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
try {
|
||||
detail.value = await getFinanceFeeSource(feeSourceId)
|
||||
selected.value = { id: detail.value.poolItemId, feeSourceId, customerName: detail.value.customerName, direction: detail.value.direction, documentNo: detail.value.poolItemId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', feeName: detail.value.feeItemName, amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
|
||||
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: detail.value.feeItemName || detail.value.sourceId || detail.value.poolItemId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', feeName: detail.value.feeItemName, amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
|
||||
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const feeSourceId = String(route.params.feeSourceId || '')
|
||||
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { await restoreFeeSourceDetail(feeSourceId); return }
|
||||
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return }
|
||||
if (canListCustomers.value) await loadCustomers()
|
||||
await load()
|
||||
})
|
||||
@@ -209,7 +232,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange">
|
||||
<template #toolbar-actions><div v-if="canCreateStatement" class="generation-actions"><el-button :disabled="!filters.customerId" @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></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 #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
|
||||
<template #included="{ row }">{{ row.settlementIncluded == null ? '待加载' : row.settlementIncluded ? '参与' : '不参与' }}</template>
|
||||
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
|
||||
@@ -217,11 +240,11 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
|
||||
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div><h2>费用来源详情</h2><p>{{ selected?.documentNo }} · {{ selected?.customerName }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div>
|
||||
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div>
|
||||
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
|
||||
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
|
||||
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
|
||||
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
|
||||
</template>
|
||||
</AdminPage>
|
||||
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
|
||||
@@ -78,10 +78,45 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(document.body.textContent).toContain('hash-81')
|
||||
expect(document.body.textContent).toContain('优惠调整')
|
||||
expect(document.body.textContent).toContain('创建人A')
|
||||
expect(document.body.textContent).toContain('2026-08-20 11:00:00')
|
||||
expect(document.body.textContent).toContain('2026-08-20 11:00')
|
||||
expect(document.querySelector('.el-drawer')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves customer names from the active customer list and never exposes internal customer or operator ids', async () => {
|
||||
const internalCustomerId = '2088123456789'
|
||||
const internalOperatorId = '1000001'
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: internalCustomerId, tenantName: '虫虫跨境科技', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
const internalStatement = {
|
||||
...statement, customerId: internalCustomerId, customerName: `客户${internalCustomerId}`,
|
||||
createdBy: internalOperatorId, createdAt: '2026-08-14T03:27:34Z', updatedBy: internalOperatorId, updatedAt: '2026-08-14T04:28:35Z',
|
||||
}
|
||||
const internalDetail = {
|
||||
...detail, ...internalStatement,
|
||||
paymentHistory: [{ ...detail.paymentHistory[0]!, createdBy: internalOperatorId, occurredAt: '2026-08-14T04:28:35Z' }],
|
||||
}
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([internalStatement])
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue(internalDetail)
|
||||
|
||||
const wrapper = await mountPage(['platform:finance:customer-statement:list', 'platform:customer:list', 'platform:finance:reconciliation:adjust'])
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(internalStatement)
|
||||
await flushPromises()
|
||||
|
||||
const text = document.body.textContent || ''
|
||||
expect(text).toContain('虫虫跨境科技')
|
||||
expect(document.querySelector('[aria-label="客户对账单列表"]')?.textContent).toContain('虫虫跨境科技')
|
||||
expect(document.querySelector('[aria-label="所选对账单联动明细"]')?.textContent).toContain('虫虫跨境科技')
|
||||
expect(text).not.toContain(`客户${internalCustomerId}`)
|
||||
expect(text).not.toContain(internalOperatorId)
|
||||
expect(text).toContain('操作人信息缺失')
|
||||
expect(text).toContain('2026-08-14 03:27')
|
||||
expect(text).not.toContain('2026-08-14T03:27:34Z')
|
||||
|
||||
await (wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void> }).openAdjustment(internalStatement)
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('调整客户对账单')
|
||||
expect(document.body.textContent).toContain('运费')
|
||||
})
|
||||
|
||||
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void>; filters: Record<string, unknown>; filteredRows: FinanceStatementReadModel[] }
|
||||
|
||||
@@ -98,7 +98,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'status', label: '审核状态', width: 120, slotName: 'status' },
|
||||
{ key: 'billNo', label: '对账单号', minWidth: 180 },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180 },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||||
{ key: 'periodStart', label: '结算起始日', width: 130 },
|
||||
{ key: 'periodEnd', label: '结算结束日', width: 130 },
|
||||
{ key: 'creditDays', label: '账期(天)', width: 100 },
|
||||
@@ -106,15 +106,39 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
|
||||
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
|
||||
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170 },
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
|
||||
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key: 'createdBy', label: '创建人', width: 110 },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170 },
|
||||
{ key: 'updatedBy', label: '更新人', width: 110 },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 170 },
|
||||
{ key: 'createdBy', label: '创建人', width: 130, slotName: 'createdBy' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||||
{ key: 'updatedBy', label: '更新人', width: 130, slotName: 'updatedBy' },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
|
||||
]
|
||||
|
||||
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName.trim()])))
|
||||
|
||||
function isInternalCustomerLabel(value: string, customerId: string) {
|
||||
const normalized = value.replaceAll(' ', '')
|
||||
return normalized === customerId || /^客户(?:ID)?\d+$/.test(normalized)
|
||||
}
|
||||
|
||||
function customerDisplayName(customerId: string, value?: string) {
|
||||
const resolved = customerNameById.value.get(String(customerId))
|
||||
if (resolved) return resolved
|
||||
const candidate = String(value || '').trim()
|
||||
return candidate && !isInternalCustomerLabel(candidate, String(customerId)) ? candidate : '客户名称未提供'
|
||||
}
|
||||
|
||||
function operatorDisplayName(value?: string) {
|
||||
const candidate = String(value || '').trim()
|
||||
if (!candidate) return '系统'
|
||||
return /^\d+$/.test(candidate) ? '操作人信息缺失' : candidate
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconciliationDetail): StatementRow {
|
||||
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || ''
|
||||
return {
|
||||
@@ -277,7 +301,7 @@ async function exportStatements() {
|
||||
return
|
||||
}
|
||||
const header = ['审核状态', '对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额', '已收金额', '未收金额', '最后收款时间', '是否结清', '备注', '创建人', '创建时间', '更新人', '更新时间']
|
||||
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, row.customerName, row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, row.lastPaymentAt, row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, row.createdBy || '', row.createdAt || '', row.updatedBy || '', row.updatedAt || ''])
|
||||
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, customerDisplayName(row.customerId, row.customerName), row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, formatDateTime(row.lastPaymentAt), row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, operatorDisplayName(row.createdBy), formatDateTime(row.createdAt), operatorDisplayName(row.updatedBy), formatDateTime(row.updatedAt)])
|
||||
const blob = new Blob([`\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
@@ -383,14 +407,20 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" link type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
</template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
|
||||
<template #lastPaymentAt="{ row }">{{ formatDateTime(row.lastPaymentAt) }}</template>
|
||||
<template #settled="{ row }"><el-tag v-if="row.settled !== null" :type="row.settled ? 'success' : 'warning'">{{ row.settled ? '是' : '否' }}</el-tag><span v-else>—</span></template>
|
||||
<template #createdBy="{ row }">{{ operatorDisplayName(row.createdBy) }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
<template #updatedBy="{ row }">{{ operatorDisplayName(row.updatedBy) }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
|
||||
<header class="statement-detail-header">
|
||||
<div><strong>已选择 {{ selectedId ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
|
||||
<div><strong>已选择 {{ selectedId ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ customerDisplayName(detail.customerId, detail.customerName) }}</span></div>
|
||||
<el-button v-if="selectedId" link @click="clearSelection">清空</el-button>
|
||||
</header>
|
||||
<el-tabs v-model="activeDetailTab">
|
||||
@@ -400,7 +430,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<template v-else-if="detail">
|
||||
<el-table :data="detail.lines" size="small" border stripe empty-text="该对账单暂无账单明细">
|
||||
<el-table-column label="应收/应付" width="110"><template #default>应收</template></el-table-column>
|
||||
<el-table-column label="客户" min-width="170"><template #default>{{ detail.customerName }}</template></el-table-column>
|
||||
<el-table-column label="客户" min-width="170"><template #default>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template></el-table-column>
|
||||
<el-table-column label="入仓号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template></el-table-column>
|
||||
<el-table-column label="柜次号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template></el-table-column>
|
||||
<el-table-column label="业务发生日" width="130"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template></el-table-column>
|
||||
@@ -418,18 +448,19 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<el-table-column label="结算起始日" width="130"><template #default>{{ detail.periodStart || '—' }}</template></el-table-column>
|
||||
<el-table-column label="结算结束日" width="130"><template #default>{{ detail.periodEnd || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column prop="occurredAt" label="发生时间" min-width="170" /></el-table></el-collapse-item></el-collapse>
|
||||
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
|
||||
</template>
|
||||
<el-empty v-else description="请在主表中选择一张对账单" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="账单支付历史表" name="payments">
|
||||
<el-table v-if="detail" :data="detail.paymentHistory" size="small" border stripe empty-text="该对账单暂无有效收款记录">
|
||||
<el-table-column prop="transactionDate" label="支付日期" min-width="130" />
|
||||
<el-table-column label="客户" min-width="170"><template #default>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template></el-table-column>
|
||||
<el-table-column label="支付日期" min-width="130"><template #default="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template></el-table-column>
|
||||
<el-table-column prop="amount" label="支付金额(CNY)" min-width="150" />
|
||||
<el-table-column label="支付方式" min-width="130"><template #default="{ row }">{{ paymentMethodLabel(row.method) }}</template></el-table-column>
|
||||
<el-table-column prop="referenceNo" label="支付流水号" min-width="170" />
|
||||
<el-table-column prop="createdBy" label="登记人" min-width="130" />
|
||||
<el-table-column prop="occurredAt" label="登记时间" min-width="170" />
|
||||
<el-table-column label="登记人" min-width="130"><template #default="{ row }">{{ operatorDisplayName(row.createdBy) }}</template></el-table-column>
|
||||
<el-table-column label="登记时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180" />
|
||||
</el-table>
|
||||
<el-empty v-else description="请在主表中选择一张对账单" />
|
||||
|
||||
@@ -63,6 +63,9 @@ const paymentForm = ref({ transactionDate: new Date().toISOString().slice(0, 10)
|
||||
const receiptOpen = ref(false)
|
||||
const receiptBusy = ref(false)
|
||||
const receiptFile = ref<File | null>(null)
|
||||
const receiptUploadKey = ref(0)
|
||||
const receiptValidationError = ref<string | null>(null)
|
||||
const receiptError = ref<ApiErrorDetails | null>(null)
|
||||
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
|
||||
const actionBusy = ref(false)
|
||||
|
||||
@@ -134,7 +137,7 @@ function displayDate(value?: string) { return value || '—' }
|
||||
function rowTotal(value: unknown) { return totalAmount(value as SettlementRow) }
|
||||
function rowPaid(value: unknown) { return paidAmount(value as SettlementRow) }
|
||||
function rowOutstanding(value: unknown) { return Number((value as SettlementRow).outstandingAmount || 0) }
|
||||
function customerName(customerId: string, snapshot?: string) { return snapshot || customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || `客户 ${customerId}` }
|
||||
function customerName(customerId: string, snapshot?: string) { return snapshot || customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '—' }
|
||||
function totalAmount(row: SettlementRow) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
|
||||
function paidAmount(row: SettlementRow) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivedAmount : (row as FinancePayableSettlement).paidAmount }
|
||||
function selectedSupplierName() { return selectedRow.value && 'supplierName' in selectedRow.value ? selectedRow.value.supplierName || '—' : '—' }
|
||||
@@ -172,10 +175,9 @@ async function load(model: QueryModel = filters.value) {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function openDetail(row: unknown) {
|
||||
function openDetail(row: unknown) {
|
||||
selectedRow.value = row as SettlementRow
|
||||
emit('openDetail', selectedRow.value.id)
|
||||
await loadDetail(selectedRow.value.id)
|
||||
}
|
||||
|
||||
async function loadDetail(settlementId: string) {
|
||||
@@ -243,6 +245,8 @@ function selectPaymentFile(file: UploadFile) { paymentFile.value = file.raw ?? n
|
||||
async function openReceipt(row: unknown) {
|
||||
const settlement = row as FinanceReceivableSettlement
|
||||
selectedRow.value = settlement
|
||||
receiptError.value = null
|
||||
receiptValidationError.value = null
|
||||
receiptBusy.value = true
|
||||
try {
|
||||
const current = await getFinanceSettlement(settlement.id)
|
||||
@@ -251,7 +255,7 @@ async function openReceipt(row: unknown) {
|
||||
receiptFile.value = null
|
||||
receiptForm.value = { receivedDate: new Date().toISOString().slice(0, 10), amount: current.outstandingAmount, paymentMethod: 'BANK_TRANSFER', remark: '' }
|
||||
receiptOpen.value = true
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '应收结算单加载失败,无法登记收款') }
|
||||
} catch (cause) { receiptError.value = getApiErrorDetails(cause, '应收结算单加载失败,无法登记收款') }
|
||||
finally { receiptBusy.value = false }
|
||||
}
|
||||
|
||||
@@ -260,11 +264,26 @@ async function openReceiptFromDetail() {
|
||||
await openReceipt({ id: detail.value.id, customerId: detail.value.customerId, settlementNo: detail.value.settlementNo, receivableAmount: detail.value.amount, receivedAmount: detail.value.paidAmount, outstandingAmount: detail.value.outstandingAmount, currency: 'CNY', status: detail.value.status, version: detail.value.version })
|
||||
}
|
||||
|
||||
function selectReceiptFile(file: UploadFile) { receiptFile.value = file.raw ?? null }
|
||||
function selectReceiptFile(file: UploadFile) {
|
||||
const raw = file.raw
|
||||
if (!raw || !raw.type.startsWith('image/')) {
|
||||
receiptFile.value = null
|
||||
receiptValidationError.value = '收款凭证仅支持图片文件'
|
||||
receiptUploadKey.value += 1
|
||||
return
|
||||
}
|
||||
receiptFile.value = raw
|
||||
receiptValidationError.value = null
|
||||
receiptError.value = null
|
||||
}
|
||||
|
||||
async function submitReceipt() {
|
||||
const current = detail.value
|
||||
if (!current?.reconciliationStatementId || !receiptFile.value || receiptForm.value.amount <= 0) return ElMessage.warning('请填写收款金额并选择收款凭证')
|
||||
if (!current?.reconciliationStatementId || !receiptFile.value || receiptForm.value.amount <= 0) {
|
||||
receiptValidationError.value = '请填写收款金额并选择收款凭证'
|
||||
return
|
||||
}
|
||||
receiptError.value = null
|
||||
receiptBusy.value = true
|
||||
try {
|
||||
const voucher = await uploadFinanceVoucher(current.customerId, receiptFile.value)
|
||||
@@ -273,7 +292,7 @@ async function submitReceipt() {
|
||||
ElMessage.success('收款已登记')
|
||||
if (detailMode.value) await loadDetail(current.id)
|
||||
else await load()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '收款登记失败') }
|
||||
} catch (cause) { receiptError.value = getApiErrorDetails(cause, '收款登记失败') }
|
||||
finally { receiptBusy.value = false }
|
||||
}
|
||||
|
||||
@@ -478,7 +497,10 @@ onMounted(() => {
|
||||
|
||||
<AppDialog v-model="receiptOpen" title="登记收款" width="min(560px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
|
||||
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>客户公司</span><strong>{{ detailPartyName() }}</strong></div><div><span>应收金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已收金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
|
||||
<el-form label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><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-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
|
||||
<el-alert v-if="receiptValidationError" type="error" :title="receiptValidationError" :closable="false" show-icon class="operation-error" />
|
||||
<el-alert v-if="receiptError" type="error" :title="receiptError.message" :closable="false" show-icon class="operation-error" />
|
||||
<ApiErrorMeta v-if="receiptError" :details="receiptError" class="operation-error-meta" />
|
||||
<el-form label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><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-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
|
||||
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
@@ -510,6 +532,8 @@ onMounted(() => {
|
||||
.partial-summary strong.is-danger { color: #c2413a; }
|
||||
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
|
||||
.remaining-preview strong { color: #b45309; font-size: 16px; }
|
||||
.operation-error { margin-bottom: 12px; }
|
||||
.operation-error-meta { margin: -6px 0 12px; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
@media (max-width: 720px) {
|
||||
.settlement-summary, .settlement-detail-summary, .partial-summary { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import {
|
||||
actionFinanceSettlement,
|
||||
actionReconciliationStatement,
|
||||
@@ -143,6 +144,60 @@ describe('finance G3/G4 page operations', () => {
|
||||
}, ['9001'])
|
||||
})
|
||||
|
||||
it('keeps receipt errors in the modal and rejects non-image vouchers before upload', async () => {
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
openReceipt(row: Record<string, unknown>): Promise<void>
|
||||
selectReceiptFile(file: { raw: File }): void
|
||||
submitReceipt(): Promise<void>
|
||||
receiptFile: File | null
|
||||
receiptOpen: boolean
|
||||
receiptValidationError: string | null
|
||||
receiptError: { message: string } | null
|
||||
}
|
||||
|
||||
vm.selectReceiptFile({ raw: new File(['not an image'], 'receipt.pdf', { type: 'application/pdf' }) })
|
||||
expect(vm.receiptFile).toBeNull()
|
||||
expect(vm.receiptValidationError).toBe('收款凭证仅支持图片文件')
|
||||
|
||||
await vm.openReceipt({ id: '701' })
|
||||
vm.selectReceiptFile({ raw: new File(['receipt'], 'receipt.png', { type: 'image/png' }) })
|
||||
vi.mocked(uploadFinanceVoucher).mockRejectedValueOnce(new ApiResponseError('FIN_400: 凭证校验失败', 'FIN_400', 400, undefined, 'request-1', 'trace-1'))
|
||||
await vm.submitReceipt()
|
||||
|
||||
expect(vm.receiptOpen).toBe(true)
|
||||
expect(vm.receiptError?.message).toContain('FIN_400')
|
||||
expect(wrapper.text()).toContain('FIN_400')
|
||||
expect(wrapper.find('[aria-label="结算单应收列表"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits payable detail navigation and displays customer names without internal IDs', async () => {
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ap:list'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'payable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): void; customerName(customerId: string, snapshot?: string): string }
|
||||
|
||||
vi.mocked(getFinanceSettlement).mockClear()
|
||||
vm.openDetail({ id: '801' })
|
||||
|
||||
expect(wrapper.emitted('openDetail')).toEqual([['801']])
|
||||
expect(getFinanceSettlement).not.toHaveBeenCalled()
|
||||
expect(vm.customerName('2088', '上海客户有限公司')).toBe('上海客户有限公司')
|
||||
expect(vm.customerName('2088')).toBe('—')
|
||||
})
|
||||
|
||||
it('keeps settled settlements readable and exportable while hiding receipt actions', async () => {
|
||||
vi.mocked(getFinanceSettlement).mockResolvedValue({ ...receivable, status: 'PAID', paidAmount: 300, outstandingAmount: 0 })
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable', settlementId: '701' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('导出')
|
||||
expect(wrapper.text()).not.toContain('部分收款')
|
||||
})
|
||||
|
||||
it('submits settlement void and reopen actions with the current version and reason', async () => {
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '账务冲销' } as never)
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement:void'])
|
||||
|
||||
Reference in New Issue
Block a user