This commit is contained in:
2026-08-11 19:59:15 +08:00
parent c690a30422
commit 41618847b7
19 changed files with 117 additions and 142 deletions
@@ -17,7 +17,7 @@ import { financeBusinessTypeLabel, financeDocumentTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
const access = useAccessPreviewStore()
const filters = reactive<QueryModel>({ keyword: '', status: '' })
const filters = ref<QueryModel>({ keyword: '', status: '' })
const rows = ref<FinanceAccountSummary[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -55,13 +55,13 @@ const columns: DataTableColumn[] = [
{ key: 'lastChangedAt', label: '最近变动', width: 170 },
]
async function load(model: QueryModel = filters) {
Object.assign(filters, model)
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
if (!canView.value) return
loading.value = true
loadError.value = null
try {
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.keyword || '').trim(), status: String(filters.status || '') })
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.value.keyword || '').trim(), status: String(filters.value.status || '') })
rows.value = result.records
total.value = result.total
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
@@ -119,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>{{ 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-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><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>
@@ -14,7 +14,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 { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { financeArapStatusOptions, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
interface ArapRow {
@@ -38,7 +38,7 @@ const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = reactive<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' })
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' })
const allRows = ref<ArapRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -77,7 +77,7 @@ const columns: DataTableColumn[] = [
{ key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', width: 130 },
]
const filteredRows = computed(() => {
const keyword = String(filters.keyword || '').trim().toLowerCase(); const direction = String(filters.direction || ''); const status = String(filters.status || '')
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const status = String(filters.value.status || '')
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!status || row.status === status) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
})
const rows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
@@ -88,13 +88,13 @@ async function loadCustomers() {
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters) {
Object.assign(filters, model); page.value = 1; allRows.value = []; loadError.value = null
const customerId = String(filters.customerId || ''); if (!customerId) return
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model); page.value = 1; allRows.value = []; loadError.value = null
const customerId = String(filters.value.customerId || ''); if (!customerId) return
const customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || `客户 ${customerId}`
loading.value = true
try {
const direction = String(filters.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const items = await listFinanceFeePool(customerId, direction || undefined)
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, '客户应收应付加载失败,请重试') }
@@ -157,7 +157,7 @@ onMounted(() => void loadCustomers())
<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"><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 #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</template>
</DataTable></section>
@@ -165,7 +165,7 @@ onMounted(() => void loadCustomers())
<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>
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
</template>
<template #footer><el-button @click="drawerOpen = false">关闭</el-button></template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Plus } from '@lucide/vue'
import { computed, onMounted, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -14,14 +14,14 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
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 access = useAccessPreviewStore(); const customers = ref<PlatformTenantResource[]>([]); const filters = ref<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.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 loadCustomers() { customerError.value = null; 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.value) { filters.value = { ...model }; rows.value = []; error.value = null; if (!filters.value.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.value.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 #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>
<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 #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</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></template></AppDrawer></AdminPage>
</template>
<style scoped>.finance-statements-page{min-width:0}.blocked-alert{margin-top:16px}</style>
<style scoped>.finance-statements-page{min-width:0}</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Plus } from '@lucide/vue'
import { computed, onMounted, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -11,14 +11,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
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 { listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types'
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = reactive<QueryModel>({ customerId: '' })
const filters = ref<QueryModel>({ customerId: '' })
const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
@@ -28,14 +28,13 @@ const selected = ref<FinanceReceivableSettlement | FinancePayableSettlement | nu
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
const canView = computed(() => access.can(viewPermission.value))
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
const blocked = computed(() => props.kind === 'receivable' ? !financePhase1Capabilities.receivableSettlementCreate : !financePhase1Capabilities.payableSettlementCreate)
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: '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 } }
async function loadCustomers() { customerError.value = null; 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.value) { filters.value = { ...model }; rows.value = []; error.value = null; if (!filters.value.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.value.customerId)) : await listFinancePayableSettlements(String(filters.value.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 || '客户信息暂不可用' }
@@ -49,18 +48,16 @@ onMounted(() => void loadCustomers())
<template v-if="customerError || error" #state><LoadErrorState :title="`${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="`${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>
<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="客户">{{ 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>
</template>
<style scoped>.finance-settlement-page{min-width:0}.blocked-alert{margin-top:16px}@media(max-width:720px){:deep(.el-drawer__footer .el-button){min-height:44px}}</style>
<style scoped>.finance-settlement-page{min-width:0}@media(max-width:720px){:deep(.el-drawer__footer .el-button){min-height:44px}}</style>
@@ -44,7 +44,7 @@ const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const serverPaged = ref(false)
const filters = reactive<QueryModel>({ keyword: '', customerId: '', status: '' })
const filters = ref<QueryModel>({ keyword: '', customerId: '', status: '' })
const drawerOpen = ref(false)
const drawerMode = ref<DrawerMode>('create')
const drawerLoading = ref(false)
@@ -324,7 +324,7 @@ async function load() {
if (!canView.value) return
loading.value = true; loadError.value = null
try {
const customerId = String(filters.customerId || '').trim()
const customerId = String(filters.value.customerId || '').trim()
if (!customerId) { allRows.value = []; total.value = 0; return }
let source: TableRow[]
let responseTotal = 0
@@ -337,14 +337,14 @@ async function load() {
} else {
const result = await listFinancePayableSettlements(customerId); source = result.map(mapPayable); responseTotal = result.length; serverPaged.value = false
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
const status = String(filters.status || '')
const keyword = String(filters.value.keyword || '').trim().toLowerCase()
const status = String(filters.value.status || '')
allRows.value = source.filter((row) => (!keyword || Object.values(row).some((value) => String(value ?? '').toLowerCase().includes(keyword))) && (!status || row.status === status))
total.value = keyword || status ? allRows.value.length : responseTotal
} catch (error) { allRows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
finally { loading.value = false }
}
function applyFilters(model: QueryModel) { Object.assign(filters, model); page.value = 1; void load() }
function applyFilters(model: QueryModel) { Object.assign(filters.value, model); page.value = 1; void load() }
function changePageSize() { page.value = 1; void load() }
function openCreate() {
billingOrdersRequest += 1; receiptBillsRequest += 1; costOptionsRequest += 1
@@ -570,7 +570,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(可选)" /></el-form-item>
</template>
</FormSection>
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。">
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="上传真实收付款凭证后再保存财务记录。">
<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">凭证已上传,创建失败时可直接重试。</p>
@@ -3,6 +3,7 @@ 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 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')
@@ -17,6 +18,9 @@ describe('finance presentation governance', () => {
expect(arap).toContain('financeSourceTypeLabel(detail.sourceType)')
expect(arap).toContain('financeOperationActionLabel(log.action)')
expect(arap).toContain('financeOperationResultLabel(log.result)')
expect(arap).toContain('financeDirectionLabel(row.direction)')
expect(arap).toContain('financeDirectionLabel(detail.direction)')
expect(arap).not.toContain("detail.direction === 'RECEIVABLE' ? '应收' : '应付'")
expect(arap).toContain('请填写驳回原因')
expect(arap).toContain('请说明参与或排除结算的原因')
})
@@ -25,4 +29,11 @@ describe('finance presentation governance', () => {
expect(manual).toContain('凭证已上传,创建失败时可直接重试')
expect(manual).not.toContain('凭证 fileId:')
})
it('hides unavailable generation controls instead of rendering disabled buttons', () => {
expect(settlement).not.toContain('生成{{ title }}')
expect(settlement).not.toContain('接口尚未提供,暂不可操作')
expect(statements).not.toContain('生成对账单')
expect(statements).not.toContain('disabled title="接口尚未提供')
})
})
+4 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { financeBusinessTypeLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
import { financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
describe('finance status presentation', () => {
it.each([
@@ -32,6 +32,9 @@ describe('finance status presentation', () => {
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
expect(financeDirectionLabel('RECEIVABLE')).toBe('应收')
expect(financeDirectionLabel('PAYABLE')).toBe('应付')
expect(financeDirectionLabel('NEW_DIRECTION')).toBe('未知方向')
expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('其他来源')
})
})
+4
View File
@@ -72,6 +72,9 @@ const financeOperationActionLabels: Record<string, string> = {
const financeOperationResultLabels: Record<string, string> = {
SUCCESS: '操作成功', FAILED: '操作失败', PENDING: '待处理', PROCESSING: '处理中',
}
const financeDirectionLabels: Record<string, string> = {
RECEIVABLE: '应收', PAYABLE: '应付',
}
function codeLabel(value: unknown, labels: Record<string, string>, unknownLabel: string) {
const code = normalizeStatus(value)
@@ -84,3 +87,4 @@ export const financeBusinessTypeLabel = (value: unknown) => codeLabel(value, fin
export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '其他单据')
export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '其他操作')
export const financeOperationResultLabel = (value: unknown) => codeLabel(value, financeOperationResultLabels, '结果未知')
export const financeDirectionLabel = (value: unknown) => codeLabel(value, financeDirectionLabels, '未知方向')