This commit is contained in:
2026-08-13 12:22:41 +08:00
parent 835ac0ad12
commit 8a8c4fa190
26 changed files with 572 additions and 148 deletions
+12 -3
View File
@@ -1,12 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('uses only the formal settlement customer summary and keeps money as decimal strings', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: '9007199254740993', currency: 'CNY', totalReceivable: '123456.780000', receivedAmount: '100000.000000', outstandingReceivable: '23456.780000', pendingBilling: '0.000000', outstandingPayable: '0.000000', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null } })
const result = await getFinanceCustomerAccountSummary('9007199254740993')
expect(get).toHaveBeenCalledWith('/platform/finance/v1/customer-accounts/9007199254740993/summary')
expect(result).toMatchObject({ customerId: '9007199254740993', currency: 'CNY', totalReceivable: '123456.780000', outstandingReceivable: '23456.780000' })
})
it('uses the customer statement read-model path', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({
code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2 }],
@@ -107,7 +116,7 @@ describe('finance api', () => {
})
it('uses the frozen G3 reconciliation create and action contracts', async () => {
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, feeItemName: '运费', statementAmount: 100 }], paymentHistory: [], revisions: [] }
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', containerId: 31, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [], revisions: [] }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
@@ -118,7 +127,7 @@ describe('finance api', () => {
expect(post.mock.calls[0]).toEqual(['/platform/finance/v1/statements', expect.objectContaining({ customerId: '10001', currency: 'CNY', generationMode: 'BY_CUTOFF' }), { headers: { 'Idempotency-Key': expect.any(String) } }])
expect(post.mock.calls[1]).toEqual(['/platform/finance/v1/reconciliation-statements/501/actions', { action: 'SUBMIT', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.any(String) } }])
expect(get).toHaveBeenCalledWith('/platform/finance/v1/reconciliation-statements/501')
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, lines: [{ statementLineId: '601', sourceFeeId: '42', amount: 100 }] })
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, createdBy: '7', createdAt: '2026-08-01T09:00:00', updatedBy: '8', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: '601', sourceFeeId: '42', sourceHash: 'hash-42', containerId: '31', sourceAmount: 120, amount: 100, adjustmentReason: '优惠调整' }] })
expect(loaded.revisions).toEqual([])
})
+28 -1
View File
@@ -2,6 +2,17 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel, FinanceVoucherResource, ReconciliationActionPayload } from '../types'
const basePath = '/platform/finance/v1'
export interface FinanceCustomerAccountSummaryResource {
customerId: string
currency: 'CNY'
totalReceivable: number | string
receivedAmount: number | string
outstandingReceivable: number | string
pendingBilling: number | string
outstandingPayable: number | string
asOf: string
lastChangedAt: string | null
}
export const financePhase1Capabilities = {
accountList: true,
accountLedger: true,
@@ -64,6 +75,18 @@ export async function getFinanceCustomerAccount(customerId: string): Promise<Fin
}
}
export async function getFinanceCustomerAccountSummary(customerId: string): Promise<FinanceCustomerAccountSummaryResource> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/customer-accounts/${customerId}/summary`)
const item = unwrapApiResponse(response)
return {
customerId: stringValue(item.customerId), currency: 'CNY',
totalReceivable: stringValue(item.totalReceivable), receivedAmount: stringValue(item.receivedAmount),
outstandingReceivable: stringValue(item.outstandingReceivable), pendingBilling: stringValue(item.pendingBilling),
outstandingPayable: stringValue(item.outstandingPayable), asOf: stringValue(item.asOf),
lastChangedAt: item.lastChangedAt == null ? null : stringValue(item.lastChangedAt),
}
}
export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise<FinanceFeePoolItem[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params: { customerId: customerId?.trim() || undefined, direction } })
return unwrapApiResponse(response).map((item) => {
@@ -128,7 +151,11 @@ export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: str
function normalizeReconciliationDetail(item: RawResource): FinanceReconciliationDetail {
const lines = (Array.isArray(item.lines) ? item.lines : []).map((entry) => {
const row = entry as RawResource
return { statementLineId: stringValue(row.statementLineId), sourceFeeId: stringValue(row.feeSourceId), sourceFeeVersion: numberValue(row.sourceVersion), orderId: null, inboundId: row.inboundId == null ? null : stringValue(row.inboundId), feeItemName: stringValue(row.feeItemName), amount: numberValue(row.statementAmount) }
return {
statementLineId: stringValue(row.statementLineId), sourceFeeId: stringValue(row.feeSourceId), sourceFeeVersion: numberValue(row.sourceVersion), sourceHash: stringValue(row.sourceHash) || undefined,
orderId: null, inboundId: row.inboundId == null ? null : stringValue(row.inboundId), containerId: row.containerId == null ? null : stringValue(row.containerId),
feeItemName: stringValue(row.feeItemName), sourceAmount: numberValue(row.sourceAmount), amount: numberValue(row.statementAmount), adjustmentReason: stringValue(row.adjustmentReason) || undefined,
}
})
return {
id: stringValue(item.statementId), billNo: stringValue(item.statementNo), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
+1
View File
@@ -1,2 +1,3 @@
export { financeRoutes } from './routes'
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
export { getFinanceCustomerAccountSummary, type FinanceCustomerAccountSummaryResource } from './api/finance'
@@ -1,6 +1,28 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import ElementPlus 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 { listFinanceAccountLedger, listFinanceAccounts } from '../api/finance'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
vi.mock('../api/finance', () => ({
listFinanceAccounts: vi.fn(),
listFinanceAccountLedger: vi.fn(),
getFinanceCustomerTerms: vi.fn(),
updateFinanceCustomerTerms: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): 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) }
}
describe('FinanceCustomerAccountsView delivery', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8')
@@ -25,4 +47,32 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain("listFinanceAccounts({ page: currentPage, size: 200 })")
expect(page).not.toContain('listFinanceAccounts({ customerId')
})
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.stubGlobal('sessionStorage', memoryStorage())
vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 })
vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [], total: 0, pages: 1 })
})
it('restores account summary and ledger after opening the detail URL directly', async () => {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['platform:finance:account:view']
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('客户A · C-001')
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
wrapper.unmount()
})
})
afterAll(() => vi.unstubAllGlobals())
@@ -40,8 +40,8 @@ const ledgerPage = ref(1)
const ledgerPageSize = ref(20)
const ledgerTotal = ref(0)
const canView = computed(() => access.can('platform:finance:customer-account:list') || access.can('platform:finance:account:view'))
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
const canView = computed(() => access.can('platform:finance:customer-account:list'))
const canViewTerms = computed(() => canView.value)
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '客户编号/客户名称/联系人/手机号', props: { 'aria-label': '关键词' } },
@@ -164,7 +164,7 @@ onMounted(() => {
})
watch(() => [route.name, String(route.params.customerId || '')], ([name, customerId], previous) => {
if (!previous) return
if (name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(customerId)
if (name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(String(customerId))
else { detailOpen.value = false; selected.value = null; ledger.value = []; void load() }
})
</script>
@@ -69,9 +69,9 @@ const selectedRows = ref<ArapRow[]>([])
const page = ref(1)
const pageSize = ref(20)
const canView = computed(() => access.can('platform:finance:customer-arap:list') || access.can('platform:finance:bill:view'))
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
const canViewSource = computed(() => access.can('platform:finance:fee-source:view') || access.can('platform:finance:bill:view'))
const canViewSource = computed(() => canView.value)
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'))
@@ -194,7 +194,7 @@ onMounted(async () => {
})
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
if (!previous) return
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(feeSourceId)
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
else { detailOpen.value = false; selected.value = null; detail.value = null; void load() }
})
</script>
@@ -29,12 +29,12 @@ const statement: FinanceStatementReadModel = {
const detail: FinanceReconciliationDetail = {
...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单',
createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00',
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, orderId: '11', inboundId: '21', feeItemName: '运费', amount: 300 }],
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', orderId: '11', inboundId: '21', containerId: '31', feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }],
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '财务员', occurredAt: '2026-08-20 10:00:00' }],
revisions: [],
}
async function mountPage(permissions = ['platform:finance:bill:view']) {
async function mountPage(permissions = ['platform:finance:bill:view', 'platform:finance:fee-source:view']) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
@@ -75,6 +75,8 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.body.textContent).toContain('CONT-31')
expect(document.body.textContent).toContain('IN-21')
expect(document.body.textContent).toContain('来源版本')
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.querySelector('.el-drawer')).toBeNull()
@@ -95,10 +97,11 @@ describe('FinanceCustomerStatementsView', () => {
expect(vm.filteredRows).toHaveLength(0)
})
it('exports the currently filtered real statement rows as CSV', async () => {
it('hydrates and exports the currently filtered real statement rows as CSV', async () => {
const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = await mountPage()
;(wrapper.vm as unknown as { exportStatements(): void }).exportStatements()
await (wrapper.vm as unknown as { exportStatements(): Promise<void> }).exportStatements()
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(clicked).toHaveBeenCalledOnce()
})
@@ -112,6 +115,15 @@ describe('FinanceCustomerStatementsView', () => {
expect(listPlatformTenants).not.toHaveBeenCalled()
})
it('does not request fee-source details without its permission and keeps frozen trace fields visible', async () => {
const wrapper = await mountPage(['platform:finance:bill:view'])
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
await flushPromises()
expect(getFinanceFeeSource).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('柜次ID 31')
expect(document.body.textContent).toContain('hash-81')
})
it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => {
const wrapper = await mountPage()
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
@@ -119,4 +131,14 @@ describe('FinanceCustomerStatementsView', () => {
await flushPromises()
expect(document.body.textContent).toContain('筛选字段加载失败')
})
it('keeps the failed selected row visible and exposes a detail retry state', async () => {
const wrapper = await mountPage()
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
await flushPromises()
expect(document.body.textContent).toContain('已选择 1 项')
expect(document.body.textContent).toContain('明细加载失败')
expect(document.body.textContent).toContain('重试')
})
})
@@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
@@ -62,6 +62,7 @@ const detailCache = new Map<string, FinanceReconciliationDetail>()
const lineSourceCache = new Map<string, StatementLineSource>()
const createOpen = ref(false)
const createBusy = ref(false)
const exportBusy = ref(false)
const actionBusy = ref(false)
const createForm = ref({ periodStart: new Date().toISOString().slice(0, 8) + '01', periodEnd: new Date().toISOString().slice(0, 10), remark: '' })
const adjustOpen = ref(false)
@@ -70,8 +71,9 @@ const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
const canView = computed(() => access.can('platform:finance:customer-statement:list') || access.can('platform:finance:bill:view'))
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canViewFeeSource = computed(() => access.can('platform:finance:customer-arap:list'))
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
@@ -85,7 +87,7 @@ const fields = computed<QueryField[]>(() => [
] },
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: '请输入柜次号' },
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
{ key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
{ key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
{ key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
@@ -149,6 +151,7 @@ const filteredRows = computed(() => rows.value.filter((row) => {
}))
async function hydrateLineSources(current: FinanceReconciliationDetail) {
if (!canViewFeeSource.value) return
await Promise.all(current.lines.map(async (line) => {
if (!line.sourceFeeId || lineSourceCache.has(line.sourceFeeId)) return
try {
@@ -235,12 +238,43 @@ async function openDetail(row: FinanceStatementReadModel) {
}
}
function clearSelection() {
selectedId.value = ''
detail.value = null
detailError.value = null
}
function retrySelectedDetail() {
const row = rows.value.find((item) => item.id === selectedId.value)
if (row) void openDetail(row)
}
function selectedRowClass({ row }: { row: StatementRow }) {
return row.id === selectedId.value ? 'is-selected-statement' : ''
}
function paymentMethodLabel(method: string) {
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[method] || method || '—'
}
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('"', '""')}"` }
function exportStatements() {
async function exportStatements() {
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
} catch (cause) {
error.value = getApiErrorDetails(cause, '导出前加载完整对账单字段失败')
exportBusy.value = false
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 blob = new Blob([`\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
@@ -250,6 +284,7 @@ function exportStatements() {
anchor.download = `客户对账单-${new Date().toISOString().slice(0, 10)}.csv`
anchor.click()
URL.revokeObjectURL(url)
exportBusy.value = false
ElMessage.success(`已导出 ${body.length} 条客户对账单`)
}
@@ -260,6 +295,8 @@ function openCreate() {
async function createStatement() {
if (!filters.value.customerId) return ElMessage.warning('请先选择客户')
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
createBusy.value = true
try {
await createReconciliationStatement({ customerId: String(filters.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
@@ -328,7 +365,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<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-class-name="selectedRowClass" @row-click="openDetail">
<template #toolbar-actions>
<el-button :disabled="loading || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button :loading="exportBusy" :disabled="loading || exportBusy || !filteredRows.length" @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 }">
@@ -352,32 +389,35 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
<header class="statement-detail-header">
<div><strong>已选择 {{ detail ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
<el-button v-if="detail" link @click="selectedId = ''; detail = null">清空</el-button>
<div><strong>已选择 {{ selectedId ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
<el-button v-if="selectedId" link @click="clearSelection">清空</el-button>
</header>
<el-tabs v-model="activeDetailTab">
<el-tab-pane label="账单明细表" name="lines">
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="selectedId && openDetail(rows.find((row) => row.id === selectedId)!)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<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="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 || '—' }}</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>
<el-table-column label="来源类型" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.sourceType || '—' }}</template></el-table-column>
<el-table-column label="来源类型" min-width="140"><template #default="{ row }">{{ financeSourceTypeLabel(lineSourceCache.get(row.sourceFeeId)?.sourceType) }}</template></el-table-column>
<el-table-column prop="feeItemName" label="费用项目" min-width="150" />
<el-table-column label="计费量" width="110"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template></el-table-column>
<el-table-column label="单价(CNY)" width="120"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template></el-table-column>
<el-table-column prop="sourceAmount" label="来源金额(CNY)" width="150" />
<el-table-column prop="amount" label="账单金额(CNY)" width="150" />
<el-table-column prop="sourceFeeId" label="来源费用ID" min-width="140" />
<el-table-column prop="sourceFeeVersion" label="来源版本" width="110" />
<el-table-column prop="sourceHash" label="来源快照Hash" min-width="190" />
<el-table-column prop="adjustmentReason" label="调整说明" min-width="180" />
<el-table-column label="对账单号" min-width="180"><template #default>{{ detail.billNo }}</template></el-table-column>
<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 prop="action" label="动作" width="130" /><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 prop="occurredAt" label="发生时间" min-width="170" /></el-table></el-collapse-item></el-collapse>
</template>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
@@ -385,7 +425,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<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 prop="amount" label="支付金额(CNY)" min-width="150" />
<el-table-column prop="method" label="支付方式" min-width="130" />
<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" />
@@ -66,10 +66,9 @@ const receiptFile = ref<File | 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 viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
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 canView = computed(() => access.can(listPermission.value) || access.can(viewPermission.value))
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'))
@@ -346,6 +345,78 @@ onMounted(() => {
<template>
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<section v-else-if="detailMode" class="settlement-detail-page" :aria-label="`${title}详情`">
<header class="settlement-detail-header">
<div class="settlement-detail-heading">
<el-button aria-label="返回列表" title="返回列表" @click="backToList"><ArrowLeft :size="17" /></el-button>
<div><div class="settlement-detail-breadcrumb">财务管理 / {{ title }} / {{ detail?.settlementNo || settlementId }}</div><h2>{{ title }}详情</h2></div>
<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 && 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>
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</div>
</header>
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="10" animated />
<template v-else-if="detail">
<section class="settlement-detail-summary" aria-label="结算金额汇总">
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</span><strong>{{ money(detail.amount) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div>
<div class="summary-item"><span>未结余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div>
<div class="summary-item"><span>到期日</span><strong class="is-date">{{ displayDate(detail.dueDate) }}</strong></div>
</section>
<section class="settlement-detail-info surface-panel">
<h3>基本信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
<el-descriptions-item label="币种">CNY</el-descriptions-item>
<el-descriptions-item label="收付款状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="关联账单">{{ detail.reconciliationStatementId || '—' }}</el-descriptions-item>
<el-descriptions-item label="业务期间">{{ detail.businessPeriod || '—' }}</el-descriptions-item>
<el-descriptions-item label="生成日期">{{ displayDate(detail.generatedDate) }}</el-descriptions-item>
<el-descriptions-item label="到期日">{{ displayDate(detail.dueDate) }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-section surface-panel">
<h3>费用明细</h3>
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140" />
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
<el-table-column prop="sourceVersion" label="来源版本" width="100" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section surface-panel">
<h3>{{ kind === 'receivable' ? '收款记录' : '付款记录' }}</h3>
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section surface-panel">
<h3>修订记录</h3>
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无修订记录">
<el-table-column prop="revisionNo" label="版本" width="90" />
<el-table-column prop="action" label="动作" min-width="120" />
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
<el-table-column prop="occurredAt" label="时间" min-width="160" />
</el-table>
</section>
</template>
</section>
<ListPageLayout v-else :aria-label="title">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" @reset="load" />
@@ -388,59 +459,6 @@ onMounted(() => {
</template>
</ListPageLayout>
<AppDrawer v-model="detailOpen" :title="`${title}详情`" size="min(900px, 96vw)">
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="8" animated />
<div v-else-if="detail" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ kind === 'receivable' ? customerName(detail.customerId) : selectedSupplierName() }}</el-descriptions-item>
<el-descriptions-item label="币种">CNY</el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '应收金额' : '应付金额'">{{ money(detail.amount) }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '已收' : '已付'">{{ money(detail.paidAmount) }}</el-descriptions-item>
<el-descriptions-item label="余额">{{ money(detail.outstandingAmount) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reconciliationStatementId" label="关联对账单">{{ detail.reconciliationStatementId }}</el-descriptions-item>
</el-descriptions>
<section class="detail-section">
<h3>费用明细</h3>
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140" />
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
<el-table-column prop="sourceVersion" label="来源版本" width="100" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section">
<h3>{{ kind === 'receivable' ? '收款记录' : '付款记录' }}</h3>
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section">
<h3>修订记录</h3>
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无修订记录">
<el-table-column prop="revisionNo" label="版本" width="90" />
<el-table-column prop="action" label="动作" min-width="120" />
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
<el-table-column prop="occurredAt" label="时间" min-width="160" />
</el-table>
</section>
</div>
</AppDrawer>
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
<el-form label-position="top">
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
@@ -449,11 +467,15 @@ onMounted(() => {
</AppDialog>
<AppDialog v-model="paymentOpen" title="登记付款" width="min(560px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
<el-form label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="凭证号"><el-input v-model="paymentForm.referenceNo" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" /></el-form-item></el-form>
<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="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="银行流水号/凭证号"><el-input v-model="paymentForm.referenceNo" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" /></el-form-item></el-form>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(paymentForm.amount) }}</strong></div>
</AppDialog>
<AppDialog v-model="receiptOpen" title="登记收款" width="min(560px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<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>
<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>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
</AppDialog>
</AdminPage>
</template>
@@ -466,13 +488,30 @@ onMounted(() => {
.summary-item strong { display: block; margin-top: 8px; color: var(--cc-text-primary, #1f2937); font-size: 24px; line-height: 1.2; }
.summary-item strong.is-success { color: #16803c; }
.summary-item strong.is-danger { color: #c2413a; }
.detail-content { min-width: 0; }
.detail-section { margin-top: 22px; }
.detail-section h3 { margin: 0 0 10px; color: var(--cc-text-primary, #1f2937); font-size: 15px; font-weight: 600; }
.settlement-detail-page { display: grid; min-width: 0; gap: 14px; }
.settlement-detail-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-width: 0; }
.settlement-detail-heading, .settlement-detail-actions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
.settlement-detail-heading h2 { margin: 2px 0 0; font-size: 18px; letter-spacing: 0; }
.settlement-detail-breadcrumb { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.summary-item strong.is-date { font-size: 20px; }
.settlement-detail-info, .detail-section { min-width: 0; padding: 16px; }
.settlement-detail-info h3, .detail-section h3 { margin: 0 0 12px; color: var(--cc-text-primary, #1f2937); font-size: 15px; font-weight: 600; }
.partial-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; padding: 14px; border: 1px solid var(--el-color-primary-light-7); border-radius: 8px; background: var(--el-color-primary-light-9); }
.partial-summary div { min-width: 0; }
.partial-summary span, .partial-summary strong { display: block; overflow-wrap: anywhere; }
.partial-summary span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.partial-summary strong { margin-top: 4px; color: var(--cc-text-primary, #1f2937); font-size: 15px; }
.partial-summary strong.is-success { color: #16803c; }
.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; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 720px) {
.settlement-summary { grid-template-columns: 1fr; }
.settlement-summary, .settlement-detail-summary, .partial-summary { grid-template-columns: 1fr; }
.summary-item strong { font-size: 21px; }
:deep(.el-drawer__footer .el-button) { min-height: 44px; }
.settlement-detail-header { align-items: flex-start; flex-direction: column; }
.settlement-detail-actions { width: 100%; }
.settlement-detail-actions :deep(.el-button) { min-height: 40px; }
}
</style>
@@ -87,7 +87,7 @@ let receiptBillsRequest = 0
let costOptionsRequest = 0
const title = computed(() => ({ bills: '客户对账单', receipts: '结算单应收', costs: '结算单应付' }[props.mode]))
const viewPermission = computed(() => ({ bills: 'platform:finance:bill:view', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view' }[props.mode]))
const viewPermission = computed(() => ({ bills: 'platform:finance:customer-statement:list', receipts: 'platform:finance:settlement-ar:list', costs: 'platform:finance:settlement-ap:list' }[props.mode]))
const createPermission = computed(() => ({ bills: 'platform:finance:bill:create', receipts: 'platform:finance:receipt:create', costs: 'platform:finance:cost:create' }[props.mode]))
const canView = computed(() => access.can(viewPermission.value))
const canCreate = computed(() => Boolean(createPermission.value) && access.can(createPermission.value))
@@ -3,17 +3,31 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const settlement = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
const financeRoutes = readFileSync(resolve(process.cwd(), 'src/modules/finance/routes.ts'), 'utf8')
const statements = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
const arap = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
describe('finance presentation governance', () => {
it('shows customer names instead of internal customer IDs in formal settlements', () => {
expect(settlement).toContain('customerName(detail.customerId)')
expect(settlement).toContain('customerName(detail.value.customerId, detail.value.customerName)')
expect(settlement).toContain('detailPartyName()')
expect(settlement).toContain('activeCustomers.value.map')
expect(settlement).not.toContain('{{ selected.customerId }}')
})
it('uses prototype-defined standalone settlement details and modal partial payments', () => {
expect(financeRoutes).toContain("finance/settlement-receivables/:settlementId")
expect(financeRoutes).toContain("finance/settlement-payables/:settlementId")
expect(financeRoutes).toContain('showPageHeading: false')
expect(settlement).toContain('class="settlement-detail-page"')
expect(settlement).toContain('返回列表')
expect(settlement).not.toContain('AppDrawer')
expect(settlement).toContain('登记后剩余余额')
expect(settlement).toContain('remainingAfter(receiptForm.amount)')
expect(settlement).toContain('remainingAfter(paymentForm.amount)')
})
it('uses optional all-customer filters without a customer-selection blocker', () => {
for (const source of [settlement, statements, arap]) {
expect(source).toContain("placeholder: '全部客户'")
+9 -9
View File
@@ -1,13 +1,13 @@
import type { RouteRecordRaw } from 'vue-router'
export const financeRoutes = [
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'] } },
{ path: 'finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'], showPageHeading: false } },
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'] } },
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'], showPageHeading: false } },
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list', permissionAnyOf: ['platform:finance:bill:view'] } },
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'] } },
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'], showPageHeading: false } },
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'] } },
{ path: 'finance/settlement-payables/:settlementId', name: 'finance-settlement-payable-detail', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '应付结算单详情', description: '查看应付结算信息、费用明细和付款记录', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'], showPageHeading: false } },
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:customer-account:list' } },
{ path: 'finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', showPageHeading: false } },
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list' } },
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', showPageHeading: false } },
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list' } },
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list' } },
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', showPageHeading: false } },
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list' } },
{ path: 'finance/settlement-payables/:settlementId', name: 'finance-settlement-payable-detail', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '应付结算单详情', description: '查看应付结算信息、费用明细和付款记录', permission: 'platform:finance:settlement-ap:list', showPageHeading: false } },
] satisfies RouteRecordRaw[]
+4
View File
@@ -279,10 +279,14 @@ export interface FinanceBillLine {
statementLineId?: string
sourceFeeId: string
sourceFeeVersion: number
sourceHash?: string
orderId: string | null
inboundId: string | null
containerId?: string | null
feeItemName: string
sourceAmount?: number
amount: number
adjustmentReason?: string
}
export interface FinanceBillDetail extends FinanceBillResource {