优化
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
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 { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { listFinanceAccountLedger, listFinanceAccounts } from '../api/finance'
|
||||
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
|
||||
|
||||
@@ -18,6 +20,8 @@ vi.mock('../api/finance', () => ({
|
||||
updateFinanceCustomerTerms: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
|
||||
@@ -26,6 +30,21 @@ function memoryStorage(): Storage {
|
||||
return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) }
|
||||
}
|
||||
|
||||
async function mountAccountsView(permissions: string[], initialPath = '/finance/customer-accounts') {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
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 },
|
||||
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
|
||||
] })
|
||||
await router.push(initialPath); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return Object.assign(wrapper, { router })
|
||||
}
|
||||
|
||||
describe('FinanceCustomerAccountsView delivery', () => {
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8')
|
||||
const routes = readFileSync(resolve(process.cwd(), 'src/modules/finance/routes.ts'), 'utf8')
|
||||
@@ -92,6 +111,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
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: [{ 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)
|
||||
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('restores account summary and ledger after opening the detail URL directly', async () => {
|
||||
@@ -133,6 +154,57 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(wrapper.text()).not.toContain('2026-08-13T10:00:00')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('creates account and ledger export tasks from the complete active filters without page parameters', async () => {
|
||||
const permissions = ['platform:finance:customer-account:list', 'platform:finance:customer-account:export', 'platform:download:list']
|
||||
const listWrapper = await mountAccountsView(permissions)
|
||||
const listVm = listWrapper.vm as unknown as { filters: Record<string, unknown>; exportAccounts(): Promise<void> }
|
||||
Object.assign(listVm.filters, { customerId: '10001', status: 'ACTIVE' })
|
||||
await listVm.exportAccounts()
|
||||
expect(createPlatformExportTask).toHaveBeenLastCalledWith({
|
||||
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LIST', format: 'XLSX', taskName: '客户账户',
|
||||
parameters: { customerId: '10001', status: 'ACTIVE' },
|
||||
})
|
||||
expect(createPlatformExportTask).not.toHaveBeenCalledWith(expect.objectContaining({ parameters: expect.objectContaining({ page: expect.anything() }) }))
|
||||
listWrapper.unmount()
|
||||
|
||||
vi.mocked(createPlatformExportTask).mockResolvedValueOnce({ id: 'exp-2', taskNo: 'EXP-2', taskName: '客户账户流水', status: 'PENDING' } as never)
|
||||
const detailWrapper = await mountAccountsView(permissions, '/finance/customer-accounts/10001')
|
||||
const detailVm = detailWrapper.vm as unknown as { ledgerFilters: Record<string, unknown>; exportLedger(): Promise<void> }
|
||||
Object.assign(detailVm.ledgerFilters, { businessType: 'RECEIPT', occurredRange: ['2026-08-01', '2026-08-31'] })
|
||||
await detailVm.exportLedger()
|
||||
expect(createPlatformExportTask).toHaveBeenLastCalledWith({
|
||||
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', format: 'XLSX', taskName: '客户账户流水',
|
||||
parameters: { customerId: '10001', businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' },
|
||||
})
|
||||
expect(showExportTaskCreatedDialog).toHaveBeenLastCalledWith({ taskName: '客户账户流水', taskNo: 'EXP-2' })
|
||||
detailWrapper.unmount()
|
||||
})
|
||||
|
||||
it('requires both the finance export permission and download-center access', async () => {
|
||||
const wrapper = await mountAccountsView(['platform:finance:customer-account:list', 'platform:finance:customer-account:export'])
|
||||
expect(wrapper.findAll('button').map((button) => button.text())).not.toContain('导出Excel')
|
||||
expect(createPlatformExportTask).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens the download center after confirmation and reports task creation failures', async () => {
|
||||
const permissions = ['platform:finance:customer-account:list', 'platform:finance:customer-account:export', 'platform:download:list']
|
||||
vi.mocked(showExportTaskCreatedDialog).mockResolvedValueOnce(true)
|
||||
const wrapper = await mountAccountsView(permissions)
|
||||
await (wrapper.vm as unknown as { exportAccounts(): Promise<void> }).exportAccounts()
|
||||
expect(wrapper.router.currentRoute.value.name).toBe('download-center')
|
||||
wrapper.unmount()
|
||||
|
||||
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never)
|
||||
vi.mocked(createPlatformExportTask).mockRejectedValueOnce(new Error('导出服务不可用'))
|
||||
const failedWrapper = await mountAccountsView(permissions)
|
||||
await (failedWrapper.vm as unknown as { exportAccounts(): Promise<void> }).exportAccounts()
|
||||
expect(errorSpy).toHaveBeenCalledWith('客户账户导出任务创建失败')
|
||||
expect(showExportTaskCreatedDialog).toHaveBeenCalledTimes(1)
|
||||
failedWrapper.unmount()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Download, Eye, Pencil, Save } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -14,6 +15,7 @@ import FormSection from '@/shared/components/FormSection.vue'
|
||||
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 { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
|
||||
import { financeBusinessTypeLabel } from '../status'
|
||||
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
|
||||
@@ -44,11 +46,14 @@ const ledgerFilters = ref<QueryModel>({ businessType: '', occurredRange: [] })
|
||||
const ledgerPage = ref(1)
|
||||
const ledgerPageSize = ref(20)
|
||||
const ledgerTotal = ref(0)
|
||||
const accountExportBusy = ref(false)
|
||||
const ledgerExportBusy = ref(false)
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:customer-account:list'))
|
||||
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
||||
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
|
||||
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
|
||||
const canExport = computed(() => access.can('platform:finance:customer-account:export') && access.can('platform:download:list'))
|
||||
const stateError = computed(() => customersError.value || loadError.value)
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
@@ -173,14 +178,45 @@ function formatDateTime(value: unknown) {
|
||||
function formatLastChangedAt(value: unknown) { return String(value || '').trim() ? formatDateTime(value) : '暂无变动' }
|
||||
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
|
||||
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
|
||||
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)
|
||||
async function finishExport(task: Awaited<ReturnType<typeof createPlatformExportTask>>) {
|
||||
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
|
||||
if (openDownloadCenter) await router.push({ name: 'download-center' })
|
||||
}
|
||||
async function exportAccounts() {
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
const status = String(filters.value.status || '').trim()
|
||||
accountExportBusy.value = true
|
||||
try {
|
||||
const task = await createPlatformExportTask({
|
||||
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LIST', format: 'XLSX', taskName: '客户账户',
|
||||
parameters: {
|
||||
...(customerId ? { customerId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
})
|
||||
await finishExport(task)
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户账户导出任务创建失败')) }
|
||||
finally { accountExportBusy.value = false }
|
||||
}
|
||||
async function exportLedger() {
|
||||
if (!selected.value) return
|
||||
const range = Array.isArray(ledgerFilters.value.occurredRange) ? ledgerFilters.value.occurredRange.map(String) : []
|
||||
const businessType = String(ledgerFilters.value.businessType || '').trim()
|
||||
ledgerExportBusy.value = true
|
||||
try {
|
||||
const task = await createPlatformExportTask({
|
||||
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', format: 'XLSX', taskName: '客户账户流水',
|
||||
parameters: {
|
||||
customerId: selected.value.customerId,
|
||||
...(businessType ? { businessType } : {}),
|
||||
...(range[0] ? { startDate: range[0] } : {}),
|
||||
...(range[1] ? { endDate: range[1] } : {}),
|
||||
},
|
||||
})
|
||||
await finishExport(task)
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户账户流水导出任务创建失败')) }
|
||||
finally { ledgerExportBusy.value = false }
|
||||
}
|
||||
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [customerName(row.customerId, row.customerName), 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' })
|
||||
@@ -218,7 +254,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<template v-if="stateError" #state><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
|
||||
<section v-if="!stateError" 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-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 #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</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 #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
|
||||
@@ -236,7 +272,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<template v-else-if="selected">
|
||||
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
|
||||
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
|
||||
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||
</FormSection>
|
||||
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></div></div></FormSection>
|
||||
</template>
|
||||
|
||||
@@ -5,13 +5,17 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
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 { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
listFinanceFeePool: vi.fn(),
|
||||
getFinanceFeeSource: vi.fn(),
|
||||
@@ -80,6 +84,8 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
|
||||
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, 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)
|
||||
})
|
||||
|
||||
it('shows detail from the list permission but keeps G2 operations independent', async () => {
|
||||
@@ -231,4 +237,36 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
await vm.submitGeneration()
|
||||
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
|
||||
})
|
||||
|
||||
it('creates a fee-source export task with the complete active filters and typed booleans', async () => {
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:customer-arap:export', 'platform:download:list'])
|
||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportFeeSources(): Promise<void> }
|
||||
Object.assign(vm.filters, {
|
||||
customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: 'true',
|
||||
statementGenerated: 'false', businessRange: ['2026-08-01', '2026-08-31'], keyword: ' 运费 ',
|
||||
})
|
||||
await vm.exportFeeSources()
|
||||
expect(createPlatformExportTask).toHaveBeenCalledWith({
|
||||
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源',
|
||||
parameters: {
|
||||
customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true,
|
||||
statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: '运费',
|
||||
},
|
||||
})
|
||||
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户应收应付费用来源', taskNo: 'EXP-3' })
|
||||
})
|
||||
|
||||
it('does not expose fee-source export without download-center access', async () => {
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:customer-arap:export'])
|
||||
expect(wrapper.findAll('button').map((button) => button.text())).not.toContain('导出Excel')
|
||||
expect(createPlatformExportTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits every empty fee-source filter instead of exporting the current table page', async () => {
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:customer-arap:export', 'platform:download:list'])
|
||||
await (wrapper.vm as unknown as { exportFeeSources(): Promise<void> }).exportFeeSources()
|
||||
expect(createPlatformExportTask).toHaveBeenCalledWith({
|
||||
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源', parameters: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Eye, FileCheck2, FilePlus2, Link2 } from '@lucide/vue'
|
||||
import { ArrowLeft, Download, Eye, FileCheck2, FilePlus2, Link2 } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -15,6 +16,7 @@ import FormSection from '@/shared/components/FormSection.vue'
|
||||
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 { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
@@ -71,6 +73,7 @@ const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: st
|
||||
const selectedRows = ref<ArapRow[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const exportBusy = ref(false)
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
|
||||
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
||||
@@ -78,6 +81,7 @@ const canViewSource = computed(() => access.can('platform:finance:customer-arap:
|
||||
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
|
||||
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
|
||||
const canCreateStatement = computed(() => access.can('platform:finance:reconciliation:create'))
|
||||
const canExport = computed(() => access.can('platform:finance:customer-arap:export') && access.can('platform:download:list'))
|
||||
const stateError = computed(() => customersError.value || loadError.value)
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
@@ -226,6 +230,34 @@ async function submitGeneration() {
|
||||
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 exportFeeSources() {
|
||||
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
const direction = String(filters.value.direction || '').trim()
|
||||
const reviewStatus = String(filters.value.reviewStatus || '').trim()
|
||||
const included = String(filters.value.settlementIncluded || '')
|
||||
const generated = String(filters.value.statementGenerated || '')
|
||||
const keyword = String(filters.value.keyword || '').trim()
|
||||
exportBusy.value = true
|
||||
try {
|
||||
const task = await createPlatformExportTask({
|
||||
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源',
|
||||
parameters: {
|
||||
...(customerId ? { customerId } : {}),
|
||||
...(direction ? { direction } : {}),
|
||||
...(reviewStatus ? { reviewStatus } : {}),
|
||||
...(included ? { settlementIncluded: included === 'true' } : {}),
|
||||
...(generated ? { statementGenerated: generated === 'true' } : {}),
|
||||
...(range[0] ? { businessDateFrom: range[0] } : {}),
|
||||
...(range[1] ? { businessDateTo: range[1] } : {}),
|
||||
...(keyword ? { keyword } : {}),
|
||||
},
|
||||
})
|
||||
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
|
||||
if (openDownloadCenter) await router.push({ name: 'download-center' })
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户应收应付导出任务创建失败')) }
|
||||
finally { exportBusy.value = false }
|
||||
}
|
||||
async function closeDetail() { detailOpen.value = false; selected.value = null; detail.value = null; await router.push({ name: 'finance-customer-arap' }) }
|
||||
async function restoreFeeSourceDetail(feeSourceId: string) {
|
||||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
@@ -257,7 +289,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<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="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 @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 #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 #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 #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 #documentNo="{ row }">{{ feeDocumentLabel(row.direction, row.feeName) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #sourceId="{ row }">{{ row.sourceId }}</template>
|
||||
|
||||
@@ -5,12 +5,16 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
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(),
|
||||
getFinanceFeeSource: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
|
||||
@@ -42,6 +46,7 @@ async function mountPage(permissions = ['platform:finance:customer-statement:lis
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
|
||||
] })
|
||||
await router.push(initialPath); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
@@ -62,6 +67,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(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never)
|
||||
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
||||
vi.mocked(getFinanceFeeSource).mockResolvedValue({
|
||||
poolItemId: '81', feeSourceId: '81', customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '',
|
||||
direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceId: 'FEE-81', sourceFeeId: 'FEE-81', sourceVersion: 2,
|
||||
@@ -159,13 +166,35 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(vm.filteredRows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hydrates and exports the currently filtered real statement rows as CSV', async () => {
|
||||
const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
it('creates a formal statement export task with only non-empty filters', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportStatements(): Promise<void> }
|
||||
vm.filters = {
|
||||
status: 'ISSUED', billNo: ' REC-501 ', customerId: '10001', containerNo: ' CONT-31 ',
|
||||
periodStartRange: ['2026-08-01', '2026-08-05'], periodEndRange: [], settled: 'false',
|
||||
lastPaymentRange: ['2026-08-20', '2026-08-21'], createdRange: [],
|
||||
}
|
||||
await (wrapper.vm as unknown as { exportStatements(): Promise<void> }).exportStatements()
|
||||
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(clicked).toHaveBeenCalledOnce()
|
||||
expect(createPlatformExportTask).toHaveBeenCalledWith({
|
||||
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST', format: 'XLSX', taskName: '客户对账单',
|
||||
parameters: {
|
||||
customerId: '10001', status: 'ISSUED', billNo: 'REC-501', containerNo: 'CONT-31',
|
||||
periodStartFrom: '2026-08-01', periodStartTo: '2026-08-05', settled: false,
|
||||
lastPaymentFrom: '2026-08-20', lastPaymentTo: '2026-08-21',
|
||||
},
|
||||
})
|
||||
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' })
|
||||
expect(getReconciliationStatement).not.toHaveBeenCalledWith('501')
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires both statement export and download-center list permissions for the export action', async () => {
|
||||
await mountPage(['platform:finance:customer-statement:list', 'platform:finance:customer-statement:export'])
|
||||
expect([...document.querySelectorAll('button')].some((item) => item.textContent?.includes('导出'))).toBe(false)
|
||||
|
||||
document.body.innerHTML = ''
|
||||
await mountPage(['platform:finance:customer-statement:list', 'platform:finance:customer-statement:export', 'platform:download:list'])
|
||||
expect([...document.querySelectorAll('button')].some((item) => item.textContent?.includes('导出'))).toBe(true)
|
||||
})
|
||||
|
||||
it('disables statement generation when customer selection permission is missing', async () => {
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -15,6 +16,7 @@ import FormSection from '@/shared/components/FormSection.vue'
|
||||
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, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
||||
@@ -90,6 +92,7 @@ const canReview = computed(() => access.can('platform:finance:reconciliation:rev
|
||||
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
|
||||
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
|
||||
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 detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
|
||||
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
|
||||
@@ -318,31 +321,46 @@ function revisionActionLabel(action: string) {
|
||||
return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record<string, string>)[action] || action || '—'
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||||
async function exportStatements() {
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
|
||||
const parameters: Record<string, string | boolean> = {}
|
||||
const putText = (key: string) => {
|
||||
const value = String(filters.value[key] || '').trim()
|
||||
if (value) parameters[key] = value
|
||||
}
|
||||
const putRange = (sourceKey: string, fromKey: string, toKey: string) => {
|
||||
const value = filters.value[sourceKey]
|
||||
if (!Array.isArray(value)) return
|
||||
if (value[0]) parameters[fromKey] = String(value[0])
|
||||
if (value[1]) parameters[toKey] = String(value[1])
|
||||
}
|
||||
if (customerId) parameters.customerId = customerId
|
||||
putText('status')
|
||||
putText('billNo')
|
||||
putText('containerNo')
|
||||
putRange('periodStartRange', 'periodStartFrom', 'periodStartTo')
|
||||
putRange('periodEndRange', 'periodEndFrom', 'periodEndTo')
|
||||
putRange('lastPaymentRange', 'lastPaymentFrom', 'lastPaymentTo')
|
||||
putRange('createdRange', 'createdFrom', 'createdTo')
|
||||
const settled = String(filters.value.settled || '')
|
||||
if (settled) parameters.settled = settled === 'true'
|
||||
|
||||
exportBusy.value = true
|
||||
try {
|
||||
const hydrated = await Promise.all(rows.value.map(async (row) => {
|
||||
const current = await fetchDetail(row.id)
|
||||
return detailToRow(row, current)
|
||||
}))
|
||||
rows.value = hydrated
|
||||
const task = await createPlatformExportTask({
|
||||
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST',
|
||||
format: 'XLSX',
|
||||
taskName: '客户对账单',
|
||||
parameters,
|
||||
})
|
||||
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
|
||||
if (openDownloadCenter) await router.push({ name: 'download-center' })
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '导出前加载完整对账单字段失败')
|
||||
ElMessage.error(getApiErrorMessage(cause, '客户对账单导出任务创建失败'))
|
||||
} finally {
|
||||
exportBusy.value = false
|
||||
return
|
||||
}
|
||||
const header = ['审核状态', '对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额', '已收金额', '未收金额', '最后收款时间', '是否结清', '备注', '创建人', '创建时间', '更新人', '更新时间']
|
||||
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.createdByName), formatDateTime(row.createdAt), operatorDisplayName(row.updatedByName), 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')
|
||||
anchor.href = url
|
||||
anchor.download = `客户对账单-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
exportBusy.value = false
|
||||
ElMessage.success(`已导出 ${body.length} 条客户对账单`)
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@@ -495,7 +513,7 @@ watch(detailRouteId, (statementId) => {
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
|
||||
<template #toolbar-actions>
|
||||
<el-button :loading="exportBusy" :disabled="loading || exportBusy || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
|
||||
import type { FinanceSettlementDetail } from '../types'
|
||||
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
|
||||
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/suppliers', () => ({ categoryLabels: {}, listSuppliers: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('../api/finance')>(),
|
||||
getFinanceSettlement: vi.fn(),
|
||||
listFinancePayableSettlements: vi.fn(),
|
||||
listFinanceReceivableSettlements: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
function piniaWithPermissions(permissions: string[]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return pinia
|
||||
}
|
||||
|
||||
function testRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'settlements', component: { template: '<div />' } },
|
||||
{ path: '/downloads', name: 'download-center', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const detail: FinanceSettlementDetail = {
|
||||
id: '701', settlementNo: 'AR-701', customerId: '10001', reconciliationStatementId: '501', direction: 'RECEIVABLE',
|
||||
amount: 300, paidAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 0,
|
||||
lines: [], transactions: [], revisions: [],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('formal settlement download-center exports', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
|
||||
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue(detail)
|
||||
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '应收结算单列表', taskNo: 'EXP-001' } as never)
|
||||
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('creates an AR list task with current filters and no paging or direction parameters', async () => {
|
||||
const router = testRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement-ar:export', 'platform:download:list'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportRows(): Promise<void> }
|
||||
vm.filters = { customerId: '10001', status: 'ISSUED', settlementNo: ' AR-001 ', supplierName: '' }
|
||||
|
||||
await vm.exportRows()
|
||||
|
||||
expect(createPlatformExportTask).toHaveBeenCalledWith({
|
||||
exportType: 'FINANCE_RECEIVABLE_SETTLEMENT_LIST', format: 'XLSX', taskName: '应收结算单列表',
|
||||
parameters: { customerId: '10001', status: 'ISSUED', settlementNo: 'AR-001' },
|
||||
})
|
||||
expect(createPlatformExportTask).not.toHaveBeenCalledWith(expect.objectContaining({ parameters: expect.objectContaining({ direction: expect.anything(), page: expect.anything(), pageSize: expect.anything() }) }))
|
||||
})
|
||||
|
||||
it('creates an AP list task with the supplier filter', async () => {
|
||||
const router = testRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:export', 'platform:download:list'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'payable' }, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportRows(): Promise<void> }
|
||||
vm.filters = { customerId: '10002', status: 'PARTIALLY_PAID', settlementNo: 'AP-002', supplierName: '承运供应商' }
|
||||
|
||||
await vm.exportRows()
|
||||
|
||||
expect(createPlatformExportTask).toHaveBeenCalledWith({
|
||||
exportType: 'FINANCE_PAYABLE_SETTLEMENT_LIST', format: 'XLSX', taskName: '应付结算单列表',
|
||||
parameters: { customerId: '10002', status: 'PARTIALLY_PAID', settlementNo: 'AP-002', supplierName: '承运供应商' },
|
||||
})
|
||||
})
|
||||
|
||||
it('exports a detail by settlement business key and opens the download center on confirmation', async () => {
|
||||
vi.mocked(createPlatformExportTask).mockResolvedValueOnce({ taskName: '应收结算单详情', taskNo: 'EXP-DETAIL-1' } as never)
|
||||
vi.mocked(showExportTaskCreatedDialog).mockResolvedValueOnce(true)
|
||||
const router = testRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement-ar:export', 'platform:download:list'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable', settlementId: '701' }, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as { exportDetail(): Promise<void> }
|
||||
|
||||
await vm.exportDetail()
|
||||
|
||||
expect(createPlatformExportTask).toHaveBeenCalledWith({
|
||||
exportType: 'FINANCE_RECEIVABLE_SETTLEMENT_DETAIL', format: 'XLSX', taskName: '应收结算单详情', parameters: { settlementId: '701' },
|
||||
})
|
||||
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '应收结算单详情', taskNo: 'EXP-DETAIL-1' })
|
||||
expect(router.currentRoute.value.name).toBe('download-center')
|
||||
})
|
||||
|
||||
it('uses the payable detail type without a direction parameter', async () => {
|
||||
vi.mocked(getFinanceSettlement).mockResolvedValueOnce({ ...detail, id: '801', settlementNo: 'AP-801', direction: 'PAYABLE' })
|
||||
vi.mocked(createPlatformExportTask).mockResolvedValueOnce({ taskName: '应付结算单详情', taskNo: 'EXP-DETAIL-2' } as never)
|
||||
const router = testRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:export', 'platform:download:list'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'payable', settlementId: '801' }, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
await (wrapper.vm as unknown as { exportDetail(): Promise<void> }).exportDetail()
|
||||
|
||||
expect(createPlatformExportTask).toHaveBeenCalledWith({
|
||||
exportType: 'FINANCE_PAYABLE_SETTLEMENT_DETAIL', format: 'XLSX', taskName: '应付结算单详情', parameters: { settlementId: '801' },
|
||||
})
|
||||
})
|
||||
|
||||
it('hides export actions unless both export and download-list permissions are present', async () => {
|
||||
const router = testRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:download:list'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.settlement-list-actions').text()).not.toContain('导出')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ArrowLeft, CreditCard, Download, Eye, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { categoryLabels, listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -15,6 +17,7 @@ import FormSection from '@/shared/components/FormSection.vue'
|
||||
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 {
|
||||
actionFinanceSettlement,
|
||||
createFinanceReceipt,
|
||||
@@ -41,6 +44,7 @@ type SettlementRow = FinanceReceivableSettlement | FinancePayableSettlement
|
||||
|
||||
const props = defineProps<{ kind: 'receivable' | 'payable'; settlementId?: string }>()
|
||||
const emit = defineEmits<{ openDetail: [settlementId: string]; back: [] }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
|
||||
@@ -77,15 +81,18 @@ 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)
|
||||
const exportSubmitting = ref(false)
|
||||
|
||||
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
|
||||
const listPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:list' : 'platform:finance:settlement-ap:list')
|
||||
const exportPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:export' : 'platform:finance:settlement-ap:export')
|
||||
const canView = computed(() => access.can(listPermission.value))
|
||||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||||
const canCreate = computed(() => access.can(createPermission.value))
|
||||
const canPay = computed(() => props.kind === 'payable' && access.can('platform:finance:payment:create'))
|
||||
const canReceive = computed(() => props.kind === 'receivable' && access.can('platform:finance:receipt:create'))
|
||||
const canVoid = computed(() => access.can('platform:finance:settlement:void'))
|
||||
const canExport = computed(() => access.can(exportPermission.value) && access.can('platform:download:list'))
|
||||
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
|
||||
const detailMode = computed(() => Boolean(props.settlementId))
|
||||
const createLabel = computed(() => props.kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
|
||||
@@ -397,37 +404,48 @@ async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
|
||||
finally { actionBusy.value = false }
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||||
function exportRows() {
|
||||
if (!filteredRows.value.length) return ElMessage.warning('当前没有可导出的结算单')
|
||||
const header = props.kind === 'receivable'
|
||||
? ['结算单号', '客户', '币种', '应收金额', '已收', '余额', '生成日期', '到期日', '收款状态']
|
||||
: ['结算单号', '供应商', '费用类型', '币种', '应付金额', '已付', '余额', '生成日期', '付款状态']
|
||||
const records = filteredRows.value.map((row) => props.kind === 'receivable'
|
||||
? [row.settlementNo, customerName(row.customerId, (row as FinanceReceivableSettlement).customerName), row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinanceReceivableSettlement).billDate, (row as FinanceReceivableSettlement).dueDate, financeStatusLabel(row.status)]
|
||||
: [row.settlementNo, (row as FinancePayableSettlement).supplierName, (row as FinancePayableSettlement).feeItemName, row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinancePayableSettlement).createdAt, financeStatusLabel(row.status)])
|
||||
const blob = new Blob([`\ufeff${[header, ...records].map((record) => record.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${title.value}-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
async function createSettlementExport(detailId?: string) {
|
||||
const isDetail = Boolean(detailId)
|
||||
const taskName = props.kind === 'receivable'
|
||||
? (isDetail ? '应收结算单详情' : '应收结算单列表')
|
||||
: (isDetail ? '应付结算单详情' : '应付结算单列表')
|
||||
const exportType = props.kind === 'receivable'
|
||||
? (isDetail ? 'FINANCE_RECEIVABLE_SETTLEMENT_DETAIL' : 'FINANCE_RECEIVABLE_SETTLEMENT_LIST')
|
||||
: (isDetail ? 'FINANCE_PAYABLE_SETTLEMENT_DETAIL' : 'FINANCE_PAYABLE_SETTLEMENT_LIST')
|
||||
const settlementId = String(detailId || '')
|
||||
if (isDetail && !/^[1-9]\d*$/.test(settlementId)) return ElMessage.error('结算单业务标识无效,无法创建导出任务')
|
||||
const parameters: Record<string, string> = isDetail ? { settlementId } : {}
|
||||
if (!isDetail) {
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
|
||||
const status = String(filters.value.status || '').trim()
|
||||
const settlementNo = String(filters.value.settlementNo || '').trim()
|
||||
const supplierName = String(filters.value.supplierName || '').trim()
|
||||
if (customerId) parameters.customerId = customerId
|
||||
if (status) parameters.status = status
|
||||
if (settlementNo) parameters.settlementNo = settlementNo
|
||||
if (props.kind === 'payable' && supplierName) parameters.supplierName = supplierName
|
||||
}
|
||||
|
||||
exportSubmitting.value = true
|
||||
try {
|
||||
const task = await createPlatformExportTask({
|
||||
exportType,
|
||||
format: 'XLSX',
|
||||
taskName,
|
||||
parameters,
|
||||
})
|
||||
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
|
||||
if (openDownloadCenter) await router.push({ name: 'download-center' })
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, `${taskName}导出任务创建失败`))
|
||||
} finally {
|
||||
exportSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportDetail() {
|
||||
if (!detail.value) return
|
||||
const current = detail.value
|
||||
const header = ['结算单号', props.kind === 'receivable' ? '客户' : '供应商', '币种', props.kind === 'receivable' ? '应收金额' : '应付金额', props.kind === 'receivable' ? '已收' : '已付', '余额', '业务期间', '生成日期', '到期日', '状态']
|
||||
const record = [current.settlementNo, props.kind === 'receivable' ? customerName(current.customerId, current.customerName) : current.supplierName, current.currency, current.amount, current.paidAmount, current.outstandingAmount, current.businessPeriod, current.generatedDate, current.dueDate, financeStatusLabel(current.status)]
|
||||
const blob = new Blob([`\ufeff${[header, record].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${current.settlementNo}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
function exportRows() { return createSettlementExport() }
|
||||
function exportDetail() { return detail.value ? createSettlementExport(detail.value.id) : undefined }
|
||||
|
||||
onMounted(() => {
|
||||
if (canListCustomers.value) void loadCustomers()
|
||||
@@ -448,7 +466,7 @@ onMounted(() => {
|
||||
<el-tag v-if="detail" :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="settlement-detail-actions">
|
||||
<el-button v-if="detail" @click="exportDetail"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="detail && canExport" :loading="exportSubmitting" :disabled="exportSubmitting" @click="exportDetail"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="detail && canReceive && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" :loading="receiptBusy" @click="openReceiptFromDetail"><HandCoins :size="15" />部分收款</el-button>
|
||||
<el-button v-if="detail && canPay && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" @click="openPaymentFromDetail"><CreditCard :size="15" />部分付款</el-button>
|
||||
<el-button v-if="detail && canVoid && detail.status === 'ISSUED'" type="danger" :loading="actionBusy" @click="performAction(detailActionRow(), 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
@@ -534,7 +552,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="settlement-list-actions">
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
|
||||
<el-button @click="exportRows"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="canExport" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportRows"><Download :size="15" />导出</el-button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="surface-panel table-panel">
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('finance G3/G4 page operations', () => {
|
||||
|
||||
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 pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement-ar:export', 'platform:finance:receipt:create', 'platform:download:list'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable', settlementId: '701' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -113,11 +113,11 @@ describe('Finance G3/G4 views', () => {
|
||||
|
||||
it('creates a payable settlement and registers a voucher-backed payment', async () => {
|
||||
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' })
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } }
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { customerId: string; supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } }
|
||||
await vm.load({ customerId: '10001' }); await vm.openCreate()
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
|
||||
expect(document.body.textContent).toContain('拖车供应商A · 拖车')
|
||||
Object.assign(vm.createForm, { supplierId: '301', dueDate: '2026-09-30', feeSourceIds: ['81'] })
|
||||
Object.assign(vm.createForm, { customerId: '10001', supplierId: '301', dueDate: '2026-09-30', feeSourceIds: ['81'] })
|
||||
await vm.submitCreate()
|
||||
expect(createPayableSettlement).toHaveBeenCalledWith({ customerId: '10001', supplierId: '301', feeSourceIds: ['81'], dueDate: '2026-09-30', currency: 'CNY' })
|
||||
vm.openPayment(payable); vm.paymentFile = new File(['x'], 'voucher.png', { type: 'image/png' }); vm.paymentForm.amount = 100
|
||||
|
||||
Reference in New Issue
Block a user