642 lines
42 KiB
Vue
642 lines
42 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import '../financeDensity.css'
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ArrowLeft, Download, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { createPlatformExportTask } from '@/modules/downloads'
|
||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||
import FormSection from '@/shared/components/FormSection.vue'
|
||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
|
||
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
||
import type { FinanceFeeSourceDetail } from '../types'
|
||
|
||
interface ArapRow {
|
||
id: string
|
||
feeSourceId: string | null
|
||
sourceSystem: string
|
||
customerId: string
|
||
customerName: string
|
||
direction: 'RECEIVABLE' | 'PAYABLE'
|
||
documentNo: string
|
||
sourceBusinessId: string
|
||
sourceType: string
|
||
sourceId: string
|
||
orderId: string
|
||
inboundId: string
|
||
containerId: string
|
||
inboundCode: string
|
||
containerNo: string
|
||
supplierName: string
|
||
feeName: string
|
||
amount: number
|
||
status: string
|
||
occurredAt: string
|
||
businessDate: string
|
||
reviewStatus: string
|
||
statementGenerated: boolean | null
|
||
}
|
||
|
||
const access = useAccessPreviewStore()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const customers = ref<PlatformTenantResource[]>([])
|
||
const customersLoading = ref(false)
|
||
const customersError = ref<ApiErrorDetails | null>(null)
|
||
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
|
||
const allRows = ref<ArapRow[]>([])
|
||
const loading = ref(false)
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
const detailOpen = ref(false)
|
||
const selected = ref<ArapRow | null>(null)
|
||
const detail = ref<FinanceFeeSourceDetail | null>(null)
|
||
const operationLogRows = computed(() => (detail.value?.operationLogs || []).map((log, index) => ({ ...log, rowKey: index })))
|
||
const operationLogColumns: DataTableColumn[] = [
|
||
{ key: 'occurredAt', label: '时间', width: 170, slotName: 'occurredAt' },
|
||
{ key: 'action', label: '操作类型', minWidth: 120, slotName: 'action' },
|
||
{ key: 'operatorName', label: '操作人', minWidth: 120, slotName: 'operatorName' },
|
||
{ key: 'result', label: '备注 / 结果', minWidth: 280, slotName: 'result' },
|
||
]
|
||
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 mutationOpen = ref(false)
|
||
const mutationSaving = ref(false)
|
||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||
const mutationMode = ref<'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID'>('EDIT')
|
||
const mutationForm = reactive({ amount: 0, reason: '' })
|
||
const mutationTitle = computed(() => ({ EDIT: '编辑费用', ADJUST: '调整费用', SUBMIT: '提交审核', VOID: '作废费用' } as const)[mutationMode.value])
|
||
const selectedRows = ref<ArapRow[]>([])
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const total = ref(0)
|
||
const exportBusy = ref(false)
|
||
const customerSelectVisible = ref(false)
|
||
|
||
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
|
||
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
||
const canViewSource = computed(() => access.can('platform:finance:customer-arap:list'))
|
||
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
|
||
const canEditSource = computed(() => access.can('platform:finance:fee-source:edit'))
|
||
const canCreateStatement = computed(() => access.can('platform:finance:reconciliation:create'))
|
||
const canExport = computed(() => access.can('platform:finance:customer-arap:export') && access.can('platform:download:list'))
|
||
function isFirstlegSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
|
||
const system = String(source.sourceSystem || '').trim().toUpperCase()
|
||
const type = String(source.sourceType || '').trim().toUpperCase()
|
||
return system === 'FIRSTLEG' || ['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)
|
||
}
|
||
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': '客户名称',
|
||
class: 'finance-customer-select',
|
||
popperClass: 'finance-customer-arap-popper',
|
||
teleported: false,
|
||
filterable: true,
|
||
clearable: true,
|
||
loading: customersLoading.value,
|
||
onVisibleChange: onCustomerSelectVisible,
|
||
},
|
||
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: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
|
||
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions },
|
||
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
|
||
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
|
||
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '请输入业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
|
||
])
|
||
function onCustomerSelectVisible(visible: boolean) { customerSelectVisible.value = visible }
|
||
function closeCustomerSelectOnScroll() {
|
||
if (!customerSelectVisible.value) return
|
||
const active = document.activeElement
|
||
if (active instanceof HTMLElement) active.blur()
|
||
}
|
||
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
|
||
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
|
||
}
|
||
const columns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
|
||
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
|
||
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
|
||
]
|
||
function customerName(customerId: string, snapshot?: string) {
|
||
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
|
||
const candidate = String(snapshot || '').trim()
|
||
const placeholder = [String(customerId), `客户 ${customerId}`, `客户${customerId}`].includes(candidate)
|
||
return masterName || (candidate && !placeholder ? candidate : '客户名称暂不可用')
|
||
}
|
||
function sourceTypeDisplay(value: unknown) {
|
||
const code = String(value || '').trim().toUpperCase()
|
||
const labels: Record<string, string> = {
|
||
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_COST: '整柜成本', CONTAINER_FEE: '柜次费用',
|
||
MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
|
||
OVERSEAS_INBOUND_FEE: '海外入仓费用', OVERSEAS_PROCUREMENT_FEE: '海外采购费用',
|
||
OVERSEAS_TRUCK_FEE: '本土卡车费用', OVERSEAS_LOOSEBOARD_FEE: '本土散板费用',
|
||
OVERSEAS_WAREHOUSE_FEE: '海外仓服务费', OVERSEAS_CONTAINER_FEE: '海外柜子费用',
|
||
OVERSEAS_ORDER_FEE: '尾程订单费用', OVERSEAS_DELIVERY_FEE: '尾程派送费用',
|
||
OVERSEAS_RELABEL_FEE: '换标费用',
|
||
}
|
||
return labels[code] || (code ? '其他费用来源' : '来源类型暂不可用')
|
||
}
|
||
function feeDocumentLabel(direction: ArapRow['direction'], feeName: unknown) {
|
||
const prefix = direction === 'RECEIVABLE' ? '应收费用' : '应付费用'
|
||
const name = displayFeeName(feeName)
|
||
return name ? `${prefix} · ${name}` : prefix
|
||
}
|
||
function displayFeeName(value: unknown) {
|
||
const name = String(value || '').trim()
|
||
if (!name) return ''
|
||
if (/^ADMIN-API-\d+-整柜成本$/i.test(name)) return '整柜成本'
|
||
return name
|
||
}
|
||
function sourceSummary(sourceType: unknown, orderId: unknown, inboundId: unknown, containerId: unknown) {
|
||
const relations = [inboundId ? '关联入库单' : '', orderId ? '关联订单' : '', containerId ? '关联柜次' : ''].filter(Boolean)
|
||
const type = sourceTypeDisplay(sourceType)
|
||
return relations.length ? `${type} · ${relations.join('、')}` : type
|
||
}
|
||
function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 'sourceBusinessId'>) {
|
||
const references = [row.inboundCode ? `入仓码 ${row.inboundCode}` : '', row.containerNo ? `柜次 ${row.containerNo}` : '']
|
||
const businessId = row.sourceBusinessId.trim()
|
||
if (businessId && ![row.inboundCode, row.containerNo].includes(businessId)) references.push(`业务单号 ${businessId}`)
|
||
return references.filter(Boolean).join(' · ') || '业务关联待补充'
|
||
}
|
||
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
|
||
function sourceStatusDisplay(value: unknown) {
|
||
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
|
||
return labels[String(value || '').toUpperCase()] || '状态待确认'
|
||
}
|
||
function formatDateTime(value: unknown) {
|
||
const raw = String(value || '').trim()
|
||
if (!raw) return '时间暂不可用'
|
||
const date = new Date(raw)
|
||
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
|
||
}
|
||
function formatBusinessDate(value: unknown) {
|
||
const raw = String(value || '').trim()
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return '业务日期待补充'
|
||
const [year, month, day] = raw.split('-')
|
||
return `${year}/${Number(month)}/${Number(day)}`
|
||
}
|
||
function displayOperator(value: unknown) {
|
||
const operator = String(value || '').trim()
|
||
if (!operator) return '名称数据缺失'
|
||
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
|
||
}
|
||
const rows = computed(() => allRows.value)
|
||
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + row.amount, 0))
|
||
|
||
async function loadCustomers() {
|
||
if (!canListCustomers.value) return
|
||
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, status: 'ACTIVE' }); 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 }
|
||
}
|
||
async function load(model: QueryModel = filters.value) {
|
||
Object.assign(filters.value, model); allRows.value = []; loadError.value = null
|
||
const customerId = String(filters.value.customerId || '').trim()
|
||
loading.value = true
|
||
try {
|
||
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
|
||
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||
const result = await listFinanceFeeSources({
|
||
page: page.value, size: pageSize.value, customerId: customerId || undefined, direction: direction || undefined,
|
||
status: String(filters.value.status || '') || undefined,
|
||
reviewStatus: (String(filters.value.reviewStatus || '') || undefined) as 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | undefined,
|
||
statementGenerated: String(filters.value.statementGenerated || '') ? String(filters.value.statementGenerated) === 'true' : undefined,
|
||
businessDateFrom: range[0], businessDateTo: range[1], keyword: String(filters.value.keyword || '').trim() || undefined,
|
||
})
|
||
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, sourceSystem: item.sourceSystem, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, statementGenerated: item.statementGenerated }))
|
||
total.value = result.total
|
||
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
|
||
finally { loading.value = false }
|
||
}
|
||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||
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: ArapRow) {
|
||
if (!row.feeSourceId) return
|
||
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: row.feeSourceId } })
|
||
}
|
||
async function openReview(row: ArapRow) {
|
||
selected.value = row
|
||
const loaded = await loadDetail(row)
|
||
if (!loaded) { detailOpen.value = true; return }
|
||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; mutationError.value = null; reviewOpen.value = true
|
||
}
|
||
async function submitReview() {
|
||
if (reviewSaving.value || !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 openMutation(row: ArapRow, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
|
||
selected.value = row
|
||
if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(row)) {
|
||
ElMessage.info(String(row.sourceSystem || '').toUpperCase() === 'LASTLEG'
|
||
? '请到对应海外业务费用中调整或作废,审核后会通过跨区事件同步到财务'
|
||
: '请到订单入库费用或柜次费用明细中调整或作废,审核后会自动同步到财务')
|
||
return
|
||
}
|
||
const loaded = await loadDetail(row)
|
||
if (!loaded) { detailOpen.value = true; return }
|
||
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; reviewError.value = null; mutationOpen.value = true
|
||
}
|
||
function sourceActions(row: ArapRow): RowActionItem[] {
|
||
const available = row.status === 'AVAILABLE'
|
||
const draft = ['DRAFT', 'REJECTED'].includes(row.reviewStatus)
|
||
const editable = canEditSource.value && !isUpstreamSource(row)
|
||
return [
|
||
{ key: 'detail', label: '详情', priority: 300, visible: canViewSource.value },
|
||
{ key: 'edit', label: '编辑', priority: 250, visible: editable && draft && available },
|
||
{ key: 'submit', label: '提交审核', priority: 200, tone: 'warning', visible: canEditSource.value && draft && available },
|
||
{ key: 'review', label: '审核', priority: 200, tone: 'warning', visible: canReviewSource.value && row.reviewStatus === 'PENDING' && available },
|
||
{ key: 'adjust', label: '调整', priority: 150, visible: editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated },
|
||
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: editable && available && !row.statementGenerated },
|
||
]
|
||
}
|
||
function handleSourceAction(command: string, row: ArapRow) {
|
||
if (command === 'detail') void openDetail(row)
|
||
else if (command === 'review') void openReview(row)
|
||
else if (command === 'edit') void openMutation(row, 'EDIT')
|
||
else if (command === 'submit') void openMutation(row, 'SUBMIT')
|
||
else if (command === 'adjust') void openMutation(row, 'ADJUST')
|
||
else if (command === 'void') void openMutation(row, 'VOID')
|
||
}
|
||
async function submitMutation() {
|
||
if (mutationSaving.value || !detail.value) return
|
||
if (!mutationForm.reason.trim()) { ElMessage.warning('请填写操作原因'); return }
|
||
if ((mutationMode.value === 'EDIT' || mutationMode.value === 'ADJUST') && mutationForm.amount <= 0) { ElMessage.warning('金额必须大于 0'); return }
|
||
mutationSaving.value = true; mutationError.value = null
|
||
try {
|
||
const action = { reason: mutationForm.reason.trim(), expectedVersion: detail.value.operationVersion }
|
||
if (mutationMode.value === 'EDIT') detail.value = await editFinanceFeeSource(detail.value.feeSourceId, { ...action, amount: mutationForm.amount })
|
||
else if (mutationMode.value === 'ADJUST') detail.value = await adjustFinanceFeeSource(detail.value.feeSourceId, { ...action, amount: mutationForm.amount })
|
||
else if (mutationMode.value === 'SUBMIT') detail.value = await submitFinanceFeeSource(detail.value.feeSourceId, action)
|
||
else detail.value = await voidFinanceFeeSource(detail.value.feeSourceId, action)
|
||
mutationOpen.value = false; ElMessage.success(({ EDIT: '费用已保存为草稿', ADJUST: '费用调整已保存,待重新提交审核', SUBMIT: '费用已提交审核', VOID: '费用已作废' } as const)[mutationMode.value]); await load()
|
||
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用操作失败,请重试') }
|
||
finally { mutationSaving.value = false }
|
||
}
|
||
function statusLabel(status: string) { return financeStatusLabel(status) }
|
||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
|
||
function isSelectableReceivable(row: ArapRow) {
|
||
return Boolean(row.feeSourceId)
|
||
&& row.direction === 'RECEIVABLE'
|
||
&& row.reviewStatus === 'APPROVED'
|
||
&& row.status === 'AVAILABLE'
|
||
&& row.amount > 0
|
||
&& row.statementGenerated === false
|
||
}
|
||
async function continueSelectedGeneration() {
|
||
if (!selectedRows.value.length) return ElMessage.warning('请先勾选需要生成对账单的应收费用')
|
||
if (selectedRows.value.some((row) => !isSelectableReceivable(row))) return ElMessage.warning('所选费用中存在不可生成对账单的记录,请刷新后重新选择')
|
||
const customerIds = [...new Set(selectedRows.value.map((row) => row.customerId))]
|
||
if (customerIds.length !== 1) return ElMessage.warning('一次只能选择同一客户的费用生成对账单')
|
||
const businessDates = selectedRows.value.map((row) => row.businessDate).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value)).sort()
|
||
if (businessDates.length !== selectedRows.value.length) return ElMessage.warning('所选费用缺少有效业务日期,暂不能生成对账单')
|
||
await router.push({
|
||
name: 'finance-customer-statements',
|
||
query: {
|
||
createMode: 'SELECTED',
|
||
customerId: customerIds[0],
|
||
feeSourceIds: selectedRows.value.map((row) => row.feeSourceId as string).join(','),
|
||
periodStart: businessDates[0],
|
||
periodEnd: businessDates.at(-1),
|
||
},
|
||
})
|
||
}
|
||
async function exportFeeSources() {
|
||
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||
const customerId = String(filters.value.customerId || '').trim()
|
||
const direction = String(filters.value.direction || '').trim()
|
||
const reviewStatus = String(filters.value.reviewStatus || '').trim()
|
||
const generated = String(filters.value.statementGenerated || '')
|
||
const keyword = String(filters.value.keyword || '').trim()
|
||
exportBusy.value = true
|
||
try {
|
||
const task = await createPlatformExportTask({
|
||
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源',
|
||
parameters: {
|
||
...(customerId ? { customerId } : {}),
|
||
...(direction ? { direction } : {}),
|
||
...(reviewStatus ? { reviewStatus } : {}),
|
||
...(generated ? { statementGenerated: generated === 'true' } : {}),
|
||
...(range[0] ? { businessDateFrom: range[0] } : {}),
|
||
...(range[1] ? { businessDateTo: range[1] } : {}),
|
||
...(keyword ? { keyword } : {}),
|
||
},
|
||
})
|
||
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
|
||
if (openDownloadCenter) await router.push({ name: 'download-center' })
|
||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户应收应付导出任务创建失败')) }
|
||
finally { exportBusy.value = false }
|
||
}
|
||
async function closeDetail() { detailOpen.value = false; selected.value = null; detail.value = null; await router.push({ name: 'finance-customer-arap' }) }
|
||
async function restoreFeeSourceDetail(feeSourceId: string) {
|
||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||
try {
|
||
detail.value = await getFinanceFeeSource(feeSourceId)
|
||
selected.value = { id: detail.value.poolItemId, feeSourceId, sourceSystem: detail.value.sourceSystem, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, statementGenerated: detail.value.statementGenerated }
|
||
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
|
||
finally { detailLoading.value = false }
|
||
}
|
||
|
||
onMounted(async () => {
|
||
document.addEventListener('scroll', closeCustomerSelectOnScroll, true)
|
||
const feeSourceId = String(route.params.feeSourceId || '')
|
||
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return }
|
||
if (canListCustomers.value) await loadCustomers()
|
||
await load()
|
||
})
|
||
onBeforeUnmount(() => document.removeEventListener('scroll', closeCustomerSelectOnScroll, true))
|
||
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
|
||
if (!previous) return
|
||
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
|
||
else { detailOpen.value = false; selected.value = null; detail.value = null; void load() }
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage as="div" class="finance-arap-page admin-density" spacing="normal">
|
||
<OperationErrorToast :details="reviewError || mutationError" />
|
||
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
|
||
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
|
||
|
||
|
||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customersError && !loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||
<template #toolbar-start><h1>客户应收应付</h1></template>
|
||
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
|
||
<template #notices><template v-if="stateError"><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
|
||
|
||
|
||
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
|
||
<template #actions="{ row }"><RowActions text-actions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)" :more-label="`更多费用来源操作 · ${(row as ArapRow).documentNo || '当前费用'}`" @action="handleSourceAction($event, row as ArapRow)" /><span v-else class="muted-action">手工成本</span></template>
|
||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
||
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
|
||
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
|
||
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
|
||
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
|
||
</DataTable></section>
|
||
</ListPageLayout>
|
||
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
|
||
<section class="surface-panel finance-arap-detail__surface">
|
||
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
|
||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
|
||
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
|
||
<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"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
|
||
</DetailPageLayout>
|
||
</section>
|
||
</AdminPage>
|
||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" :title="`费用来源审核 · ${selected?.documentNo || '费用来源'}`" fullscreenable confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||
<div class="density-dialog-sections">
|
||
<section v-if="detail" class="density-dialog-section" data-tone="source" aria-label="费用来源资料">
|
||
<h3 class="density-dialog-section__title">费用来源</h3>
|
||
<table class="density-table fee-dialog-facts"><tbody>
|
||
<tr><th scope="row">客户</th><td colspan="3">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</td></tr>
|
||
<tr><th scope="row">费用项目</th><td>{{ displayFeeName(detail.feeItemName) || '—' }}</td><th scope="row">金额(CNY)</th><td>{{ money(detail.amount) }}</td></tr>
|
||
<tr><th scope="row">关联业务</th><td colspan="3">{{ businessReference(detail) }}</td></tr>
|
||
<tr><th scope="row">收付方向</th><td>{{ financeDirectionLabel(detail.direction) }}</td><th scope="row">审核状态</th><td>{{ statusLabel(detail.reviewStatus) }}</td></tr>
|
||
</tbody></table>
|
||
</section>
|
||
<section class="density-dialog-section" data-tone="work" aria-label="费用审核登记"><h3 class="density-dialog-section__title">审核登记</h3>
|
||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="reviewSaving"><table class="density-table density-table--editable"><tbody>
|
||
<tr><td><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item></td></tr>
|
||
<tr><td><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'" :required="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></td></tr>
|
||
</tbody></table></el-form>
|
||
</section>
|
||
</div>
|
||
</AppDialog>
|
||
<AppDialog class="admin-density-dialog" v-model="mutationOpen" :title="`${mutationTitle} · ${selected?.documentNo || '费用来源'}`" fullscreenable :confirm-type="mutationMode === 'VOID' ? 'danger' : 'primary'" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
|
||
<div class="density-dialog-sections">
|
||
<section v-if="detail" class="density-dialog-section" data-tone="source" aria-label="费用来源资料">
|
||
<h3 class="density-dialog-section__title">费用来源</h3>
|
||
<table class="density-table fee-dialog-facts"><tbody>
|
||
<tr><th scope="row">客户</th><td colspan="3">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</td></tr>
|
||
<tr><th scope="row">费用项目</th><td>{{ displayFeeName(detail.feeItemName) || '—' }}</td><th scope="row">金额(CNY)</th><td>{{ money(detail.amount) }}</td></tr>
|
||
<tr><th scope="row">关联业务</th><td colspan="3">{{ businessReference(detail) }}</td></tr>
|
||
<tr><th scope="row">收付方向</th><td>{{ financeDirectionLabel(detail.direction) }}</td><th scope="row">审核状态</th><td>{{ statusLabel(detail.reviewStatus) }}</td></tr>
|
||
</tbody></table>
|
||
</section>
|
||
<section class="density-dialog-section" data-tone="work" aria-label="费用操作登记"><h3 class="density-dialog-section__title">{{ mutationTitle }}</h3>
|
||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="mutationSaving"><table class="density-table density-table--editable"><tbody>
|
||
<tr v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'"><td><el-form-item label="金额(CNY)" required><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" placeholder="请输入调整金额" /></el-form-item></td></tr>
|
||
<tr><td><el-form-item label="操作原因" required><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></td></tr>
|
||
</tbody></table></el-form>
|
||
</section>
|
||
</div>
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.fee-dialog-facts { table-layout: fixed; }
|
||
.fee-dialog-facts th { width: var(--admin-label-width); }
|
||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||
.finance-arap-page {
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Customer names can contain an order suffix; keep the selector readable without changing the query value. */
|
||
:deep(.finance-customer-select .el-select__selected-item) {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
:deep(.finance-customer-arap-popper) {
|
||
width: max(260px, min(360px, calc(100vw - 32px))) !important;
|
||
max-width: calc(100vw - 32px);
|
||
}
|
||
|
||
:deep(.finance-customer-arap-popper .el-select-dropdown__item) {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.finance-arap-detail {
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.finance-arap-detail__surface {
|
||
width: min(100%, 1920px);
|
||
margin: 0 auto;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.finance-arap-detail__layout {
|
||
width: 100%;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.generation-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--admin-space-3);
|
||
}
|
||
|
||
.muted-action {
|
||
color: var(--cc-muted);
|
||
}
|
||
|
||
.arap-detail-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||
gap: var(--admin-space-6);
|
||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||
}
|
||
|
||
.arap-information,
|
||
.arap-settlement,
|
||
.operation-log {
|
||
padding: var(--admin-space-4) 0;
|
||
}
|
||
|
||
.arap-settlement {
|
||
padding-left: var(--admin-space-6);
|
||
border-left: 1px solid var(--cc-border, #e3e8ef);
|
||
}
|
||
|
||
|
||
|
||
:deep(.arap-detail .el-descriptions__content) {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
:deep(.arap-detail .el-descriptions__label) {
|
||
width: 112px;
|
||
color: var(--cc-muted);
|
||
}
|
||
|
||
.operation-log :deep(.el-empty) {
|
||
padding: var(--admin-space-4) 0 2px;
|
||
}
|
||
|
||
.operation-log :deep(.el-empty__image) {
|
||
display: none;
|
||
}
|
||
|
||
.operation-log small {
|
||
display: block;
|
||
margin-top: var(--admin-space-1);
|
||
color: var(--cc-muted);
|
||
}
|
||
|
||
@media (min-width: 1920px) {
|
||
|
||
.arap-detail-grid {
|
||
gap: var(--admin-space-4);
|
||
}
|
||
|
||
.arap-settlement {
|
||
padding-left: var(--admin-space-4);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1080px) {
|
||
.finance-arap-detail__surface {
|
||
width: 100%;
|
||
}
|
||
|
||
.arap-metrics {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.arap-metric:nth-child(3) {
|
||
padding-left: 0;
|
||
border-left: 0;
|
||
}
|
||
|
||
.arap-detail-grid {
|
||
grid-template-columns: 1fr;
|
||
gap: 0;
|
||
}
|
||
|
||
.arap-settlement {
|
||
padding-left: 0;
|
||
border-left: 0;
|
||
border-top: 1px solid var(--cc-border, #e3e8ef);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.detail-heading {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.detail-actions {
|
||
margin-left: 0;
|
||
}
|
||
|
||
.arap-metrics {
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.arap-metric,
|
||
.arap-metric:first-child,
|
||
.arap-metric:nth-child(3) {
|
||
padding-left: 0;
|
||
}
|
||
|
||
.arap-metric + .arap-metric {
|
||
border-left: 0;
|
||
}
|
||
|
||
: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>
|