2026-08-11 18:43:56 +08:00
< script setup lang = "ts" >
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
import '../financeDensity.css'
2026-08-17 16:45:46 +08:00
import { computed , onMounted , ref , watch } from 'vue'
import { useRoute , useRouter } from 'vue-router'
2026-08-12 09:25:55 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
2026-09-18 16:48:40 +08:00
import { ArrowLeft , Download , Plus } from '@lucide/vue'
2026-08-11 18:43:56 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-17 17:49:10 +08:00
import { createPlatformExportTask } from '@/modules/downloads'
2026-08-11 18:43:56 +08:00
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
2026-08-17 17:49:10 +08:00
import { getApiErrorDetails , getApiErrorMessage , type ApiErrorDetails } from '@/shared/api/http'
2026-09-15 09:58:15 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-08-11 18:43:56 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-12 09:25:55 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-11 18:43:56 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-17 12:31:27 +08:00
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
2026-08-11 18:43:56 +08:00
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-09-18 16:48:40 +08:00
import RowActions , { type RowActionItem } from '@/shared/components/RowActions.vue'
2026-08-17 17:49:10 +08:00
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
2026-08-20 16:13:55 +08:00
import { actionReconciliationStatement , createReconciliationStatement , getReconciliationStatement , listFinanceFeeSources , listFinanceStatements } from '../api/finance'
2026-08-27 21:48:15 +08:00
import { financeBillStatusOptions , financeSourceTypeLabel , financeStatusLabel , financeStatusTone } from '../status'
2026-08-20 16:13:55 +08:00
import type { FinanceFeeSourceSummary , FinanceReconciliationDetail , FinanceStatementReadModel , ReconciliationActionPayload } from '../types'
2026-08-12 09:25:55 +08:00
interface AdjustmentRow {
selected : boolean
statementLineId : string
feeItemName : string
originalAmount : number
adjustedAmount : number
reason : string
}
2026-08-17 19:52:04 +08:00
type StatementRow = Omit < FinanceStatementReadModel , ' creditDays ' | ' settled ' > & {
2026-08-13 11:57:44 +08:00
periodStart : string
periodEnd : string
creditDays : number | null
settled : boolean | null
lastPaymentAt : string
remark : string
}
2026-08-12 09:25:55 +08:00
const access = useAccessPreviewStore ()
2026-08-17 16:45:46 +08:00
const route = useRoute ()
const router = useRouter ()
2026-08-12 09:25:55 +08:00
const customers = ref < PlatformTenantResource [] >([])
2026-08-13 11:57:44 +08:00
const filters = ref < QueryModel >({
status : '' , billNo : '' , customerId : '' , containerNo : '' , periodStartRange : [], periodEndRange : [],
settled : '' , lastPaymentRange : [], createdRange : [],
})
2026-08-27 21:48:15 +08:00
const appliedFilters = ref < QueryModel >({ ... filters . value })
2026-08-13 11:57:44 +08:00
const rows = ref < StatementRow [] >([])
2026-08-19 09:14:35 +08:00
const page = ref ( 1 )
const pageSize = ref ( 20 )
2026-08-12 09:25:55 +08:00
const loading = ref ( false )
const error = ref < ApiErrorDetails | null >( null )
const customerError = ref < ApiErrorDetails | null >( null )
2026-08-13 11:57:44 +08:00
const selectedId = ref ( '' )
2026-08-12 09:25:55 +08:00
const detail = ref < FinanceReconciliationDetail | null >( null )
2026-08-13 11:57:44 +08:00
const detailLoading = ref ( false )
2026-08-12 09:25:55 +08:00
const detailError = ref < ApiErrorDetails | null >( null )
2026-08-13 11:57:44 +08:00
const activeDetailTab = ref < 'lines' | 'payments' > ( 'lines' )
const detailCache = new Map < string , FinanceReconciliationDetail >()
2026-08-12 09:25:55 +08:00
const createOpen = ref ( false )
const createBusy = ref ( false )
2026-08-20 16:13:55 +08:00
const createSourcesLoading = ref ( false )
const createSourcesError = ref < ApiErrorDetails | null >( null )
2026-08-25 22:08:59 +08:00
const createError = ref < ApiErrorDetails | null >( null )
2026-08-20 16:13:55 +08:00
const createSourceOptions = ref < FinanceFeeSourceSummary [] >([])
2026-08-13 12:22:41 +08:00
const exportBusy = ref ( false )
2026-08-12 09:25:55 +08:00
const actionBusy = ref ( false )
2026-08-17 16:45:46 +08:00
const createForm = ref ({
2026-08-20 16:13:55 +08:00
generationMode : 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED' ,
2026-08-17 16:45:46 +08:00
customerId : '' ,
periodStart : new Date (). toISOString (). slice ( 0 , 8 ) + '01' ,
periodEnd : new Date (). toISOString (). slice ( 0 , 10 ),
2026-08-20 16:13:55 +08:00
feeSourceIds : [] as string [],
2026-08-17 16:45:46 +08:00
remark : '' ,
})
2026-08-20 16:13:55 +08:00
let createSourceLoadSequence = 0
2026-08-12 09:25:55 +08:00
const adjustOpen = ref ( false )
const adjustBusy = ref ( false )
const adjustStatement = ref < FinanceStatementReadModel | null >( null )
const adjustReason = ref ( '' )
const adjustLines = ref < AdjustmentRow [] >([])
2026-08-13 14:37:59 +08:00
const canView = computed (() => access . can ( 'platform:finance:customer-statement:list' ))
2026-08-13 11:57:44 +08:00
const canListCustomers = computed (() => access . can ( 'platform:customer:list' ))
2026-08-13 14:37:59 +08:00
const canViewFeeSource = computed (() => access . can ( 'platform:finance:customer-statement:list' ))
2026-08-20 16:13:55 +08:00
const canSelectSources = computed (() => access . can ( 'platform:finance:customer-arap:list' ))
2026-08-12 09:25:55 +08:00
const canCreate = computed (() => access . can ( 'platform:finance:reconciliation:create' ))
2026-08-17 16:45:46 +08:00
const activeCustomers = computed (() => customers . value . filter (( item ) => item . status === 'ACTIVE' ))
2026-08-12 09:25:55 +08:00
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' ))
2026-08-17 17:49:10 +08:00
const canExport = computed (() => access . can ( 'platform:finance:customer-statement:export' ) && access . can ( 'platform:download:list' ))
2026-08-13 11:57:44 +08:00
const canGenerate = computed (() => canCreate . value && canListCustomers . value )
2026-08-20 16:13:55 +08:00
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 ))
2026-08-17 16:45:46 +08:00
const detailRouteId = computed (() => route . name === 'finance-customer-statement-detail' ? String ( route . params . statementId || '' ) : '' )
const isDetailRoute = computed (() => Boolean ( detailRouteId . value ))
2026-08-13 11:57:44 +08:00
const fields = computed < QueryField [] >(() => [
2026-08-27 21:48:15 +08:00
{ key : 'status' , type : 'select' , label : '对账单状态' , width : 180 , placeholder : '请选择对账单状态' , options : financeBillStatusOptions },
2026-08-13 11:57:44 +08:00
{ key : 'billNo' , type : 'input' , label : '对账单号' , width : 210 , placeholder : '请输入对账单号' },
2026-08-26 14:33:13 +08:00
{ 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 ) })) },
2026-08-13 12:22:41 +08:00
{ key : 'containerNo' , type : 'input' , label : '柜次号' , width : 190 , placeholder : canViewFeeSource . value ? '请输入柜次号' : '缺少费用来源查看权限' , props : { disabled : ! canViewFeeSource . value } },
2026-08-26 14:33:13 +08:00
{ 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 : '请选择结束日期' } },
2026-08-13 11:57:44 +08:00
])
2026-08-12 09:25:55 +08:00
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 280 , fixed : 'left' , slotName : 'actions' },
2026-08-27 21:48:15 +08:00
{ key : 'status' , label : '对账单状态' , width : 120 , slotName : 'status' },
2026-08-19 09:14:35 +08:00
{ key : 'customerConfirmationStatus' , label : '客户确认' , width : 130 , slotName : 'customerConfirmationStatus' },
2026-08-12 09:25:55 +08:00
{ key : 'billNo' , label : '对账单号' , minWidth : 180 },
2026-08-14 14:27:03 +08:00
{ key : 'customerName' , label : '客户' , minWidth : 180 , slotName : 'customerName' },
2026-08-13 11:57:44 +08:00
{ key : 'periodStart' , label : '结算起始日' , width : 130 },
{ key : 'periodEnd' , label : '结算结束日' , width : 130 },
{ key : 'creditDays' , label : '账期(天)' , width : 100 },
2026-08-17 19:52:04 +08:00
{ key : 'lineCount' , label : '费用明细数' , width : 110 },
{ key : 'sourceCount' , label : '来源数量' , width : 100 },
2026-08-13 11:57:44 +08:00
{ key : 'currency' , label : '货币' , width : 86 },
2026-08-17 19:52:04 +08:00
{ key : 'sourceAmount' , label : '调整前金额(CNY)' , width : 150 },
{ key : 'adjustmentAmount' , label : '调整金额(CNY)' , width : 145 },
2026-08-13 11:57:44 +08:00
{ key : 'totalAmount' , label : '总应收金额(CNY)' , width : 155 },
{ key : 'receivedAmount' , label : '已收金额(CNY)' , width : 145 },
{ key : 'outstandingAmount' , label : '未收金额(CNY)' , width : 145 },
2026-08-14 14:27:03 +08:00
{ key : 'lastPaymentAt' , label : '最后收款时间' , minWidth : 170 , slotName : 'lastPaymentAt' },
2026-08-13 11:57:44 +08:00
{ key : 'settled' , label : '是否结清' , width : 100 , slotName : 'settled' },
{ key : 'remark' , label : '备注' , minWidth : 180 },
2026-08-17 16:45:46 +08:00
{ key : 'createdByName' , label : '创建人' , width : 130 , slotName : 'createdByName' },
2026-08-14 14:27:03 +08:00
{ key : 'createdAt' , label : '创建时间' , minWidth : 170 , slotName : 'createdAt' },
2026-08-17 19:52:04 +08:00
{ key : 'submittedAt' , label : '提交时间' , minWidth : 170 , slotName : 'submittedAt' },
{ key : 'reviewedAt' , label : '审核时间' , minWidth : 170 , slotName : 'reviewedAt' },
{ key : 'issuedAt' , label : '出账时间' , minWidth : 170 , slotName : 'issuedAt' },
2026-08-19 09:14:35 +08:00
{ key : 'customerConfirmedByName' , label : '客户确认人' , width : 140 , slotName : 'customerConfirmedByName' },
{ key : 'customerConfirmedAt' , label : '客户确认时间' , minWidth : 170 , slotName : 'customerConfirmedAt' },
2026-08-17 16:45:46 +08:00
{ key : 'updatedByName' , label : '更新人' , width : 130 , slotName : 'updatedByName' },
2026-08-14 14:27:03 +08:00
{ key : 'updatedAt' , label : '更新时间' , minWidth : 170 , slotName : 'updatedAt' },
2026-08-12 09:25:55 +08:00
]
2026-08-17 12:31:27 +08:00
const detailLineColumns : DataTableColumn [] = [
2026-08-17 19:52:04 +08:00
{ 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' },
2026-08-17 12:31:27 +08:00
{ 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 },
2026-08-17 19:52:04 +08:00
{ key : 'voucherFileIds' , label : '收款凭证' , minWidth : 130 , slotName : 'voucherFileIds' },
2026-08-17 16:45:46 +08:00
{ key : 'createdByName' , label : '登记人' , minWidth : 140 , slotName : 'createdByName' },
2026-08-17 12:31:27 +08:00
{ key : 'occurredAt' , label : '登记时间' , width : 170 , slotName : 'occurredAt' },
{ key : 'remark' , label : '备注' , minWidth : 200 },
]
2026-08-12 09:25:55 +08:00
2026-08-14 14:27:03 +08:00
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 ()
2026-08-17 16:45:46 +08:00
if ( ! candidate || /^(?:operator-)?\d+$/i . test ( candidate )) return '名称数据缺失'
return candidate
2026-08-14 14:27:03 +08:00
}
function formatDateTime ( value ?: string ) {
return value ? value . replace ( 'T' , ' ' ). replace ( /Z$/ , '' ). slice ( 0 , 16 ) : '—'
}
2026-08-13 11:57:44 +08:00
function detailToRow ( row : FinanceStatementReadModel , current ?: FinanceReconciliationDetail ) : StatementRow {
2026-08-17 19:52:04 +08:00
const latestPayment = current ? . paymentHistory . map (( item ) => item . occurredAt || item . transactionDate ). filter ( Boolean ). sort (). at ( - 1 ) || row . lastReceiptAt || ''
2026-08-13 11:57:44 +08:00
return {
... row ,
2026-08-17 19:52:04 +08:00
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 || '' ,
2026-08-17 16:45:46 +08:00
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 ,
2026-08-13 11:57:44 +08:00
}
}
2026-08-27 21:48:15 +08:00
function stringFilter ( key : string ) { return String ( appliedFilters . value [ key ] || '' ). trim (). toLowerCase () }
2026-08-13 11:57:44 +08:00
function inDateRange ( value : string , key : string ) {
2026-08-27 21:48:15 +08:00
const range = appliedFilters . value [ key ]
2026-08-13 11:57:44 +08:00
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 )
2026-08-17 19:52:04 +08:00
&& ( ! containerNo || Boolean ( current ? . lines . some (( line ) => line . containerNo ? . toLowerCase (). includes ( containerNo ))))
2026-08-13 11:57:44 +08:00
&& inDateRange ( row . periodStart , 'periodStartRange' )
&& inDateRange ( row . periodEnd , 'periodEndRange' )
&& inDateRange ( row . lastPaymentAt , 'lastPaymentRange' )
&& inDateRange ( row . createdAt || '' , 'createdRange' )
}))
2026-08-19 09:14:35 +08:00
const paginatedRows = computed (() => {
const start = ( page . value - 1 ) * pageSize . value
return filteredRows . value . slice ( start , start + pageSize . value )
})
2026-08-13 11:57:44 +08:00
2026-08-12 09:25:55 +08:00
async function loadCustomers () {
2026-08-13 11:57:44 +08:00
if ( ! canListCustomers . value ) return
2026-08-12 09:25:55 +08:00
customerError . value = null
try {
2026-08-14 12:50:42 +08:00
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
2026-08-12 09:25:55 +08:00
} catch ( cause ) {
customerError . value = getApiErrorDetails ( cause , '客户列表加载失败' )
}
}
2026-08-13 11:57:44 +08:00
async function load ( model ?: QueryModel ) {
2026-09-09 15:26:43 +08:00
// The list response already contains every normal filter field. Only the
// container-number filter still depends on statement lines from the detail API.
// Avoid loading every statement detail for an ordinary search (N+1 requests).
const hydrateDetails = Boolean ( String ( model ? . containerNo || '' ). trim ())
2026-08-19 09:14:35 +08:00
if ( model ) {
filters . value = { ... model }
2026-08-27 21:48:15 +08:00
appliedFilters . value = { ... model }
2026-08-19 09:14:35 +08:00
page . value = 1
}
2026-08-12 09:25:55 +08:00
rows . value = []
2026-08-13 11:57:44 +08:00
selectedId . value = ''
detail . value = null
detailCache . clear ()
2026-08-12 09:25:55 +08:00
error . value = null
loading . value = true
2026-08-13 11:57:44 +08:00
try {
2026-08-27 21:48:15 +08:00
const statements = await listFinanceStatements ( String ( appliedFilters . value . customerId || '' ). trim () || undefined )
2026-08-13 11:57:44 +08:00
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
}
2026-08-12 09:25:55 +08:00
}
2026-08-13 11:57:44 +08:00
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
2026-08-12 09:25:55 +08:00
}
2026-08-17 16:45:46 +08:00
async function loadDetail ( statementId : string ) {
selectedId . value = statementId
2026-08-12 09:25:55 +08:00
detail . value = null
detailError . value = null
2026-08-13 11:57:44 +08:00
detailLoading . value = true
try {
2026-08-17 16:45:46 +08:00
detail . value = await fetchDetail ( statementId , true )
2026-08-13 11:57:44 +08:00
} catch ( cause ) {
detailError . value = getApiErrorDetails ( cause , '对账单明细加载失败,请重试' )
} finally {
detailLoading . value = false
}
}
2026-08-17 16:45:46 +08:00
async function openDetail ( row : FinanceStatementReadModel ) {
await router . push ({ name : 'finance-customer-statement-detail' , params : { statementId : row . id } })
2026-08-13 12:22:41 +08:00
}
function retrySelectedDetail () {
2026-08-17 16:45:46 +08:00
if ( selectedId . value ) void loadDetail ( selectedId . value )
2026-08-13 12:22:41 +08:00
}
2026-08-17 16:45:46 +08:00
async function returnToList () {
await router . push ({ name : 'finance-customer-statements' })
2026-08-13 11:57:44 +08:00
}
2026-08-13 12:22:41 +08:00
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 || '—'
}
2026-08-17 19:52:04 +08:00
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
}
2026-08-13 12:22:41 +08:00
async function exportStatements () {
2026-08-17 17:49:10 +08:00
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'
2026-08-13 12:22:41 +08:00
exportBusy . value = true
try {
2026-08-17 17:49:10 +08:00
const task = await createPlatformExportTask ({
exportType : 'FINANCE_RECONCILIATION_STATEMENT_LIST' ,
format : 'XLSX' ,
taskName : '客户对账单' ,
parameters ,
})
2026-09-15 09:58:15 +08:00
const openDownloadCenter = await showExportTaskCreatedDialog ({ taskName : task . taskName , taskNo : task . taskNo , dense : true })
2026-08-17 17:49:10 +08:00
if ( openDownloadCenter ) await router . push ({ name : 'download-center' })
2026-08-13 12:22:41 +08:00
} catch ( cause ) {
2026-08-17 17:49:10 +08:00
ElMessage . error ( getApiErrorMessage ( cause , '客户对账单导出任务创建失败' ))
} finally {
2026-08-13 12:22:41 +08:00
exportBusy . value = false
}
2026-08-13 11:57:44 +08:00
}
2026-08-20 16:13:55 +08:00
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 : '' ,
}
}
2026-08-13 11:57:44 +08:00
function openCreate () {
if ( ! canListCustomers . value ) return ElMessage . warning ( '缺少客户列表权限,无法选择客户生成对账单' )
2026-08-20 16:13:55 +08:00
createForm . value = defaultCreateForm ()
createSourceOptions . value = []
createSourcesError . value = null
2026-08-25 22:08:59 +08:00
createError . value = null
2026-08-13 11:57:44 +08:00
createOpen . value = true
2026-08-12 09:25:55 +08:00
}
2026-08-20 16:13:55 +08:00
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' })
}
2026-08-12 09:25:55 +08:00
async function createStatement () {
2026-08-17 16:45:46 +08:00
if ( ! createForm . value . customerId ) return ElMessage . warning ( '请先选择客户' )
2026-08-13 12:22:41 +08:00
if ( ! createForm . value . periodStart || ! createForm . value . periodEnd ) return ElMessage . warning ( '请选择完整账期' )
if ( createForm . value . periodStart > createForm . value . periodEnd ) return ElMessage . warning ( '账期开始日期不能晚于结束日期' )
2026-08-20 16:13:55 +08:00
if ( createForm . value . generationMode === 'SELECTED' && ! createForm . value . feeSourceIds . length ) return ElMessage . warning ( '请选择至少一笔应收费用' )
2026-08-12 09:25:55 +08:00
createBusy . value = true
try {
2026-08-20 16:13:55 +08:00
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 ,
})
2026-08-12 09:25:55 +08:00
createOpen . value = false
ElMessage . success ( '对账单已生成' )
2026-08-20 16:13:55 +08:00
await router . push ({ name : 'finance-customer-statement-detail' , params : { statementId : created . id } })
2026-08-25 22:08:59 +08:00
} catch ( cause ) {
const details = getApiErrorDetails ( cause , '创建对账单失败' )
const noEligibleSources = details . code === 'FIN_409' && /没有符合结算条件|没有可生成/ . test ( details . message )
createError . value = noEligibleSources
? { ... details , message : '当前客户在所选账期内没有可生成的应收费用,请调整账期或选择其他客户后重试' }
: details
}
2026-08-12 09:25:55 +08:00
finally { createBusy . value = false }
}
async function performAction ( row : FinanceStatementReadModel , actionName : ReconciliationActionPayload [ 'action' ]) {
const reason = [ 'REJECT' , 'VOID' , 'REOPEN' ]. includes ( actionName )
2026-09-15 09:58:15 +08:00
? await ElMessageBox . prompt ( '请输入操作原因' , '对账单操作' , { customClass : 'admin-density-confirm' , inputType : 'textarea' , inputPattern : /\S+/ , inputErrorMessage : '操作原因不能为空' , confirmButtonText : '确认' , cancelButtonText : '取消' }). then (( result ) => result . value ). catch (() => null )
2026-08-12 09:25:55 +08:00
: undefined
if ( reason === null ) return
actionBusy . value = true
try {
await actionReconciliationStatement ( row . id , { action : actionName , expectedVersion : row . version , ...( reason ? { reason } : {}) })
ElMessage . success ( '对账单状态已更新' )
2026-08-27 21:48:15 +08:00
await load ( appliedFilters . value )
2026-08-12 09:25:55 +08:00
} catch ( cause ) { error . value = getApiErrorDetails ( cause , '对账单操作失败' ) }
finally { actionBusy . value = false }
}
async function openAdjustment ( row : FinanceStatementReadModel ) {
adjustBusy . value = true
try {
2026-08-13 11:57:44 +08:00
const current = await fetchDetail ( row . id , true )
2026-08-12 09:25:55 +08:00
adjustStatement . value = { ... row , version : current . version }
adjustReason . value = ''
2026-08-13 11:57:44 +08:00
adjustLines . value = current . lines . flatMap (( line ) => line . statementLineId ? [{ selected : false , statementLineId : line . statementLineId , feeItemName : line . feeItemName , originalAmount : line . amount , adjustedAmount : line . amount , reason : '' }] : [])
2026-08-12 09:25:55 +08:00
adjustOpen . value = true
} catch ( cause ) { error . value = getApiErrorDetails ( cause , '调整明细加载失败' ) }
finally { adjustBusy . value = false }
}
2026-09-18 16:48:40 +08:00
function statementActions ( row : FinanceStatementReadModel ) : RowActionItem [] {
return [
{ key : 'detail' , label : '详情' , priority : 300 , disabled : loading . value },
{ key : 'submit' , label : '提交' , priority : 250 , visible : row . status === 'DRAFT' && canCreate . value , loading : actionBusy . value },
{ key : 'approve' , label : '通过' , priority : 250 , tone : 'warning' , visible : row . status === 'PENDING_REVIEW' && canReview . value , loading : actionBusy . value },
{ key : 'reject' , label : '驳回' , priority : 200 , tone : 'danger' , visible : row . status === 'PENDING_REVIEW' && canReview . value , loading : actionBusy . value },
{ key : 'issue' , label : '出账' , priority : 200 , visible : row . status === 'APPROVED' && canIssue . value , loading : actionBusy . value },
{ key : 'adjust' , label : '调整' , priority : 200 , visible : row . status === 'ISSUED' && canAdjust . value , loading : adjustBusy . value },
{ key : 'reopen' , label : '重开' , priority : 150 , visible : [ 'REJECTED' , 'VOIDED' ]. includes ( String ( row . status )) && canVoid . value , loading : actionBusy . value },
{ key : 'void' , label : '作废' , tone : 'danger' , divided : true , visible : [ 'DRAFT' , 'PENDING_REVIEW' , 'APPROVED' , 'ISSUED' ]. includes ( String ( row . status )) && canVoid . value , loading : actionBusy . value },
]
}
function handleStatementAction ( command : string , row : FinanceStatementReadModel ) {
if ( command === 'detail' ) void openDetail ( row )
else if ( command === 'adjust' ) void openAdjustment ( row )
else {
const actions : Record < string , ReconciliationActionPayload [' action '] > = {
submit : 'SUBMIT' , approve : 'APPROVE' , reject : 'REJECT' , issue : 'ISSUE' , reopen : 'REOPEN' , void : 'VOID' ,
}
const action = actions [ command ]
if ( action ) void performAction ( row , action )
}
}
2026-08-12 09:25:55 +08:00
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 , {
2026-08-13 11:57:44 +08:00
action : 'ADJUST' , reason : adjustReason . value . trim (), expectedVersion : statement . version ,
2026-08-12 09:25:55 +08:00
adjustmentLines : selected . map (( line ) => ({ statementLineId : line . statementLineId , adjustedAmount : line . adjustedAmount , reason : line . reason . trim () })),
})
adjustOpen . value = false
ElMessage . success ( '对账单调整已保存' )
2026-08-27 21:48:15 +08:00
await load ( appliedFilters . value )
2026-08-12 09:25:55 +08:00
await openDetail ( statement )
} catch ( cause ) { error . value = getApiErrorDetails ( cause , '对账单调整失败' ) }
finally { adjustBusy . value = false }
}
2026-08-20 16:13:55 +08:00
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 ()
},
)
2026-08-17 16:45:46 +08:00
watch ( detailRouteId , ( statementId ) => {
if ( statementId ) void loadDetail ( statementId )
else {
selectedId . value = ''
detail . value = null
detailError . value = null
void load ()
}
}, { immediate : true })
2026-08-11 18:43:56 +08:00
</ script >
2026-08-12 09:25:55 +08:00
2026-08-11 18:43:56 +08:00
< template >
2026-09-15 09:58:15 +08:00
< AdminPage as = "div" class = "finance-statements-page admin-density" spacing = "normal" >
2026-08-12 09:25:55 +08:00
< el-result v-if = "!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" />
2026-09-15 09:58:15 +08:00
< DetailPageLayout v-else-if = "isDetailRoute" class="surface-panel statement-detail-page finance-density-detail" aria-label="客户对账单详情" >
< template # header >< div class = "density-detail-header" >< div class = "density-detail-heading" >< el-button link class = "density-back" @click ="returnToList" >< ArrowLeft :size = "16" /> 返回客户对账单 </ el-button >< h1 class = "density-detail-title" >{{ detail ? . billNo || '—' }}</ h1 >< StatusBadge v-if = "detail" :status="financeStatusLabel(detail.status)" /></ div ></ div ></ template >
2026-08-17 16:45:46 +08:00
< template v-if = "detail" # summary >
2026-09-15 09:58:15 +08:00
< dl class = "density-detail-overview" >< div >< dt > 客户 </ dt >< dd >{{ customerDisplayName ( detail . customerId , detail . customerName ) }}</ dd ></ div >< div >< dt > 期间 </ dt >< dd >{{ detail . periodStart || '—' }} 至 {{ detail . periodEnd || '—' }}</ dd ></ div ></ dl >
< FormSection class = "statement-summary-section density-form-section" title = "对账单概要" aria -label =" 对账单摘要 " >
< dl class = "density-fields statement-summary-grid" >
< div class = "statement-summary-item" >< dt > 总应收金额 </ dt >< dd >{{ detail . totalAmount . toFixed ( 2 ) }} CNY </ dd ></ div >
< div class = "statement-summary-item" >< dt > 已收金额 </ dt >< dd >{{ detail . paidAmount . toFixed ( 2 ) }} CNY </ dd ></ div >
< div class = "statement-summary-item" >< dt > 未收金额 </ dt >< dd class = "statement-outstanding" >{{ detail . outstandingAmount . toFixed ( 2 ) }} CNY </ dd ></ div >
< div class = "statement-summary-item" >< dt > 账期 </ dt >< dd >{{ detail . creditDays }} 天 </ dd ></ div >
< div class = "statement-summary-item" >< dt > 费用明细 / 来源 </ dt >< dd >{{ detail . lines . length }} / {{ new Set ( detail . lines . map (( line ) => line . sourceFeeId )). size }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 调整前金额 </ dt >< dd >{{ detailSourceAmount (). toFixed ( 2 ) }} CNY </ dd ></ div >
< div class = "statement-summary-item" >< dt > 调整金额 </ dt >< dd >{{ ( detail . totalAmount - detailSourceAmount ()). toFixed ( 2 ) }} CNY </ dd ></ div >
< div class = "statement-summary-item" >< dt > 货币 </ dt >< dd >{{ detail . currency }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 是否结清 </ dt >< dd >{{ detail . settled ? '是' : '否' }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 创建人 </ dt >< dd >{{ operatorDisplayName ( detail . createdByName ) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 创建时间 </ dt >< dd >{{ formatDateTime ( detail . createdAt ) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 提交时间 </ dt >< dd >{{ formatDateTime ( revisionTime ([ 'SUBMIT' ])) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 审核时间 </ dt >< dd >{{ formatDateTime ( revisionTime ([ 'APPROVE' , 'REJECT' ])) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 出账时间 </ dt >< dd >{{ formatDateTime ( revisionTime ([ 'ISSUE' ])) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 客户确认状态 </ dt >< dd >{{ financeStatusLabel ( detail . customerConfirmationStatus || 'NOT_REQUIRED' ) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 客户确认人 </ dt >< dd >{{ operatorDisplayName ( detail . customerConfirmedByName ) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 客户确认时间 </ dt >< dd >{{ formatDateTime ( detail . customerConfirmedAt ) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 更新人 </ dt >< dd >{{ operatorDisplayName ( detail . updatedByName ) }}</ dd ></ div >
< div class = "statement-summary-item" >< dt > 更新时间 </ dt >< dd >{{ formatDateTime ( detail . updatedAt ) }}</ dd ></ div >
< div class = "statement-summary-item statement-summary-item--remark" >< dt > 备注 </ dt >< dd >{{ detail . remark || '—' }}</ dd ></ div >
</ dl >
2026-08-17 16:45:46 +08:00
</ FormSection >
</ template >
2026-09-15 09:58:15 +08:00
< el-tabs v-model = "activeDetailTab" class="statement-detail-tabs density-element-tabs density-contained-tabs" >
< el-tab-pane name = "lines" >< template # label >< span class = "density-tab-icon" aria -hidden =" true " > 📋 </ span >< span > 账单明细表 </ span ></ template >
2026-08-17 16:45:46 +08:00
< 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 />
2026-09-15 09:58:15 +08:00
< FormSection class = "density-form-section" v-else-if = "detail" title="账单明细" :bordered="false" >
2026-08-17 16:45:46 +08:00
< 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 =" 客户对账单账单明细 " >
2026-08-17 19:52:04 +08:00
< 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 >
2026-08-17 16:45:46 +08:00
< 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 >
2026-09-15 09:58:15 +08:00
< el-tab-pane name = "payments" >< template # label >< span class = "density-tab-icon" aria -hidden =" true " > 💰 </ span >< span > 账单支付历史表 </ span ></ template >
< FormSection class = "density-form-section" v-if = "detail" title="支付历史" :bordered="false" >
2026-08-17 16:45:46 +08:00
< 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 >
2026-08-17 19:52:04 +08:00
< template # voucherFileIds = "{ row }" >{{ row . voucherFileIds . length ? ` ${ row . voucherFileIds . length } 个附件` : ( row . referenceNo || '—' ) }}</ template >
2026-08-17 16:45:46 +08:00
< template # createdByName = "{ row }" >{{ operatorDisplayName ( row . createdByName ) }}</ template >
< template # occurredAt = "{ row }" >{{ formatDateTime ( row . occurredAt ) }}</ template >
</ DataTable >
</ div >
</ FormSection >
</ el-tab-pane >
</ el-tabs >
</ DetailPageLayout >
2026-08-12 09:25:55 +08:00
< ListPageLayout v-else aria -label =" 客户对账单 " >
2026-09-09 15:26:43 +08:00
< template # toolbar >< QueryToolbar v-model = "filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></ template >
2026-08-12 09:25:55 +08:00
< 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 >
2026-08-13 11:57:44 +08:00
< template v-if = "!customerError && !error" >
< section class = "surface-panel table-panel" >
2026-08-19 09:14:35 +08:00
< 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 " >
2026-08-13 11:57:44 +08:00
< template # toolbar -actions >
2026-08-17 17:49:10 +08:00
< el-button v-if = "canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" /> 导出 </ el-button >
2026-08-13 11:57:44 +08:00
< el-button v-if = "canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" /> 生成对账单 </ el-button >
</ template >
2026-09-18 16:48:40 +08:00
< template # actions = "{ row }" >< RowActions : actions = "statementActions(row as FinanceStatementReadModel)" : more -label ="` 更多对账单操作 · $ { ( row as FinanceStatementReadModel ) .billNo } `" @action ="handleStatementAction($event, row as FinanceStatementReadModel)" /></ template >
2026-08-13 11:57:44 +08:00
< template # status = "{ row }" >< el-tag :type = "financeStatusTone(row.status)" >{{ financeStatusLabel ( row . status ) }}</ el-tag ></ template >
2026-08-19 09:14:35 +08:00
< template # customerConfirmationStatus = "{ row }" >< el-tag : type = "financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')" >{{ financeStatusLabel ( row . customerConfirmationStatus || 'NOT_REQUIRED' ) }}</ el-tag ></ template >
2026-08-14 14:27:03 +08:00
< template # customerName = "{ row }" >{{ customerDisplayName ( row . customerId , row . customerName ) }}</ template >
< template # lastPaymentAt = "{ row }" >{{ formatDateTime ( row . lastPaymentAt ) }}</ template >
2026-08-13 11:57:44 +08:00
< template # settled = "{ row }" >< el-tag v-if = "row.settled !== null" :type="row.settled ? 'success' : 'warning'" >{{ row.settled ? ' 是 ' : ' 否 ' }} </ el -tag >< span v-else > — </ span ></ template >
2026-08-17 16:45:46 +08:00
< template # createdByName = "{ row }" >{{ operatorDisplayName ( row . createdByName ) }}</ template >
2026-08-14 14:27:03 +08:00
< template # createdAt = "{ row }" >{{ formatDateTime ( row . createdAt ) }}</ template >
2026-08-17 19:52:04 +08:00
< template # submittedAt = "{ row }" >{{ formatDateTime ( row . submittedAt ) }}</ template >
< template # reviewedAt = "{ row }" >{{ formatDateTime ( row . reviewedAt ) }}</ template >
< template # issuedAt = "{ row }" >{{ formatDateTime ( row . issuedAt ) }}</ template >
2026-08-19 09:14:35 +08:00
< template # customerConfirmedByName = "{ row }" >{{ row . customerConfirmationStatus === 'CONFIRMED' ? operatorDisplayName ( row . customerConfirmedByName ) : '—' }}</ template >
< template # customerConfirmedAt = "{ row }" >{{ formatDateTime ( row . customerConfirmedAt ) }}</ template >
2026-08-17 16:45:46 +08:00
< template # updatedByName = "{ row }" >{{ operatorDisplayName ( row . updatedByName ) }}</ template >
2026-08-14 14:27:03 +08:00
< template # updatedAt = "{ row }" >{{ formatDateTime ( row . updatedAt ) }}</ template >
2026-08-13 11:57:44 +08:00
< template # pagination -left > 共 {{ filteredRows . length }} 条 · 币种 : CNY </ template >
</ DataTable >
</ section >
2026-08-12 09:25:55 +08:00
</ template >
2026-08-13 11:57:44 +08:00
</ ListPageLayout >
2026-08-12 09:25:55 +08:00
2026-09-15 09:58:15 +08:00
< AppDialog class = "admin-density-dialog" v-model = "createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement" >
2026-08-25 22:08:59 +08:00
< el-alert v-if = "createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null" >< p >{{ createError . message }}</ p ></ el-alert >
2026-09-15 09:58:15 +08:00
< el-form class = "density-form" label -position =" top " >
2026-08-20 16:13:55 +08:00
< 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 >
2026-09-03 19:29:38 +08:00
< el-form-item label = "备注" >< el-input v-model = "createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></ el-form-item >
2026-08-20 16:13:55 +08:00
</ el-form >
</ AppDialog >
2026-08-12 09:25:55 +08:00
2026-09-15 09:58:15 +08:00
< AppDialog class = "admin-density-dialog" v-model = "adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment" >
< el-form class = "density-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 >
2026-09-03 19:29:38 +08:00
< 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 >
2026-08-12 09:25:55 +08:00
</ AppDialog >
</ AdminPage >
2026-08-11 18:43:56 +08:00
</ template >
2026-08-12 09:25:55 +08:00
< style scoped >
. finance - statements - page { min - width : 0 ; }
: deep (. el - select ), : deep (. el - date - editor ) { width : 100 % ; }
2026-09-15 09:58:15 +08:00
. create - period - row { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : var ( -- admin - space - 6 ); }
. selected - source - summary { width : 100 % ; margin - top : var ( -- admin - space - 2 ); color : var ( -- cc - text - secondary ); font - size : var ( -- admin - font - caption ); text - align : right ; }
2026-08-17 16:45:46 +08:00
. statement - detail - page { min - width : 0 ; width : min ( 100 % , 1920 px ); margin : 0 auto ; overflow : hidden ; }
2026-09-15 09:58:15 +08:00
. statement - summary - section { padding : var ( -- admin - space - 4 ) var ( -- admin - space - 6 ); border - bottom : 1 px solid var ( -- cc - border - subtle ); }
2026-08-17 12:31:27 +08:00
. 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 - line - table { min - width : 1840 px ; }
. statement - payment - table { min - width : 1300 px ; }
2026-09-15 09:58:15 +08:00
. revision - collapse { margin - top : var ( -- admin - space - 5 ); }
2026-08-18 11:33:01 +08:00
: deep (. danger - menu - item : not (. is - disabled )) { color : var ( -- el - color - danger ); }
2026-08-17 16:58:35 +08:00
@ media ( min - width : 1920 px ) {
. statement - summary - section {
2026-09-15 09:58:15 +08:00
padding : var ( -- admin - space - 3 ) var ( -- admin - space - 5 );
2026-08-17 16:58:35 +08:00
}
. detail - table - scroll {
2026-09-15 09:58:15 +08:00
margin - bottom : var ( -- admin - space - 3 );
2026-08-17 16:58:35 +08:00
}
. revision - collapse {
2026-09-15 09:58:15 +08:00
margin - top : var ( -- admin - space - 3 );
2026-08-17 16:58:35 +08:00
}
}
2026-08-13 11:57:44 +08:00
@ media ( max - width : 640 px ) {
2026-08-17 16:45:46 +08:00
. statement - detail - page__header { align - items : stretch ; flex - direction : column ; }
2026-08-17 19:13:25 +08:00
. statement - summary - grid { grid - template - columns : 1 fr ; }
. statement - summary - item -- remark { grid - column : auto ; }
2026-08-13 11:57:44 +08:00
}
2026-08-12 09:25:55 +08:00
</ style >