2026-08-11 18:06:35 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
2026-08-11 18:43:56 +08:00
|
|
|
import { Eye, FileCheck2, Link2 } from '@lucide/vue'
|
2026-08-11 19:35:28 +08:00
|
|
|
import { ElMessage } from 'element-plus'
|
2026-08-11 18:06:35 +08:00
|
|
|
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'
|
2026-08-11 19:35:28 +08:00
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
2026-08-11 18:06:35 +08:00
|
|
|
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'
|
2026-08-11 19:35:28 +08:00
|
|
|
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
2026-08-11 19:59:15 +08:00
|
|
|
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
2026-08-11 19:35:28 +08:00
|
|
|
import type { FinanceFeeSourceDetail } from '../types'
|
2026-08-11 18:06:35 +08:00
|
|
|
|
|
|
|
|
interface ArapRow {
|
|
|
|
|
id: string
|
2026-08-11 19:35:28 +08:00
|
|
|
feeSourceId: string | null
|
2026-08-11 18:06:35 +08:00
|
|
|
customerName: string
|
|
|
|
|
direction: 'RECEIVABLE' | 'PAYABLE'
|
|
|
|
|
documentNo: string
|
|
|
|
|
sourceType: string
|
|
|
|
|
sourceId: string
|
|
|
|
|
orderId: string
|
|
|
|
|
inboundId: string
|
|
|
|
|
containerId: string
|
|
|
|
|
feeName: string
|
|
|
|
|
amount: number
|
|
|
|
|
status: string
|
|
|
|
|
occurredAt: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
const customers = ref<PlatformTenantResource[]>([])
|
|
|
|
|
const customersLoading = ref(false)
|
|
|
|
|
const customersError = ref<ApiErrorDetails | null>(null)
|
2026-08-11 19:59:15 +08:00
|
|
|
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' })
|
2026-08-11 18:06:35 +08:00
|
|
|
const allRows = ref<ArapRow[]>([])
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
const drawerOpen = ref(false)
|
|
|
|
|
const selected = ref<ArapRow | null>(null)
|
2026-08-11 19:35:28 +08:00
|
|
|
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: '' })
|
2026-08-11 18:06:35 +08:00
|
|
|
const page = ref(1)
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
|
|
|
|
const canView = computed(() => access.can('platform:finance:bill:view'))
|
2026-08-11 18:43:56 +08:00
|
|
|
const canViewSource = computed(() => access.can('platform:finance:fee-source:view'))
|
|
|
|
|
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
|
|
|
|
|
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
|
2026-08-11 18:06:35 +08:00
|
|
|
const stateError = computed(() => customersError.value || loadError.value)
|
2026-08-11 19:35:28 +08:00
|
|
|
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
2026-08-11 18:06:35 +08:00
|
|
|
const queryFields = computed<QueryField[]>(() => [
|
2026-08-12 17:29:35 +08:00
|
|
|
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户', filterable: true, clearable: true, loading: customersLoading.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
2026-08-11 18:06:35 +08:00
|
|
|
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
|
2026-08-11 19:35:28 +08:00
|
|
|
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions },
|
2026-08-11 18:06:35 +08:00
|
|
|
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
|
|
|
|
|
])
|
|
|
|
|
const columns: DataTableColumn[] = [
|
2026-08-11 19:35:28 +08:00
|
|
|
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
2026-08-11 18:06:35 +08:00
|
|
|
{ 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 },
|
|
|
|
|
]
|
|
|
|
|
const filteredRows = computed(() => {
|
2026-08-11 19:59:15 +08:00
|
|
|
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const status = String(filters.value.status || '')
|
2026-08-11 18:06:35 +08:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
async function loadCustomers() {
|
|
|
|
|
customersLoading.value = true; customersError.value = null
|
|
|
|
|
try { const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1; do { const result = await listPlatformTenants({ page: currentPage, size: 200 }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages); customers.value = records }
|
|
|
|
|
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
|
|
|
|
|
finally { customersLoading.value = false }
|
|
|
|
|
}
|
2026-08-11 19:59:15 +08:00
|
|
|
async function load(model: QueryModel = filters.value) {
|
|
|
|
|
Object.assign(filters.value, model); page.value = 1; allRows.value = []; loadError.value = null
|
2026-08-12 17:29:35 +08:00
|
|
|
const customerId = String(filters.value.customerId || '').trim()
|
2026-08-11 18:06:35 +08:00
|
|
|
loading.value = true
|
|
|
|
|
try {
|
2026-08-11 19:59:15 +08:00
|
|
|
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
|
2026-08-12 17:29:35 +08:00
|
|
|
const items = await listFinanceFeePool(customerId || undefined, direction || undefined)
|
|
|
|
|
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName: customers.value.find((customer) => String(customer.id) === item.customerId)?.tenantName || `客户 ${item.customerId}`, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
|
2026-08-11 18:06:35 +08:00
|
|
|
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
|
|
|
|
|
finally { loading.value = false }
|
|
|
|
|
}
|
2026-08-11 19:35:28 +08:00
|
|
|
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) }
|
2026-08-11 18:06:35 +08:00
|
|
|
|
2026-08-12 17:29:35 +08:00
|
|
|
onMounted(async () => { await loadCustomers(); await load() })
|
2026-08-11 18:06:35 +08:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<AdminPage as="div" class="finance-arap-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="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>
|
2026-08-12 17:29:35 +08:00
|
|
|
<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="暂无客户应收应付数据">
|
2026-08-11 19:35:28 +08:00
|
|
|
<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>
|
2026-08-11 19:59:15 +08:00
|
|
|
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
2026-08-11 18:06:35 +08:00
|
|
|
<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>
|
2026-08-11 19:35:28 +08:00
|
|
|
<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">
|
2026-08-11 19:59:15 +08:00
|
|
|
<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>
|
2026-08-11 19:35:28 +08:00
|
|
|
<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>
|
2026-08-11 18:06:35 +08:00
|
|
|
</AdminPage>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.finance-arap-page { min-width: 0; }
|
2026-08-11 18:43:56 +08:00
|
|
|
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
|
|
|
|
.row-actions .el-button { margin-left: 0; }
|
2026-08-11 19:35:28 +08:00
|
|
|
.muted-action { color: var(--cc-muted); }
|
2026-08-11 18:06:35 +08:00
|
|
|
.arap-detail { margin-bottom: 16px; }
|
2026-08-11 19:35:28 +08:00
|
|
|
.operation-log h3 { margin: 0 0 14px; font-size: 15px; }
|
|
|
|
|
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
2026-08-11 18:06:35 +08:00
|
|
|
@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>
|