820 lines
51 KiB
Vue
820 lines
51 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ArrowLeft, Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
||
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 AdminPage from '@/shared/components/AdminPage.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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 { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
|
||
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
||
|
||
interface AdjustmentRow {
|
||
selected: boolean
|
||
statementLineId: string
|
||
feeItemName: string
|
||
originalAmount: number
|
||
adjustedAmount: number
|
||
reason: string
|
||
}
|
||
|
||
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
|
||
periodStart: string
|
||
periodEnd: string
|
||
creditDays: number | null
|
||
settled: boolean | null
|
||
lastPaymentAt: string
|
||
remark: string
|
||
}
|
||
|
||
const access = useAccessPreviewStore()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const customers = ref<PlatformTenantResource[]>([])
|
||
const filters = ref<QueryModel>({
|
||
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
|
||
settled: '', lastPaymentRange: [], createdRange: [],
|
||
})
|
||
const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||
const rows = ref<StatementRow[]>([])
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const loading = ref(false)
|
||
const error = ref<ApiErrorDetails | null>(null)
|
||
const customerError = ref<ApiErrorDetails | null>(null)
|
||
const selectedId = ref('')
|
||
const detail = ref<FinanceReconciliationDetail | null>(null)
|
||
const detailLoading = ref(false)
|
||
const detailError = ref<ApiErrorDetails | null>(null)
|
||
const activeDetailTab = ref<'lines' | 'payments'>('lines')
|
||
const detailCache = new Map<string, FinanceReconciliationDetail>()
|
||
const createOpen = ref(false)
|
||
const createBusy = ref(false)
|
||
const createSourcesLoading = ref(false)
|
||
const createSourcesError = ref<ApiErrorDetails | null>(null)
|
||
const createError = ref<ApiErrorDetails | null>(null)
|
||
const createSourceOptions = ref<FinanceFeeSourceSummary[]>([])
|
||
const exportBusy = ref(false)
|
||
const actionBusy = ref(false)
|
||
const createForm = ref({
|
||
generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
|
||
customerId: '',
|
||
periodStart: new Date().toISOString().slice(0, 8) + '01',
|
||
periodEnd: new Date().toISOString().slice(0, 10),
|
||
feeSourceIds: [] as string[],
|
||
remark: '',
|
||
})
|
||
let createSourceLoadSequence = 0
|
||
const adjustOpen = ref(false)
|
||
const adjustBusy = ref(false)
|
||
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
||
const adjustReason = ref('')
|
||
const adjustLines = ref<AdjustmentRow[]>([])
|
||
|
||
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
|
||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||
const canViewFeeSource = computed(() => access.can('platform:finance:customer-statement:list'))
|
||
const canSelectSources = computed(() => access.can('platform:finance:customer-arap:list'))
|
||
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
|
||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
|
||
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
|
||
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
|
||
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
|
||
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
|
||
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
|
||
const selectedCreateSources = computed(() => createSourceOptions.value.filter((item) => createForm.value.feeSourceIds.includes(item.feeSourceId)))
|
||
const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((total, item) => total + Number(item.amount || 0), 0))
|
||
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
|
||
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
|
||
const fields = computed<QueryField[]>(() => [
|
||
{ key: 'status', type: 'select', label: '对账单状态', width: 180, placeholder: '请选择对账单状态', options: financeBillStatusOptions },
|
||
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
|
||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
|
||
{ key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
|
||
{ key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
|
||
{ key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '请选择结清状态', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
|
||
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
|
||
])
|
||
const columns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
|
||
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus' },
|
||
{ key: 'billNo', label: '对账单号', minWidth: 180 },
|
||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||
{ key: 'periodStart', label: '结算起始日', width: 130 },
|
||
{ key: 'periodEnd', label: '结算结束日', width: 130 },
|
||
{ key: 'creditDays', label: '账期(天)', width: 100 },
|
||
{ key: 'lineCount', label: '费用明细数', width: 110 },
|
||
{ key: 'sourceCount', label: '来源数量', width: 100 },
|
||
{ key: 'currency', label: '货币', width: 86 },
|
||
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 },
|
||
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 },
|
||
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
|
||
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
|
||
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
|
||
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
|
||
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
|
||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
|
||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||
{ key: 'submittedAt', label: '提交时间', minWidth: 170, slotName: 'submittedAt' },
|
||
{ key: 'reviewedAt', label: '审核时间', minWidth: 170, slotName: 'reviewedAt' },
|
||
{ key: 'issuedAt', label: '出账时间', minWidth: 170, slotName: 'issuedAt' },
|
||
{ key: 'customerConfirmedByName', label: '客户确认人', width: 140, slotName: 'customerConfirmedByName' },
|
||
{ key: 'customerConfirmedAt', label: '客户确认时间', minWidth: 170, slotName: 'customerConfirmedAt' },
|
||
{ key: 'updatedByName', label: '更新人', width: 130, slotName: 'updatedByName' },
|
||
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
|
||
]
|
||
const detailLineColumns: DataTableColumn[] = [
|
||
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
|
||
{ key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||
{ key: 'sourceNo', label: '业务单号', minWidth: 160 },
|
||
{ key: 'orderId', label: '订单', minWidth: 150, slotName: 'orderId' },
|
||
{ key: 'inboundCode', label: '入仓号', minWidth: 150, slotName: 'inboundCode' },
|
||
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
|
||
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
|
||
{ key: 'feeItemName', label: '费用项目', minWidth: 160 },
|
||
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' },
|
||
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' },
|
||
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 },
|
||
{ key: 'amount', label: '账单金额(CNY)', width: 150 },
|
||
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
|
||
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
|
||
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
|
||
{ key: 'periodEnd', label: '结算结束日', width: 130, slotName: 'periodEnd' },
|
||
]
|
||
const paymentColumns: DataTableColumn[] = [
|
||
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
|
||
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
|
||
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
|
||
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
|
||
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
|
||
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
|
||
{ key: 'createdByName', label: '登记人', minWidth: 140, slotName: 'createdByName' },
|
||
{ key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' },
|
||
{ key: 'remark', label: '备注', minWidth: 200 },
|
||
]
|
||
|
||
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName.trim()])))
|
||
|
||
function isInternalCustomerLabel(value: string, customerId: string) {
|
||
const normalized = value.replaceAll(' ', '')
|
||
return normalized === customerId || /^客户(?:ID)?\d+$/.test(normalized)
|
||
}
|
||
|
||
function customerDisplayName(customerId: string, value?: string) {
|
||
const resolved = customerNameById.value.get(String(customerId))
|
||
if (resolved) return resolved
|
||
const candidate = String(value || '').trim()
|
||
return candidate && !isInternalCustomerLabel(candidate, String(customerId)) ? candidate : '客户名称未提供'
|
||
}
|
||
|
||
function operatorDisplayName(value?: string) {
|
||
const candidate = String(value || '').trim()
|
||
if (!candidate || /^(?:operator-)?\d+$/i.test(candidate)) return '名称数据缺失'
|
||
return candidate
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
|
||
}
|
||
|
||
function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconciliationDetail): StatementRow {
|
||
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || row.lastReceiptAt || ''
|
||
return {
|
||
...row,
|
||
periodStart: current?.periodStart || row.periodStart || '', periodEnd: current?.periodEnd || row.periodEnd || row.billDate,
|
||
creditDays: current ? current.creditDays : row.creditDays ?? null, settled: current ? current.settled : row.settled ?? null,
|
||
lastPaymentAt: latestPayment, remark: current?.remark || row.remark || '',
|
||
createdBy: current?.createdBy || row.createdBy, createdByName: current?.createdByName || row.createdByName, createdAt: current?.createdAt || row.createdAt,
|
||
updatedBy: current?.updatedBy || row.updatedBy, updatedByName: current?.updatedByName || row.updatedByName, updatedAt: current?.updatedAt || row.updatedAt,
|
||
}
|
||
}
|
||
|
||
function stringFilter(key: string) { return String(appliedFilters.value[key] || '').trim().toLowerCase() }
|
||
function inDateRange(value: string, key: string) {
|
||
const range = appliedFilters.value[key]
|
||
if (!Array.isArray(range) || range.length < 2 || !value) return !Array.isArray(range) || range.length < 2
|
||
const date = value.slice(0, 10)
|
||
return date >= String(range[0]) && date <= String(range[1])
|
||
}
|
||
const filteredRows = computed(() => rows.value.filter((row) => {
|
||
const status = stringFilter('status')
|
||
const billNo = stringFilter('billNo')
|
||
const settled = stringFilter('settled')
|
||
const containerNo = stringFilter('containerNo')
|
||
const current = detailCache.get(row.id)
|
||
return (!status || String(row.status).toLowerCase() === status)
|
||
&& (!billNo || row.billNo.toLowerCase().includes(billNo))
|
||
&& (!settled || String(row.settled) === settled)
|
||
&& (!containerNo || Boolean(current?.lines.some((line) => line.containerNo?.toLowerCase().includes(containerNo))))
|
||
&& inDateRange(row.periodStart, 'periodStartRange')
|
||
&& inDateRange(row.periodEnd, 'periodEndRange')
|
||
&& inDateRange(row.lastPaymentAt, 'lastPaymentRange')
|
||
&& inDateRange(row.createdAt || '', 'createdRange')
|
||
}))
|
||
const paginatedRows = computed(() => {
|
||
const start = (page.value - 1) * pageSize.value
|
||
return filteredRows.value.slice(start, start + pageSize.value)
|
||
})
|
||
|
||
async function loadCustomers() {
|
||
if (!canListCustomers.value) return
|
||
customerError.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 (cause) {
|
||
customerError.value = getApiErrorDetails(cause, '客户列表加载失败')
|
||
}
|
||
}
|
||
|
||
async function load(model?: QueryModel) {
|
||
const hydrateDetails = model !== undefined
|
||
if (model) {
|
||
filters.value = { ...model }
|
||
appliedFilters.value = { ...model }
|
||
page.value = 1
|
||
}
|
||
rows.value = []
|
||
selectedId.value = ''
|
||
detail.value = null
|
||
detailCache.clear()
|
||
error.value = null
|
||
loading.value = true
|
||
try {
|
||
const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined)
|
||
const detailErrors: ApiErrorDetails[] = []
|
||
const hydrated = await Promise.all(statements.map(async (row) => {
|
||
if (!hydrateDetails) return detailToRow(row)
|
||
try {
|
||
const current = await getReconciliationStatement(row.id)
|
||
detailCache.set(row.id, current)
|
||
return detailToRow(row, current)
|
||
} catch (cause) {
|
||
detailErrors.push(getApiErrorDetails(cause, `对账单 ${row.billNo || row.id} 的筛选字段加载失败`))
|
||
return detailToRow(row)
|
||
}
|
||
}))
|
||
if (detailErrors.length) {
|
||
error.value = detailErrors[0]!
|
||
return
|
||
}
|
||
rows.value = hydrated
|
||
} catch (cause) {
|
||
error.value = getApiErrorDetails(cause, '客户对账单加载失败,请重试')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function fetchDetail(statementId: string, force = false) {
|
||
if (!force && detailCache.has(statementId)) return detailCache.get(statementId)!
|
||
const current = await getReconciliationStatement(statementId)
|
||
detailCache.set(statementId, current)
|
||
return current
|
||
}
|
||
|
||
async function loadDetail(statementId: string) {
|
||
selectedId.value = statementId
|
||
detail.value = null
|
||
detailError.value = null
|
||
detailLoading.value = true
|
||
try {
|
||
detail.value = await fetchDetail(statementId, true)
|
||
} catch (cause) {
|
||
detailError.value = getApiErrorDetails(cause, '对账单明细加载失败,请重试')
|
||
} finally {
|
||
detailLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function openDetail(row: FinanceStatementReadModel) {
|
||
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: row.id } })
|
||
}
|
||
|
||
function retrySelectedDetail() {
|
||
if (selectedId.value) void loadDetail(selectedId.value)
|
||
}
|
||
|
||
async function returnToList() {
|
||
await router.push({ name: 'finance-customer-statements' })
|
||
}
|
||
|
||
function paymentMethodLabel(method: string) {
|
||
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[method] || method || '—'
|
||
}
|
||
|
||
function revisionActionLabel(action: string) {
|
||
return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record<string, string>)[action] || action || '—'
|
||
}
|
||
|
||
function revisionTime(actions: string[]) {
|
||
return detail.value?.revisions.filter((item) => actions.includes(item.action)).map((item) => item.occurredAt).sort().at(-1) || ''
|
||
}
|
||
|
||
function detailSourceAmount() {
|
||
return detail.value?.lines.reduce((total, line) => total + (line.sourceAmount ?? line.amount), 0) ?? 0
|
||
}
|
||
|
||
async function exportStatements() {
|
||
const customerId = String(filters.value.customerId || '').trim()
|
||
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
|
||
const parameters: Record<string, string | boolean> = {}
|
||
const putText = (key: string) => {
|
||
const value = String(filters.value[key] || '').trim()
|
||
if (value) parameters[key] = value
|
||
}
|
||
const putRange = (sourceKey: string, fromKey: string, toKey: string) => {
|
||
const value = filters.value[sourceKey]
|
||
if (!Array.isArray(value)) return
|
||
if (value[0]) parameters[fromKey] = String(value[0])
|
||
if (value[1]) parameters[toKey] = String(value[1])
|
||
}
|
||
if (customerId) parameters.customerId = customerId
|
||
putText('status')
|
||
putText('billNo')
|
||
putText('containerNo')
|
||
putRange('periodStartRange', 'periodStartFrom', 'periodStartTo')
|
||
putRange('periodEndRange', 'periodEndFrom', 'periodEndTo')
|
||
putRange('lastPaymentRange', 'lastPaymentFrom', 'lastPaymentTo')
|
||
putRange('createdRange', 'createdFrom', 'createdTo')
|
||
const settled = String(filters.value.settled || '')
|
||
if (settled) parameters.settled = settled === 'true'
|
||
|
||
exportBusy.value = true
|
||
try {
|
||
const task = await createPlatformExportTask({
|
||
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST',
|
||
format: 'XLSX',
|
||
taskName: '客户对账单',
|
||
parameters,
|
||
})
|
||
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
|
||
if (openDownloadCenter) await router.push({ name: 'download-center' })
|
||
} catch (cause) {
|
||
ElMessage.error(getApiErrorMessage(cause, '客户对账单导出任务创建失败'))
|
||
} finally {
|
||
exportBusy.value = false
|
||
}
|
||
}
|
||
|
||
function defaultCreateForm() {
|
||
return {
|
||
generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
|
||
customerId: '',
|
||
periodStart: new Date().toISOString().slice(0, 8) + '01',
|
||
periodEnd: new Date().toISOString().slice(0, 10),
|
||
feeSourceIds: [] as string[],
|
||
remark: '',
|
||
}
|
||
}
|
||
|
||
function openCreate() {
|
||
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
|
||
createForm.value = defaultCreateForm()
|
||
createSourceOptions.value = []
|
||
createSourcesError.value = null
|
||
createError.value = null
|
||
createOpen.value = true
|
||
}
|
||
|
||
async function loadEligibleCreateSources(preferredIds: string[] = createForm.value.feeSourceIds) {
|
||
const sequence = ++createSourceLoadSequence
|
||
createSourcesError.value = null
|
||
createSourceOptions.value = []
|
||
if (createForm.value.generationMode !== 'SELECTED' || !createForm.value.customerId
|
||
|| !createForm.value.periodStart || !createForm.value.periodEnd || !canSelectSources.value) {
|
||
createForm.value.feeSourceIds = []
|
||
return
|
||
}
|
||
createSourcesLoading.value = true
|
||
try {
|
||
const records: FinanceFeeSourceSummary[] = []
|
||
let currentPage = 1
|
||
let pages = 1
|
||
do {
|
||
const result = await listFinanceFeeSources({
|
||
page: currentPage,
|
||
size: 200,
|
||
customerId: createForm.value.customerId,
|
||
direction: 'RECEIVABLE',
|
||
status: 'AVAILABLE',
|
||
reviewStatus: 'APPROVED',
|
||
statementGenerated: false,
|
||
businessDateFrom: createForm.value.periodStart,
|
||
businessDateTo: createForm.value.periodEnd,
|
||
})
|
||
records.push(...result.records)
|
||
pages = Math.max(1, result.pages)
|
||
currentPage += 1
|
||
} while (currentPage <= pages)
|
||
if (sequence !== createSourceLoadSequence) return
|
||
createSourceOptions.value = records.filter((item) => item.direction === 'RECEIVABLE'
|
||
&& item.reviewStatus === 'APPROVED' && item.status === 'AVAILABLE'
|
||
&& !item.statementGenerated && item.amount > 0)
|
||
const availableIds = new Set(createSourceOptions.value.map((item) => item.feeSourceId))
|
||
createForm.value.feeSourceIds = preferredIds.filter((id) => availableIds.has(id))
|
||
if (preferredIds.length && createForm.value.feeSourceIds.length !== preferredIds.length) {
|
||
ElMessage.warning('部分带入费用已被占用或不再符合生成条件,请重新核对')
|
||
}
|
||
} catch (cause) {
|
||
if (sequence !== createSourceLoadSequence) return
|
||
createSourcesError.value = getApiErrorDetails(cause, '可生成费用加载失败')
|
||
createForm.value.feeSourceIds = []
|
||
} finally {
|
||
if (sequence === createSourceLoadSequence) createSourcesLoading.value = false
|
||
}
|
||
}
|
||
|
||
function queryText(value: unknown) {
|
||
return Array.isArray(value) ? String(value[0] || '') : String(value || '')
|
||
}
|
||
|
||
async function openSelectedCreateFromRoute() {
|
||
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
|
||
if (!canGenerate.value || !canSelectSources.value) {
|
||
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
|
||
return
|
||
}
|
||
const feeSourceIds = queryText(route.query.feeSourceIds).split(',').map((value) => value.trim()).filter((value) => /^[1-9]\d*$/.test(value))
|
||
const customerId = queryText(route.query.customerId)
|
||
const periodStart = queryText(route.query.periodStart)
|
||
const periodEnd = queryText(route.query.periodEnd)
|
||
if (!customerId || !feeSourceIds.length || !/^\d{4}-\d{2}-\d{2}$/.test(periodStart) || !/^\d{4}-\d{2}-\d{2}$/.test(periodEnd)) {
|
||
ElMessage.warning('带入的对账单生成参数不完整,请重新选择费用')
|
||
return
|
||
}
|
||
if (!activeCustomers.value.some((item) => String(item.id) === customerId)) {
|
||
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
|
||
return
|
||
}
|
||
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
|
||
await loadEligibleCreateSources(feeSourceIds)
|
||
createOpen.value = true
|
||
await router.replace({ name: 'finance-customer-statements' })
|
||
}
|
||
|
||
async function createStatement() {
|
||
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
|
||
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
|
||
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
|
||
if (createForm.value.generationMode === 'SELECTED' && !createForm.value.feeSourceIds.length) return ElMessage.warning('请选择至少一笔应收费用')
|
||
createBusy.value = true
|
||
try {
|
||
const created = await createReconciliationStatement({
|
||
customerId: String(createForm.value.customerId),
|
||
currency: 'CNY',
|
||
generationMode: createForm.value.generationMode,
|
||
feeSourceIds: createForm.value.generationMode === 'SELECTED' ? createForm.value.feeSourceIds : undefined,
|
||
periodStart: createForm.value.periodStart,
|
||
periodEnd: createForm.value.periodEnd,
|
||
remark: createForm.value.remark || undefined,
|
||
})
|
||
createOpen.value = false
|
||
ElMessage.success('对账单已生成')
|
||
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: created.id } })
|
||
} catch (cause) {
|
||
const details = getApiErrorDetails(cause, '创建对账单失败')
|
||
const noEligibleSources = details.code === 'FIN_409' && /没有符合结算条件|没有可生成/.test(details.message)
|
||
createError.value = noEligibleSources
|
||
? { ...details, message: '当前客户在所选账期内没有可生成的应收费用,请调整账期或选择其他客户后重试' }
|
||
: details
|
||
}
|
||
finally { createBusy.value = false }
|
||
}
|
||
|
||
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
|
||
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
|
||
? await ElMessageBox.prompt('请输入操作原因', '对账单操作', { inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
|
||
: undefined
|
||
if (reason === null) return
|
||
actionBusy.value = true
|
||
try {
|
||
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
|
||
ElMessage.success('对账单状态已更新')
|
||
await load(appliedFilters.value)
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
|
||
finally { actionBusy.value = false }
|
||
}
|
||
|
||
async function openAdjustment(row: FinanceStatementReadModel) {
|
||
adjustBusy.value = true
|
||
try {
|
||
const current = await fetchDetail(row.id, true)
|
||
adjustStatement.value = { ...row, version: current.version }
|
||
adjustReason.value = ''
|
||
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
|
||
adjustOpen.value = true
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
|
||
finally { adjustBusy.value = false }
|
||
}
|
||
|
||
async function submitAdjustment() {
|
||
const statement = adjustStatement.value
|
||
const selected = adjustLines.value.filter((line) => line.selected)
|
||
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细')
|
||
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
|
||
adjustBusy.value = true
|
||
try {
|
||
await actionReconciliationStatement(statement.id, {
|
||
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
|
||
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
|
||
})
|
||
adjustOpen.value = false
|
||
ElMessage.success('对账单调整已保存')
|
||
await load(appliedFilters.value)
|
||
await openDetail(statement)
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
|
||
finally { adjustBusy.value = false }
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (canListCustomers.value) await loadCustomers()
|
||
await openSelectedCreateFromRoute()
|
||
})
|
||
watch(
|
||
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
|
||
([open, mode]) => {
|
||
if (!open || mode !== 'SELECTED') return
|
||
void loadEligibleCreateSources()
|
||
},
|
||
)
|
||
watch(detailRouteId, (statementId) => {
|
||
if (statementId) void loadDetail(statementId)
|
||
else {
|
||
selectedId.value = ''
|
||
detail.value = null
|
||
detailError.value = null
|
||
void load()
|
||
}
|
||
}, { immediate: true })
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage as="div" class="finance-statements-page" spacing="normal">
|
||
<el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" />
|
||
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page" aria-label="客户对账单详情">
|
||
<template #header>
|
||
<header class="statement-detail-page__header">
|
||
<div><h2>客户对账单详情</h2><p>{{ detail?.billNo || '正在加载对账单信息' }}</p></div>
|
||
<el-button @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button>
|
||
</header>
|
||
</template>
|
||
<template v-if="detail" #summary>
|
||
<div class="statement-detail-head">
|
||
<div><span>客户对账单</span><h2>{{ detail.billNo }}</h2><p>{{ customerDisplayName(detail.customerId, detail.customerName) }} · {{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</p></div>
|
||
<el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
|
||
</div>
|
||
<FormSection class="statement-summary-section" title="对账单概要" aria-label="对账单摘要">
|
||
<div class="statement-summary-grid">
|
||
<div class="statement-summary-item"><span>总应收金额</span><strong>{{ detail.totalAmount.toFixed(2) }} CNY</strong></div>
|
||
<div class="statement-summary-item"><span>已收金额</span><strong>{{ detail.paidAmount.toFixed(2) }} CNY</strong></div>
|
||
<div class="statement-summary-item"><span>未收金额</span><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></div>
|
||
<div class="statement-summary-item"><span>账期</span><strong>{{ detail.creditDays }} 天</strong></div>
|
||
<div class="statement-summary-item"><span>费用明细 / 来源</span><strong>{{ detail.lines.length }} / {{ new Set(detail.lines.map((line) => line.sourceFeeId)).size }}</strong></div>
|
||
<div class="statement-summary-item"><span>调整前金额</span><strong>{{ detailSourceAmount().toFixed(2) }} CNY</strong></div>
|
||
<div class="statement-summary-item"><span>调整金额</span><strong>{{ (detail.totalAmount - detailSourceAmount()).toFixed(2) }} CNY</strong></div>
|
||
<div class="statement-summary-item"><span>货币</span><strong>{{ detail.currency }}</strong></div>
|
||
<div class="statement-summary-item"><span>是否结清</span><strong>{{ detail.settled ? '是' : '否' }}</strong></div>
|
||
<div class="statement-summary-item"><span>创建人</span><strong>{{ operatorDisplayName(detail.createdByName) }}</strong></div>
|
||
<div class="statement-summary-item"><span>创建时间</span><strong>{{ formatDateTime(detail.createdAt) }}</strong></div>
|
||
<div class="statement-summary-item"><span>提交时间</span><strong>{{ formatDateTime(revisionTime(['SUBMIT'])) }}</strong></div>
|
||
<div class="statement-summary-item"><span>审核时间</span><strong>{{ formatDateTime(revisionTime(['APPROVE', 'REJECT'])) }}</strong></div>
|
||
<div class="statement-summary-item"><span>出账时间</span><strong>{{ formatDateTime(revisionTime(['ISSUE'])) }}</strong></div>
|
||
<div class="statement-summary-item"><span>客户确认状态</span><strong>{{ financeStatusLabel(detail.customerConfirmationStatus || 'NOT_REQUIRED') }}</strong></div>
|
||
<div class="statement-summary-item"><span>客户确认人</span><strong>{{ operatorDisplayName(detail.customerConfirmedByName) }}</strong></div>
|
||
<div class="statement-summary-item"><span>客户确认时间</span><strong>{{ formatDateTime(detail.customerConfirmedAt) }}</strong></div>
|
||
<div class="statement-summary-item"><span>更新人</span><strong>{{ operatorDisplayName(detail.updatedByName) }}</strong></div>
|
||
<div class="statement-summary-item"><span>更新时间</span><strong>{{ formatDateTime(detail.updatedAt) }}</strong></div>
|
||
<div class="statement-summary-item statement-summary-item--remark"><span>备注</span><strong>{{ detail.remark || '—' }}</strong></div>
|
||
</div>
|
||
</FormSection>
|
||
</template>
|
||
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs">
|
||
<el-tab-pane label="账单明细表" name="lines">
|
||
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
|
||
<div class="detail-table-scroll">
|
||
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
|
||
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
|
||
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
|
||
<template #orderId="{ row }">{{ row.orderId ? `订单 ${row.orderId}` : '—' }}</template>
|
||
<template #inboundCode="{ row }">{{ row.inboundCode || '—' }}</template>
|
||
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</template>
|
||
<template #businessDate="{ row }">{{ row.businessDate || '—' }}</template>
|
||
<template #quantity="{ row }">{{ row.quantity ?? '—' }}</template>
|
||
<template #unitPrice="{ row }">{{ row.unitPrice ?? '—' }}</template>
|
||
<template #billNo>{{ detail.billNo }}</template>
|
||
<template #periodStart>{{ detail.periodStart || '—' }}</template>
|
||
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
|
||
</DataTable>
|
||
</div>
|
||
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
|
||
</FormSection>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="账单支付历史表" name="payments">
|
||
<FormSection v-if="detail" title="支付历史" :bordered="false">
|
||
<div class="detail-table-scroll">
|
||
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
|
||
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
|
||
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
|
||
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
|
||
<template #voucherFileIds="{ row }">{{ row.voucherFileIds.length ? `${row.voucherFileIds.length} 个附件` : (row.referenceNo || '—') }}</template>
|
||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
|
||
</DataTable>
|
||
</div>
|
||
</FormSection>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</DetailPageLayout>
|
||
<ListPageLayout v-else aria-label="客户对账单">
|
||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" :search-on-clear="false" @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>
|
||
<template v-if="!customerError && !error">
|
||
<section class="surface-panel table-panel">
|
||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
|
||
<template #toolbar-actions>
|
||
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
|
||
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
|
||
</template>
|
||
<template #actions="{ row }">
|
||
<el-button size="small" plain type="primary" :disabled="loading" @click.stop="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
|
||
<el-button v-if="row.status === 'DRAFT' && canCreate" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
|
||
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
|
||
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REJECT')"><XCircle :size="15" />驳回</el-button>
|
||
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && canReview && canVoid" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
|
||
<el-button size="small" plain :loading="actionBusy" aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal :size="16" /></el-button>
|
||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="VOID" class="danger-menu-item"><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
|
||
</el-dropdown>
|
||
<el-button v-if="row.status === 'PENDING_REVIEW' && !canReview && canVoid" size="small" plain type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||
<el-button v-if="row.status === 'APPROVED' && canIssue" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
|
||
<el-button v-if="row.status === 'ISSUED' && canAdjust" size="small" plain type="primary" :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
|
||
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" size="small" plain type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||
</template>
|
||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||
<template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template>
|
||
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
|
||
<template #lastPaymentAt="{ row }">{{ formatDateTime(row.lastPaymentAt) }}</template>
|
||
<template #settled="{ row }"><el-tag v-if="row.settled !== null" :type="row.settled ? 'success' : 'warning'">{{ row.settled ? '是' : '否' }}</el-tag><span v-else>—</span></template>
|
||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||
<template #submittedAt="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
|
||
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
|
||
<template #issuedAt="{ row }">{{ formatDateTime(row.issuedAt) }}</template>
|
||
<template #customerConfirmedByName="{ row }">{{ row.customerConfirmationStatus === 'CONFIRMED' ? operatorDisplayName(row.customerConfirmedByName) : '—' }}</template>
|
||
<template #customerConfirmedAt="{ row }">{{ formatDateTime(row.customerConfirmedAt) }}</template>
|
||
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
|
||
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||
</DataTable>
|
||
</section>
|
||
|
||
</template>
|
||
</ListPageLayout>
|
||
|
||
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
|
||
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
|
||
<el-form label-position="top">
|
||
<el-form-item label="生成方式">
|
||
<el-radio-group v-model="createForm.generationMode">
|
||
<el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button>
|
||
<el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item>
|
||
<div class="create-period-row"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
|
||
<template v-if="createForm.generationMode === 'SELECTED'">
|
||
<el-alert v-if="!canSelectSources" type="warning" :closable="false" title="当前账号没有查看费用来源的权限,不能指定费用生成。" />
|
||
<el-alert v-else-if="createSourcesError" type="error" :closable="false"><template #title>可生成费用加载失败</template><p>{{ createSourcesError.message }}</p><ApiErrorMeta :details="createSourcesError" /><el-button size="small" @click="loadEligibleCreateSources()">重试</el-button></el-alert>
|
||
<el-form-item label="应收费用">
|
||
<el-select v-model="createForm.feeSourceIds" multiple filterable collapse-tags :max-collapse-tags="3" :loading="createSourcesLoading" placeholder="请选择审核通过、已发布且未出账的费用">
|
||
<el-option v-for="item in createSourceOptions" :key="item.feeSourceId" :label="`${item.feeItemName} · ${item.businessDate} · ${Number(item.amount).toFixed(2)} CNY`" :value="item.feeSourceId" />
|
||
</el-select>
|
||
<div class="selected-source-summary">已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY</div>
|
||
</el-form-item>
|
||
</template>
|
||
<el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||
</el-form>
|
||
</AppDialog>
|
||
|
||
<AppDialog v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
|
||
<el-form label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></el-form>
|
||
<el-table :data="adjustLines" size="small" max-height="420" border stripe><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table>
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.finance-statements-page { min-width: 0; }
|
||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||
.create-period-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||
.selected-source-summary { width: 100%; margin-top: 6px; color: var(--cc-text-secondary); font-size: 12px; text-align: right; }
|
||
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
|
||
.statement-detail-page :deep(.detail-page-layout) { gap: 0; }
|
||
.statement-detail-page__header { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||
.statement-detail-page__header h2 { margin: 0; color: var(--cc-text-primary); font-size: 20px; }
|
||
.statement-detail-page__header p { margin: 4px 0 0; color: var(--cc-text-secondary); font-size: 13px; }
|
||
.statement-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||
.statement-detail-head > div > span { color: var(--cc-primary); font-size: 11px; font-weight: 700; }
|
||
.statement-detail-head h2 { margin: 2px 0 0; font-size: 16px; line-height: 1.4; overflow-wrap: anywhere; }
|
||
.statement-detail-head p { margin: 5px 0 0; color: var(--cc-text-secondary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||
.statement-summary-section { padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||
.statement-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px 24px; }
|
||
.statement-summary-item { min-width: 0; }
|
||
.statement-summary-item span, .statement-summary-item strong { display: block; overflow-wrap: anywhere; }
|
||
.statement-summary-item span { margin-bottom: 3px; color: var(--cc-text-secondary); font-size: 12px; }
|
||
.statement-summary-item strong { color: var(--cc-text-primary); font-size: 14px; line-height: 1.45; }
|
||
.statement-summary-item--remark { grid-column: span 2; }
|
||
.statement-outstanding { color: var(--el-color-warning-dark-2); }
|
||
.statement-detail-tabs { min-width: 0; }
|
||
.statement-detail-table { min-width: 0; }
|
||
.detail-table-scroll { min-width: 0; overflow-x: auto; }
|
||
.statement-detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 16px; }
|
||
.statement-detail-tabs :deep(.el-tabs__content) { min-width: 0; }
|
||
.statement-detail-tabs :deep(.form-section) { padding: 12px 16px 12px; }
|
||
.statement-line-table { min-width: 1840px; }
|
||
.statement-payment-table { min-width: 1300px; }
|
||
.revision-collapse { margin-top: 12px; }
|
||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||
|
||
@media (min-width: 1920px) {
|
||
.statement-detail-page :deep(.detail-page-layout) {
|
||
gap: 8px;
|
||
}
|
||
|
||
.statement-detail-page :deep(.detail-page-layout__header) {
|
||
min-height: 56px;
|
||
padding: 10px 14px;
|
||
}
|
||
|
||
.statement-detail-page__header h2 {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.statement-detail-head {
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
}
|
||
|
||
.statement-summary-section {
|
||
padding: 8px 14px;
|
||
}
|
||
|
||
.statement-summary-grid {
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 10px 24px;
|
||
}
|
||
|
||
.statement-summary-item--remark {
|
||
grid-column: span 2;
|
||
}
|
||
|
||
.statement-detail-tabs :deep(.el-tabs__header) {
|
||
padding: 0 14px;
|
||
}
|
||
|
||
.statement-detail-tabs :deep(.form-section) {
|
||
padding: 8px 14px;
|
||
}
|
||
|
||
.statement-detail-tabs :deep(.form-section__heading) {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.detail-table-scroll {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.revision-collapse {
|
||
margin-top: 8px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.statement-detail-page__header { align-items: stretch; flex-direction: column; }
|
||
.statement-summary-grid { grid-template-columns: 1fr; }
|
||
.statement-summary-item--remark { grid-column: auto; }
|
||
}
|
||
</style>
|