优化
This commit is contained in:
@@ -13,6 +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 { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
|
||||
import { financeBusinessTypeLabel, financeDocumentTypeLabel } from '../status'
|
||||
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -118,7 +119,7 @@ onMounted(() => void load())
|
||||
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
|
||||
<template v-else-if="selected">
|
||||
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
|
||||
<FormSection v-if="!editing" title="账户流水" description="按后端正式流水 DTO 展示应收、应付及变动后未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ entry.businessType }} · {{ entry.documentType }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
|
||||
<FormSection v-if="!editing" title="账户流水" description="按后端正式流水 DTO 展示应收、应付及变动后未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ financeBusinessTypeLabel(entry.businessType) }} · {{ financeDocumentTypeLabel(entry.documentType) }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
|
||||
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
|
||||
</template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
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 { ApiResponseError } from '@/shared/api/http'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { 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('../api/finance', () => ({
|
||||
listFinanceFeePool: vi.fn(),
|
||||
getFinanceFeeSource: vi.fn(),
|
||||
reviewFinanceFeeSource: vi.fn(),
|
||||
updateFinanceFeeSourceSettlementInclusion: 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),
|
||||
}
|
||||
}
|
||||
|
||||
const detail: FinanceFeeSourceDetail = {
|
||||
poolItemId: '42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceFeeId: '81', sourceVersion: 3,
|
||||
customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '',
|
||||
businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
|
||||
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [],
|
||||
}
|
||||
|
||||
async function mountView(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function loadRows(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
|
||||
await vm.load({ customerId: '10001', direction: '', status: '', keyword: '' })
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('FinanceCustomerArapView G2 operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
document.body.innerHTML = ''
|
||||
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }])
|
||||
vi.mocked(getFinanceFeeSource).mockReset().mockResolvedValue(detail)
|
||||
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
|
||||
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 })
|
||||
})
|
||||
|
||||
it('does not expose or call G2 operations without their permissions', async () => {
|
||||
const wrapper = await mountView(['platform:finance:bill:view'])
|
||||
await loadRows(wrapper)
|
||||
|
||||
const buttonLabels = wrapper.findAll('button').map((button) => button.text())
|
||||
expect(buttonLabels).not.toContain('详情')
|
||||
expect(buttonLabels).not.toContain('审核')
|
||||
expect(buttonLabels).not.toContain('结算')
|
||||
expect(getFinanceFeeSource).not.toHaveBeenCalled()
|
||||
expect(reviewFinanceFeeSource).not.toHaveBeenCalled()
|
||||
expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads detail and submits review and settlement changes with the returned version', async () => {
|
||||
const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement'])
|
||||
await loadRows(wrapper)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
openDetail(row: Record<string, unknown>): Promise<void>
|
||||
openReview(row: Record<string, unknown>): Promise<void>
|
||||
submitReview(): Promise<void>
|
||||
openInclusion(row: Record<string, unknown>): Promise<void>
|
||||
submitInclusion(): Promise<void>
|
||||
reviewForm: { action: 'APPROVE' | 'REJECT'; reason: string }
|
||||
inclusionForm: { included: boolean; reason: string }
|
||||
}
|
||||
const row = { id: 'RECEIVABLE:42', feeSourceId: '42' }
|
||||
|
||||
await vm.openDetail(row); await flushPromises()
|
||||
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
|
||||
expect(document.body.textContent).toContain('IB-91')
|
||||
expect(document.body.textContent).toContain('100.00 CNY')
|
||||
|
||||
await vm.openReview(row); vm.reviewForm.action = 'APPROVE'; vm.reviewForm.reason = '核对无误'; await vm.submitReview()
|
||||
expect(reviewFinanceFeeSource).toHaveBeenCalledWith('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 })
|
||||
|
||||
await vm.openInclusion(row); vm.inclusionForm.included = true; vm.inclusionForm.reason = '本期结算'; await vm.submitInclusion()
|
||||
expect(updateFinanceFeeSourceSettlementInclusion).toHaveBeenCalledWith('42', { included: true, reason: '本期结算', expectedVersion: 3 })
|
||||
})
|
||||
|
||||
it('renders request and trace ids when detail loading fails', async () => {
|
||||
vi.mocked(getFinanceFeeSource).mockRejectedValueOnce(new ApiResponseError('费用来源不可用', 'FIN-FEE-404', 404, undefined, 'req-g2-error', 'trace-g2-error'))
|
||||
const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view'])
|
||||
await loadRows(wrapper)
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): Promise<void> }
|
||||
|
||||
await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' }); await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('费用来源不可用')
|
||||
expect(document.body.textContent).toContain('req-g2-error')
|
||||
expect(document.body.textContent).toContain('trace-g2-error')
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, FileCheck2, Link2 } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, 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'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.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 { listFinanceFeePool } from '../api/finance'
|
||||
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import { financeArapStatusOptions, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
|
||||
interface ArapRow {
|
||||
id: string
|
||||
feeSourceId: string | null
|
||||
customerName: string
|
||||
direction: 'RECEIVABLE' | 'PAYABLE'
|
||||
documentNo: string
|
||||
@@ -39,6 +44,17 @@ const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const drawerOpen = ref(false)
|
||||
const selected = ref<ArapRow | null>(null)
|
||||
const detail = ref<FinanceFeeSourceDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const reviewOpen = ref(false)
|
||||
const reviewSaving = ref(false)
|
||||
const reviewError = ref<ApiErrorDetails | null>(null)
|
||||
const reviewForm = reactive<{ action: 'APPROVE' | 'REJECT'; reason: string }>({ action: 'APPROVE', reason: '' })
|
||||
const inclusionOpen = ref(false)
|
||||
const inclusionSaving = ref(false)
|
||||
const inclusionError = ref<ApiErrorDetails | null>(null)
|
||||
const inclusionForm = reactive({ included: true, reason: '' })
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
@@ -47,14 +63,15 @@ const canViewSource = computed(() => access.can('platform:finance:fee-source:vie
|
||||
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
|
||||
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
|
||||
const stateError = computed(() => customersError.value || loadError.value)
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id), disabled: item.status !== 'ACTIVE' })) },
|
||||
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }, { label: '已登记', value: 'RECORDED' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
|
||||
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
|
||||
{ key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', width: 130 },
|
||||
@@ -79,12 +96,55 @@ async function load(model: QueryModel = filters) {
|
||||
try {
|
||||
const direction = String(filters.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
|
||||
const items = await listFinanceFeePool(customerId, direction || undefined)
|
||||
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
|
||||
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function openDetail(row: Record<string, unknown>) { selected.value = row as unknown as ArapRow; drawerOpen.value = true }
|
||||
function statusLabel(status: string) { return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废', RECORDED: '已登记' } as Record<string, string>)[status] || status }
|
||||
async function loadDetail(row: ArapRow) {
|
||||
detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
if (!row.feeSourceId) { detailError.value = { message: '该手工成本没有可审核的费用来源详情' }; detailLoading.value = false; return null }
|
||||
try { detail.value = await getFinanceFeeSource(row.feeSourceId); return detail.value }
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试'); return null }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
async function openDetail(row: Record<string, unknown>) {
|
||||
selected.value = row as unknown as ArapRow; drawerOpen.value = true
|
||||
await loadDetail(selected.value)
|
||||
}
|
||||
async function openReview(row: Record<string, unknown>) {
|
||||
const target = row as unknown as ArapRow; selected.value = target
|
||||
const loaded = await loadDetail(target)
|
||||
if (!loaded) { drawerOpen.value = true; return }
|
||||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
|
||||
}
|
||||
async function submitReview() {
|
||||
if (!detail.value) return
|
||||
if (reviewForm.action === 'REJECT' && !reviewForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||||
reviewSaving.value = true; reviewError.value = null
|
||||
try {
|
||||
detail.value = await reviewFinanceFeeSource(detail.value.feeSourceId, { action: reviewForm.action, reason: reviewForm.reason.trim() || undefined, expectedVersion: detail.value.operationVersion })
|
||||
reviewOpen.value = false; ElMessage.success(reviewForm.action === 'APPROVE' ? '费用来源已审核通过' : '费用来源已驳回'); await load()
|
||||
} catch (error) { reviewError.value = getApiErrorDetails(error, '费用来源审核失败,请重试') }
|
||||
finally { reviewSaving.value = false }
|
||||
}
|
||||
async function openInclusion(row: Record<string, unknown>) {
|
||||
const target = row as unknown as ArapRow; selected.value = target
|
||||
const loaded = await loadDetail(target)
|
||||
if (!loaded) { drawerOpen.value = true; return }
|
||||
Object.assign(inclusionForm, { included: !loaded.settlementIncluded, reason: '' }); inclusionError.value = null; inclusionOpen.value = true
|
||||
}
|
||||
async function submitInclusion() {
|
||||
if (!detail.value) return
|
||||
if (!inclusionForm.reason.trim()) { ElMessage.warning('请填写调整原因'); return }
|
||||
inclusionSaving.value = true; inclusionError.value = null
|
||||
try {
|
||||
detail.value = await updateFinanceFeeSourceSettlementInclusion(detail.value.feeSourceId, { included: inclusionForm.included, reason: inclusionForm.reason.trim(), expectedVersion: detail.value.operationVersion })
|
||||
inclusionOpen.value = false; ElMessage.success(inclusionForm.included ? '已纳入结算' : '已排除结算'); await load()
|
||||
} catch (error) { inclusionError.value = getApiErrorDetails(error, '参与结算状态更新失败,请重试') }
|
||||
finally { inclusionSaving.value = false }
|
||||
}
|
||||
function statusLabel(status: string) { return financeStatusLabel(status) }
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
|
||||
onMounted(() => void loadCustomers())
|
||||
</script>
|
||||
@@ -96,13 +156,28 @@ onMounted(() => void loadCustomers())
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="load" /></template>
|
||||
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="请选择客户查询应收应付">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :disabled="!canViewSource" :title="canViewSource ? '费用来源详情接口尚未提供' : '无费用来源查看权限'" @click="openDetail(row)"><Eye :size="15" />摘要</el-button><el-button v-if="canReviewSource" link type="primary" disabled title="审核接口尚未提供,暂不可操作"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" disabled title="参与结算接口尚未提供,暂不可操作"><Link2 :size="15" />结算</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="drawerOpen" title="费用来源摘要" size="min(680px, 94vw)"><el-descriptions v-if="selected" :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ selected.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selected.status) }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="费用单据">{{ selected.documentNo }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ selected.sourceType }}</el-descriptions-item><el-descriptions-item label="来源追溯">{{ selected.sourceId }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ selected.feeName }}</el-descriptions-item><el-descriptions-item label="金额">{{ selected.amount }} CNY</el-descriptions-item><el-descriptions-item label="发生日期">{{ selected.occurredAt }}</el-descriptions-item></el-descriptions><el-alert type="info" :closable="false" title="费用来源详情、审核和参与结算接口尚未提供,当前仅展示列表摘要。" /><template #footer><el-button @click="drawerOpen = false">关闭</el-button></template></AppDrawer>
|
||||
<AppDrawer v-model="drawerOpen" title="费用来源详情" size="min(760px, 94vw)" :loading="detailLoading">
|
||||
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ detail.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
|
||||
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
|
||||
</template>
|
||||
<template #footer><el-button @click="drawerOpen = false">关闭</el-button></template>
|
||||
</AppDrawer>
|
||||
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-alert v-if="reviewError" type="error" :closable="false"><ApiErrorMeta :details="reviewError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="inclusionOpen" title="参与结算设置" confirm-text="保存设置" :confirm-loading="inclusionSaving" @confirm="submitInclusion">
|
||||
<el-alert v-if="inclusionError" type="error" :closable="false"><ApiErrorMeta :details="inclusionError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="结算状态"><el-switch v-model="inclusionForm.included" active-text="参与结算" inactive-text="排除结算" /></el-form-item><el-form-item label="调整原因"><el-input v-model="inclusionForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请说明参与或排除结算的原因" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -110,7 +185,10 @@ onMounted(() => void loadCustomers())
|
||||
.finance-arap-page { min-width: 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.row-actions .el-button { margin-left: 0; }
|
||||
.muted-action { color: var(--cc-muted); }
|
||||
.arap-detail { margin-bottom: 16px; }
|
||||
.operation-log h3 { margin: 0 0 14px; font-size: 15px; }
|
||||
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
|
||||
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
|
||||
</style>
|
||||
|
||||
@@ -12,15 +12,16 @@ 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 { getFinanceBill, listFinanceStatements } from '../api/finance'
|
||||
import { financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceBillDetail, FinanceStatementReadModel } from '../types'
|
||||
const access = useAccessPreviewStore(); const customers = ref<PlatformTenantResource[]>([]); const filters = reactive<QueryModel>({ customerId: '' }); const rows = ref<FinanceStatementReadModel[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const customerError = ref<ApiErrorDetails | null>(null); const drawerOpen = ref(false); const detail = ref<FinanceBillDetail | null>(null); const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }]
|
||||
const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
|
||||
async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
|
||||
async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, '客户对账单加载失败,请重试') } finally { loading.value = false } }
|
||||
async function openDetail(row: FinanceStatementReadModel) { drawerOpen.value = true; detail.value = null; detailError.value = null; try { detail.value = await getFinanceBill(row.id) } catch (e) { detailError.value = getApiErrorDetails(e, '对账单详情加载失败,请重试') } }
|
||||
onMounted(() => void loadCustomers())
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-statements-page" spacing="normal"><el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" /><ListPageLayout v-else aria-label="客户对账单"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template><template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template><section v-if="!customerError && !error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单"><template #toolbar-actions><el-button type="primary" disabled title="接口尚未提供,暂不可操作"><Plus :size="16" />生成对账单</el-button></template><template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template><template #pagination-left>币种:CNY · 生成、出账、调整、作废待后端 G1-G4 契约冻结</template></DataTable></section></ListPageLayout><AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(760px, 94vw)"><LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ detail.status }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions><el-alert class="blocked-alert" type="info" :closable="false" title="对账单生成与状态操作接口尚未提供,当前仅支持真实详情查询。" /></template></AppDrawer></AdminPage>
|
||||
<AdminPage as="div" class="finance-statements-page" spacing="normal"><el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" /><ListPageLayout v-else aria-label="客户对账单"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template><template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template><section v-if="!customerError && !error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单"><template #toolbar-actions><el-button type="primary" disabled title="接口尚未提供,暂不可操作"><Plus :size="16" />生成对账单</el-button></template><template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template><template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template><template #pagination-left>币种:CNY · 生成、出账、调整、作废待后端 G1-G4 契约冻结</template></DataTable></section></ListPageLayout><AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(760px, 94vw)"><LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions><el-alert class="blocked-alert" type="info" :closable="false" title="对账单生成与状态操作接口尚未提供,当前仅支持真实详情查询。" /></template></AppDrawer></AdminPage>
|
||||
</template>
|
||||
<style scoped>.finance-statements-page{min-width:0}.blocked-alert{margin-top:16px}</style>
|
||||
|
||||
@@ -12,6 +12,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 { financePhase1Capabilities, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
|
||||
import { financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types'
|
||||
|
||||
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
|
||||
@@ -28,14 +29,16 @@ const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:fi
|
||||
const canView = computed(() => access.can(viewPermission.value))
|
||||
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
|
||||
const blocked = computed(() => props.kind === 'receivable' ? !financePhase1Capabilities.receivableSettlementCreate : !financePhase1Capabilities.payableSettlementCreate)
|
||||
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
|
||||
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'customerId', label: '客户', minWidth: 140 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }]
|
||||
: [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'customerId', label: '客户', minWidth: 140 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }])
|
||||
? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
|
||||
: [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }])
|
||||
async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
|
||||
async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.customerId)) : await listFinancePayableSettlements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, `${title.value}加载失败,请重试`) } finally { loading.value = false } }
|
||||
function openDetail(row: FinanceReceivableSettlement | FinancePayableSettlement) { selected.value = row; drawerOpen.value = true }
|
||||
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
|
||||
function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' }
|
||||
onMounted(() => void loadCustomers())
|
||||
</script>
|
||||
<template>
|
||||
@@ -48,12 +51,14 @@ onMounted(() => void loadCustomers())
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="请选择客户查询结算单">
|
||||
<template #toolbar-actions><el-button type="primary" disabled :title="blocked ? '接口尚未提供,暂不可操作' : ''"><Plus :size="16" />生成{{ title }}</el-button></template>
|
||||
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceReceivableSettlement | FinancePayableSettlement)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #customer="{ row }">{{ customerName(String(row.customerId)) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #pagination-left>币种:CNY · 生成接口尚未提供时仅展示真实查询结果</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="drawerOpen" :title="`${title}详情`" size="min(680px, 94vw)">
|
||||
<el-descriptions v-if="selected" :column="2" border><el-descriptions-item label="结算单号">{{ selected.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerId }}</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '应收金额' : '应付金额'">{{ totalAmount(selected) }} CNY</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '未收金额' : '未付金额'">{{ selected.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ selected.status }}</el-descriptions-item></el-descriptions>
|
||||
<el-descriptions v-if="selected" :column="2" border><el-descriptions-item label="结算单号">{{ selected.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(selected.customerId) }}</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '应收金额' : '应付金额'">{{ totalAmount(selected) }} CNY</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '未收金额' : '未付金额'">{{ selected.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selected.status) }}</el-descriptions-item></el-descriptions>
|
||||
<el-alert class="blocked-alert" type="info" :closable="false" title="结算单详情/收付款接口尚未提供,当前不显示不可执行的提交按钮。" />
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
|
||||
@@ -46,6 +46,14 @@ describe('FinanceManualPage delivery contract', () => {
|
||||
expect(source).not.toContain('mockRevision')
|
||||
})
|
||||
|
||||
it('renders list and detail statuses through the finance translation contract', () => {
|
||||
expect(source).toContain('financeBillStatusOptions')
|
||||
expect(source).toContain('financeStatusLabel(selectedBill.status)')
|
||||
expect(source).toContain('financeStatusLabel(revision.status)')
|
||||
expect(source).toContain('financeStatusLabel(selectedLedgerRow.status)')
|
||||
expect(source).not.toContain('{{ selectedBill.status }}')
|
||||
})
|
||||
|
||||
it('uses business selectors instead of asking operators for internal ids', () => {
|
||||
expect(source).toContain("type: 'select', label: '客户'")
|
||||
expect(source).toContain('placeholder="请选择客户"')
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type UnifiedOrderResource,
|
||||
} from '@/modules/unified-orders'
|
||||
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
|
||||
import { financeBillStatusOptions, financeStatusLabel, financeStatusTone } from '../status'
|
||||
|
||||
type Mode = 'bills' | 'receipts' | 'costs'
|
||||
type DrawerMode = 'create' | 'detail' | 'edit' | 'adjust'
|
||||
@@ -103,7 +104,7 @@ const rows = computed(() => serverPaged.value ? allRows.value : allRows.value.sl
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '客户/单号/费用项目', props: { 'aria-label': '关键词' } },
|
||||
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customerOptionsLoading.value }, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })) },
|
||||
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }] }] : []),
|
||||
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: financeBillStatusOptions }] : []),
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => {
|
||||
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
|
||||
@@ -486,7 +487,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
|
||||
</div>
|
||||
<el-button v-else link type="primary" @click="openLedgerDetail(row)"><Eye :size="15" />查看</el-button>
|
||||
</template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'PAID' || row.status === 'CONFIRMED' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : row.status === 'CONFIRMED' ? '已确认' : row.status === 'VOIDED' ? '作废' : row.status }}</el-tag></template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #costType="{ row }">{{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</template>
|
||||
<template #empty><el-empty description="暂无符合条件的财务记录" /></template>
|
||||
<template #pagination-left>币种:CNY · 数据由平台财务人工维护</template>
|
||||
@@ -498,7 +499,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
|
||||
<template v-if="props.mode === 'bills' && drawerMode === 'detail' && selectedBill">
|
||||
<el-descriptions :column="2" border class="bill-summary">
|
||||
<el-descriptions-item label="账单编号">{{ selectedBill.billNo }}</el-descriptions-item><el-descriptions-item label="当前版本">v{{ selectedBill.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedBill.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedBill.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单日期">{{ selectedBill.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedBill.dueDate }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单金额">{{ selectedBill.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedBill.outstandingAmount }} CNY</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
@@ -513,7 +514,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
|
||||
<div v-else class="revision-list">
|
||||
<section v-for="revision in billRevisions" :key="revision.id" class="revision-item">
|
||||
<div class="revision-heading"><strong>调整前版本 v{{ revision.version }}</strong><span>{{ revision.createdAt || '时间未知' }}</span></div>
|
||||
<div class="revision-meta"><span>原因:{{ revision.reason || '未填写' }}</span><span>操作人ID:{{ revision.createdBy || '—' }}</span><span>状态:{{ revision.status || '—' }}</span><span>金额:{{ revision.totalAmount }} CNY</span></div>
|
||||
<div class="revision-meta"><span>原因:{{ revision.reason || '未填写' }}</span><span>操作人ID:{{ revision.createdBy || '—' }}</span><span>状态:{{ financeStatusLabel(revision.status) }}</span><span>金额:{{ revision.totalAmount }} CNY</span></div>
|
||||
<div v-for="(line, index) in revision.lines" :key="`${revision.id}-${line.sourceFeeId}-${index}`" class="revision-line"><span>{{ line.feeItemName || `费用来源 ${line.sourceFeeId}` }}</span><b>{{ line.amount }} CNY</b></div>
|
||||
<p v-if="!revision.lines.length" class="revision-empty">该版本没有费用明细快照</p>
|
||||
</section>
|
||||
@@ -523,8 +524,8 @@ onMounted(() => { void load(); void loadCustomerOptions() })
|
||||
<template v-else-if="props.mode !== 'bills' && drawerMode === 'detail' && selectedLedgerRow">
|
||||
<el-descriptions :column="2" border class="bill-summary">
|
||||
<el-descriptions-item label="结算单号">{{ selectedLedgerRow.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerNameById(selectedLedgerRow.customerId) }}</el-descriptions-item>
|
||||
<template v-if="props.mode === 'receipts'"><el-descriptions-item label="账单日期">{{ selectedLedgerRow.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedLedgerRow.dueDate }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ selectedLedgerRow.status }}</el-descriptions-item></template>
|
||||
<template v-else><el-descriptions-item label="费用项目">{{ selectedLedgerRow.feeName }}</el-descriptions-item><el-descriptions-item label="成本类型">{{ selectedLedgerRow.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</el-descriptions-item><el-descriptions-item label="关联来源">{{ selectedLedgerRow.referenceNo }}</el-descriptions-item><el-descriptions-item label="应付金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已付金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未付金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="登记时间">{{ selectedLedgerRow.createdAt }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedLedgerRow.status }}</el-descriptions-item></template>
|
||||
<template v-if="props.mode === 'receipts'"><el-descriptions-item label="账单日期">{{ selectedLedgerRow.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedLedgerRow.dueDate }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedLedgerRow.status) }}</el-descriptions-item></template>
|
||||
<template v-else><el-descriptions-item label="费用项目">{{ selectedLedgerRow.feeName }}</el-descriptions-item><el-descriptions-item label="成本类型">{{ selectedLedgerRow.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</el-descriptions-item><el-descriptions-item label="关联来源">{{ selectedLedgerRow.referenceNo }}</el-descriptions-item><el-descriptions-item label="应付金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已付金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未付金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="登记时间">{{ selectedLedgerRow.createdAt }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedLedgerRow.status) }}</el-descriptions-item></template>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<el-form v-else-if="drawerMode !== 'detail'" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || drawerLoading">
|
||||
@@ -572,7 +573,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
|
||||
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。">
|
||||
<label class="file-picker" :class="{ 'is-disabled': saving }"><FileUp :size="16" />{{ uploadedVoucher ? `已上传:${uploadedVoucher.fileName}` : file?.name || '选择凭证图片' }}<input type="file" accept="image/*" :disabled="saving" @change="selectFile" /></label>
|
||||
<p v-if="voucherError" class="voucher-error" role="alert">{{ voucherError }}</p>
|
||||
<p v-else-if="uploadedVoucher" class="voucher-success">凭证 fileId:{{ uploadedVoucher.fileId }},创建失败时可直接重试。</p>
|
||||
<p v-else-if="uploadedVoucher" class="voucher-success">凭证已上传,创建失败时可直接重试。</p>
|
||||
</FormSection>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : saving ? '上传并保存中' : '上传并保存' }}</el-button></div></template>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
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 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(selected.customerId)')
|
||||
expect(settlement).toContain('activeCustomers.value.map')
|
||||
expect(settlement).not.toContain('{{ selected.customerId }}')
|
||||
})
|
||||
|
||||
it('translates source codes and provides reason placeholders', () => {
|
||||
expect(arap).toContain('financeSourceTypeLabel(detail.sourceType)')
|
||||
expect(arap).toContain('financeOperationActionLabel(log.action)')
|
||||
expect(arap).toContain('financeOperationResultLabel(log.result)')
|
||||
expect(arap).toContain('请填写驳回原因')
|
||||
expect(arap).toContain('请说明参与或排除结算的原因')
|
||||
})
|
||||
|
||||
it('does not expose the uploaded voucher file ID in the form', () => {
|
||||
expect(manual).toContain('凭证已上传,创建失败时可直接重试')
|
||||
expect(manual).not.toContain('凭证 fileId:')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user