@@ -4,8 +4,8 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
import { Eye , FileCheck2 , FileUp , MoreHorizontal , Pencil , Plus , Save , Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { adjustFinanceBill , createFinanceBill , createFinanceCost , createFinanceReceipt , getFinanceBill , issueFinanceBill , listFinanceBills , listFinanceCosts , listFinanceReceipts , updateFinanceBill , uploadFinanceVoucher , voidFinanceBill } from '../api/finance'
import type { FinanceBillDetail , FinanceBillResource , FinanceCostResource , FinanceReceiptResource , FinanceVoucherResource } from '../types'
import { adjustFinanceBill , createFinanceBill , createFinanceCost , createFinanceReceipt , getFinanceBill , issueFinanceBill , listFinanceBillRevisions , listFinancePayableSettlements , listFinanceReceivableSettlements , listFinanceStatements , updateFinanceBill , uploadFinanceVoucher , voidFinanceBill } from '../api/finance'
import type { FinanceBillDetail , FinanceBillRevision , FinancePayableSettlement , FinanceReceivableSettlement , FinanceStatementReadModel , FinanceVoucherResource } from '../types'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -14,10 +14,26 @@ 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 { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
import {
listPlatformUnifiedOrderFeeDetails ,
listPlatformUnifiedOrders ,
type UnifiedOrderFeeDetailResource ,
type UnifiedOrderResource ,
} from '@/modules/unified-orders'
import { listContainers , type ContainerBookingResource } from '@/modules/container-loading'
type Mode = 'bills' | 'receipts' | 'costs'
type DrawerMode = 'create' | 'detail' | 'edit' | 'adjust'
type TableRow = Record < string , string | number | null >
interface BillLineDraft {
sourceFeeId : string
sourceFeeVersion : number
feeItemName : string
amount : string
orderId : string
inboundId : string
}
const props = defineProps < { mode : Mode } > ( )
const access = useAccessPreviewStore ( )
const allRows = ref < TableRow [ ] > ( [ ] )
@@ -34,17 +50,42 @@ const drawerLoading = ref(false)
const drawerError = ref < ApiErrorDetails | null > ( null )
const actionError = ref < ApiErrorDetails | null > ( null )
const selectedBill = ref < FinanceBillDetail | null > ( null )
const selectedLedgerRow = ref < TableRow | null > ( null )
const billRevisions = ref < FinanceBillRevision [ ] > ( [ ] )
const revisionLoading = ref ( false )
const revisionError = ref < ApiErrorDetails | null > ( null )
const saving = ref ( false )
const formRef = ref < FormInstance > ( )
const file = ref < File | null > ( null )
const uploadedVoucher = ref < FinanceVoucherResource | null > ( null )
const voucherError = ref ( '' )
const customers = ref < PlatformTenantResource [ ] > ( [ ] )
const customerOptionsLoading = ref ( false )
const customerOptionsError = ref < ApiErrorDetails | null > ( null )
const billingOrders = ref < UnifiedOrderResource [ ] > ( [ ] )
const billingOrdersLoading = ref ( false )
const billingOrdersError = ref ( '' )
const receiptBills = ref < FinanceStatementReadModel [ ] > ( [ ] )
const receiptBillsLoading = ref ( false )
const receiptBillsError = ref ( '' )
const costOrders = ref < UnifiedOrderResource [ ] > ( [ ] )
const costContainers = ref < ContainerBookingResource [ ] > ( [ ] )
const costOptionsLoading = ref ( false )
const costOptionsError = ref ( '' )
const feeSourcesByInbound = reactive < Record < string , UnifiedOrderFeeDetailResource [ ] > > ( { } )
const feeSourcesLoading = reactive < Record < string , boolean > > ( { } )
const form = reactive ( { customerId : '' , billDate : '' , amount : '' , paymentMethod : 'BANK_TRANSFER' , referenceNo : '' , costType : 'SINGLE_ORDER' , feeName : '' , remark : '' } )
const activeCustomers = computed ( ( ) => customers . value . filter ( ( customer ) => customer . status === 'ACTIVE' ) )
const selectableFormCustomers = computed ( ( ) => customers . value . filter ( ( customer ) => (
customer . status === 'ACTIVE' || String ( customer . id ) === form . customerId
) ) )
const allocations = reactive ( [ { billId : '' , amount : '' } ] )
const billLines = reactive ( [ { sourceFeeId : '' , sourceFeeVersion : 1 , feeItemName : '' , amount : '' , orderId : '' , inboundId : '' } ] )
const containerOrders = reactive ( [ { orderId : '' , inboundId : '' , volumeCbm : '' } ] )
const billLines = reactive < BillLineDraft [ ] > ( [ emptyBillLine ( ) ] )
let billingOrdersRequest = 0
let receiptBillsRequest = 0
let costOptionsRequest = 0
const title = computed ( ( ) => ( { bills : '客户账单' , receipts : '收款登记 ' , costs : '成本录入 ' } [ props . mode ] ) )
const title = computed ( ( ) => ( { bills : '客户对 账单' , receipts : '结算单应 收' , costs : '结算单应付 ' } [ props . mode ] ) )
const viewPermission = computed ( ( ) => ( { bills : 'platform:finance:bill:view' , receipts : 'platform:finance:receipt:view' , costs : 'platform:finance:cost:view' } [ props . mode ] ) )
const createPermission = computed ( ( ) => ( { bills : 'platform:finance:bill:create' , receipts : 'platform:finance:receipt:create' , costs : 'platform:finance:cost:create' } [ props . mode ] ) )
const canView = computed ( ( ) => access . can ( viewPermission . value ) )
@@ -53,51 +94,247 @@ const canIssue = computed(() => access.can('platform:finance:bill:issue'))
const canUpdate = computed ( ( ) => access . can ( 'platform:finance:bill:update' ) )
const canAdjust = computed ( ( ) => access . can ( 'platform:finance:bill:adjust' ) )
const canVoid = computed ( ( ) => access . can ( 'platform:finance:bill:void' ) )
const stateError = computed ( ( ) => customerOptionsError . value || loadError . value )
const drawerTitle = computed ( ( ) => {
if ( props . mode !== 'bills' ) return props . mode === 'receipts' ? '登记人工收款' : '录入人工 成本'
return { create : '新建客户账单' , detail : '账单详情' , edit : '编辑草稿账单' , adjust : '调整已出账账单' } [ drawerMode . value ]
if ( props . mode !== 'bills' ) return drawerMode . value === 'detail' ? props . mode === 'receipts' ? '应收结算详情' : '应付结算详情' : props . mode === 'receipts' ? '登记人工收款' : '登记应付 成本'
return { create : '新建客户对 账单' , detail : '对 账单详情' , edit : '编辑草稿对 账单' , adjust : '调整已出账对 账单' } [ drawerMode . value ]
} )
const rows = computed ( ( ) => serverPaged . value ? allRows . value : allRows . value . slice ( ( page . value - 1 ) * pageSize . value , page . value * pageSize . value ) )
const queryFields = computed < QueryField [ ] > ( ( ) => [
{ key : 'keyword' , type : 'input' , label : '关键词' , width : 220 , placeholder : '客户/单号/费用项目' , props : { 'aria-label' : '关键词' } } ,
{ key : 'customerId' , type : 'inpu t' , label : '客户ID ' , width : 180 , placeholder : props . mode === 'bills' ? '查询账单时必填' : '留空查询全部' , props : { 'aria-label' : '客户ID' } } ,
{ key : 'customerId' , type : 'selec t' , label : '客户' , width : 240 , placeholder : '请选择客户' , props : { 'aria-label' : '客户' , filterable : true , loading : customerOptionsLoading . value } , options : activeCustomers . value . map ( ( customer ) => ( { label : customer . tenantName , value : String ( customer . id ) } ) ) } ,
... ( props . mode === 'bills' ? [ { key : 'status' , type : 'select' as const , label : '状态' , width : 160 , placeholder : '全部状态' , options : [ { label : '草稿' , value : 'DRAFT' } , { label : '已出账' , value : 'ISSUED' } , { label : '部分收款' , value : 'PARTIALLY_PAID' } , { label : '已收清' , value : 'PAID' } , { label : '作废' , value : 'VOIDED' } ] } ] : [ ] ) ,
] )
const columns = computed < DataTableColumn [ ] > ( ( ) => {
if ( props . mode === 'bills' ) return [ { key : 'actions' , label : '操作' , width : 210 , fixed : 'left' , slotName : 'actions' } , { key : 'billNo' , label : '账单编号' , minWidth : 170 } , { key : 'customerName' , label : '客户' , minWidth : 150 } , { key : 'billDate' , label : '账单日期' , width : 120 } , { key : 'dueDate' , label : '到期日' , width : 120 } , { key : 'amount' , label : '账单金额(CNY)' , width : 140 } , { key : 'paidAmount' , label : '已收(CNY)' , width : 120 } , { key : 'outstandingAmount' , label : '未收(CNY)' , width : 120 } , { key : 'status' , label : '状态' , width : 110 , slotName : 'status' } ]
if ( props . mode === 'receipts' ) return [ { key : 'actions' , label : '操作' , width : 90 , fixed : 'left' , slotName : 'actions' } , { key : 'customerName ' , label : '客户 ' , minWidth : 150 } , { key : 'receivedAt ' , label : '收款日期 ' , width : 130 } , { key : 'amount' , label : '金额 (CNY)' , width : 130 } , { key : 'paymentMethod ' , label : '收款方式 ' , width : 130 } , { key : 'v oucherFileName ' , label : '凭证 ' , minWidth : 180 } , { key : 'allocationCount ' , label : '关联账单数 ' , width : 120 } ]
if ( props . mode === 'costs' ) return [ { key : 'actions' , label : '操作' , width : 90 , fixed : 'left' , slotName : 'actions' } , { key : 'costType' , label : '成本类型' , width : 130 , slotName : 'costType' } , { key : 'referenceNo' , label : '关联单号 ' , minWidth : 160 } , { key : 'feeName' , label : '费用项目' , minWidth : 160 } , { key : 'supplierName' , label : '客户 ' , minWidth : 150 } , { key : 'amount' , label : '金额 (CNY)' , width : 130 } , { key : 'voucherFileName ' , label : '凭证 ' , minWidth : 180 } , { key : 'createdAt' , label : '登记时间' , width : 150 } ]
if ( props . mode === 'receipts' ) return [ { key : 'actions' , label : '操作' , width : 90 , fixed : 'left' , slotName : 'actions' } , { key : 'settlementNo ' , label : '结算单号 ' , minWidth : 170 } , { key : 'billDate' , label : '账单日期' , width : 120 } , { key : 'dueDate ', label : '到期日 ' , width : 120 } , { key : 'amount' , label : '应收 (CNY)' , width : 130 } , { key : 'paidAmount ' , label : '已收(CNY) ' , width : 130 } , { key : 'outstandingAmount ' , label : '未收(CNY) ' , width : 130 } , { key : 'status ' , label : '状态 ' , width : 120 , slotName : 'status' } ]
if ( props . mode === 'costs' ) return [ { key : 'actions' , label : '操作' , width : 90 , fixed : 'left' , slotName : 'actions' } , { key : 'settlementNo' , label : '结算单号' , minWidth : 170 } , { key : 'costType' , label : '成本类型' , width : 130 , slotName : 'costType' } , { key : 'referenceNo' , label : '来源追溯 ' , minWidth : 180 } , { key : 'feeName' , label : '费用项目' , minWidth : 150 } , { key : 'supplierName' , label : '供应商 ' , minWidth : 150 } , { key : 'amount' , label : '应付 (CNY)' , width : 130 } , { key : 'paidAmount' , label : '已付(CNY)' , width : 120 } , { key : 'outstandingAmount ', label : '未付(CNY) ' , width : 120 } , { key : 'status' , label : '状态' , width : 110 , slotName : 'status' } , { key : 'createdAt' , label : '登记时间' , width : 150 } ]
return [ ]
} )
const rules : FormRules = {
customerId : [ { required : true , message : '请输入客户ID ' , trigger : 'blur ' } ] ,
customerId : [ { required : true , message : '请选择客户 ' , trigger : 'change ' } ] ,
billDate : [ { required : true , message : '请选择日期' , trigger : 'change' } ] ,
amount : [ { required : true , message : '请输入金额' , trigger : 'blur' } ] ,
referenceNo : [ { required : true , message : '请输入关联ID ' , trigger : 'blur ' } ] ,
referenceNo : [ { required : true , message : '请选择关联业务对象 ' , trigger : 'change ' } ] ,
feeName : [ { required : true , message : '请输入费用项目' , trigger : 'blur' } ] ,
paymentMethod : [ { required : true , message : '请选择收款方式' , trigger : 'change' } ] ,
}
function mapBill ( item : FinanceBillResource ) : TableRow { return { id : item . id , billNo : item . billNo , customerName : item . customerName , billDate : item . billDate , dueDate : item . dueDate , amount : item . totalAmount , paidAmount : item . paidAmount , outstandingAmount : item . outstandingAmount , status : item . status , version : item . version } }
function mapReceipt ( item : FinanceReceiptResource ) : TableRow { return { id : item . id , customerName : item . customerName , receivedAt : item . receivedDate , amount : item . amount , paymentMethod : item . paymentMethod , voucherFileName : item . voucherFileIds . length ? ` ${ item . voucherFileIds . length } 个文件 ` : '未上传' , allocationCount : item . allocationCount } }
function mapCost ( item : FinanceCostResource ) : TableRow { return { id : item . id , costType : item . costType , referenceNo : item . orderId || item . containerId || '—' , feeName : item . expenseName , supplierName : ` 客户 ${ item . customerId } ` , amount : item . amount , voucherFileName : item . voucherFileIds . length ? ` ${ item . voucherFileIds . length } 个文件 ` : '未上传' , createdAt : item . createdAt } }
function mapStatement ( item : FinanceStatementReadModel ) : TableRow { return { id : item . id , customerId : item . customerId , billNo : item . billNo , customerName : item . customerName , billDate : item . billDate , dueDate : item . dueDate , amount : item . totalAmount , paidAmount : item . receivedAmount , outstandingAmount : item . outstandingAmount , status : item . status , version : item . version } }
function mapReceivable ( item : FinanceReceivableSettlement ) : TableRow { return { id : item . id , customerId : item . customerId , settlementNo : item . settlementNo , billDate : item . billDate , dueDate : item . dueDate , amount : item . receivableAmount , paidAmount : item . receivedAmount , outstandingAmount : item . outstandingAmount , status : item . status , version : item . version } }
function mapPayable ( item : FinancePayableSettlement ) : TableRow { return { id : item . id , customerId : item . customerId , settlementNo : item . settlementNo , costType : item . costType , referenceNo : item . containerId ? ` 柜次 ${ item . containerId } ` : item . inboundId ? ` 入库单 ${ item . inboundId } ` : item . orderId ? ` 订单 ${ item . orderId } ` : '—' , feeName : item . feeItemName , supplierName : item . supplierName || '未填写' , amount : item . payableAmount , paidAmount : item . paidAmount , outstandingAmount : item . outstandingAmount , status : item . status , createdAt : item . createdAt , version : item . version } }
function customerNameById ( customerId : string | number | null ) {
return customers . value . find ( ( customer ) => String ( customer . id ) === String ( customerId ) ) ? . tenantName || '客户信息暂不可用'
}
function emptyBillLine ( ) : BillLineDraft {
return { sourceFeeId : '' , sourceFeeVersion : 1 , feeItemName : '' , amount : '' , orderId : '' , inboundId : '' }
}
async function loadCustomerOptions ( ) {
customerOptionsLoading . value = true
customerOptionsError . value = null
try {
const records : PlatformTenantResource [ ] = [ ]
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants ( { page : currentPage , size : 200 } )
records . push ( ... result . records )
pages = Math . max ( 1 , result . pages )
currentPage += 1
} while ( currentPage <= pages )
customers . value = records
} catch ( error ) {
customerOptionsError . value = getApiErrorDetails ( error , '客户列表加载失败' )
} finally {
customerOptionsLoading . value = false
}
}
async function loadBillingOrders ( customerId : string ) {
const request = ++ billingOrdersRequest
billingOrders . value = [ ]
billingOrdersError . value = ''
if ( ! customerId ) return
billingOrdersLoading . value = true
try {
const result = await listPlatformUnifiedOrders ( { tenantId : customerId , customerId , page : 1 , size : 200 } )
if ( request === billingOrdersRequest ) billingOrders . value = result . records . filter ( ( order ) => order . inboundId != null )
} catch ( error ) {
if ( request === billingOrdersRequest ) billingOrdersError . value = getApiErrorDetails ( error , '客户订单加载失败' ) . message
} finally {
if ( request === billingOrdersRequest ) billingOrdersLoading . value = false
}
}
async function listAllCustomerOrders ( customerId : string ) {
const records : UnifiedOrderResource [ ] = [ ]
let currentPage = 1
let pages = 1
do {
const result = await listPlatformUnifiedOrders ( { tenantId : customerId , customerId , page : currentPage , size : 200 } )
records . push ( ... result . records )
pages = Math . max ( 1 , result . pages )
currentPage += 1
} while ( currentPage <= pages )
return records
}
async function loadReceiptBills ( customerId : string ) {
const request = ++ receiptBillsRequest
receiptBills . value = [ ]
receiptBillsError . value = ''
if ( ! customerId ) return
receiptBillsLoading . value = true
try {
const records = await listFinanceStatements ( customerId )
if ( request === receiptBillsRequest ) receiptBills . value = records . filter ( ( bill ) => [ 'ISSUED' , 'PARTIALLY_PAID' ] . includes ( bill . status ) && bill . outstandingAmount > 0 )
} catch ( error ) {
if ( request === receiptBillsRequest ) receiptBillsError . value = getApiErrorDetails ( error , '未结清账单加载失败' ) . message
} finally {
if ( request === receiptBillsRequest ) receiptBillsLoading . value = false
}
}
async function loadCostOptions ( customerId : string ) {
const request = ++ costOptionsRequest
costOrders . value = [ ]
costContainers . value = [ ]
costOptionsError . value = ''
if ( ! customerId ) return
costOptionsLoading . value = true
try {
const orders = await listAllCustomerOrders ( customerId )
if ( request !== costOptionsRequest ) return
costOrders . value = orders
const relatedContainerKeys = new Set ( orders . flatMap ( ( order ) => [ order . transport ? . containerNo , order . transport ? . cabinetNo ] ) . filter ( ( value ) : value is string => Boolean ( value ) ) )
if ( ! relatedContainerKeys . size ) return
const firstPage = await listContainers ( { page : 1 , size : 100 } )
const remainingPages = firstPage . pages > 1
? await Promise . all ( Array . from ( { length : firstPage . pages - 1 } , ( _ , index ) => listContainers ( { page : index + 2 , size : 100 } ) ) )
: [ ]
if ( request !== costOptionsRequest ) return
costContainers . value = [ firstPage , ... remainingPages ]
. flatMap ( ( result ) => result . records )
. filter ( ( container ) => relatedContainerKeys . has ( container . containerNo || '' ) || relatedContainerKeys . has ( container . cabinetNo ) )
} catch ( error ) {
if ( request === costOptionsRequest ) costOptionsError . value = getApiErrorDetails ( error , '关联订单和柜次加载失败' ) . message
} finally {
if ( request === costOptionsRequest ) costOptionsLoading . value = false
}
}
function orderLabel ( order : UnifiedOrderResource ) {
return ` ${ order . orderNo || ` 订单 ${ order . orderId } ` } · ${ order . inboundCode || ` 入库单 ${ order . inboundId } ` } `
}
async function loadFeeSources ( inboundId : string ) {
if ( ! inboundId || feeSourcesByInbound [ inboundId ] || feeSourcesLoading [ inboundId ] ) return
feeSourcesLoading [ inboundId ] = true
try {
feeSourcesByInbound [ inboundId ] = await listPlatformUnifiedOrderFeeDetails ( form . customerId , inboundId )
} catch ( error ) {
feeSourcesByInbound [ inboundId ] = [ ]
ElMessage . error ( getApiErrorDetails ( error , '费用来源加载失败' ) . message )
} finally {
feeSourcesLoading [ inboundId ] = false
}
}
function handleCustomerChange ( customerId : string ) {
uploadedVoucher . value = null
file . value = null
billingOrders . value = [ ]
receiptBills . value = [ ]
costOrders . value = [ ]
costContainers . value = [ ]
form . referenceNo = ''
if ( props . mode !== 'bills' ) form . amount = ''
allocations . splice ( 0 , allocations . length , { billId : '' , amount : '' } )
for ( const key of Object . keys ( feeSourcesByInbound ) ) delete feeSourcesByInbound [ key ]
billLines . splice ( 0 , billLines . length , emptyBillLine ( ) )
if ( props . mode === 'bills' ) void loadBillingOrders ( customerId )
else if ( props . mode === 'receipts' ) void loadReceiptBills ( customerId )
else void loadCostOptions ( customerId )
}
function handleCostTypeChange ( ) {
form . referenceNo = ''
}
function orderOptionLabel ( order : UnifiedOrderResource ) {
return [ order . orderNo || ` 订单 ${ order . orderId } ` , order . inboundCode ? ` 入库单 ${ order . inboundCode } ` : '' ] . filter ( Boolean ) . join ( ' · ' )
}
function containerOptionLabel ( container : ContainerBookingResource ) {
return [ container . cabinetNo || ` 柜次 ${ container . id } ` , container . containerNo ? ` 柜号 ${ container . containerNo } ` : '' , container . warehouseName ] . filter ( Boolean ) . join ( ' · ' )
}
function receiptBillLabel ( bill : FinanceStatementReadModel ) {
return ` ${ bill . billNo || ` 账单 ${ bill . id } ` } · 未收 ${ bill . outstandingAmount . toFixed ( 2 ) } CNY `
}
function allocationBillDisabled ( billId : string , index : number ) {
return allocations . some ( ( allocation , allocationIndex ) => allocationIndex !== index && allocation . billId === billId )
}
function syncReceiptAmount ( ) {
form . amount = allocations . reduce ( ( sum , allocation ) => sum + ( positiveNumber ( allocation . amount ) ? Number ( allocation . amount ) : 0 ) , 0 ) . toFixed ( 2 )
}
function handleAllocationBillChange ( index : number , billId : string ) {
const allocation = allocations [ index ]
if ( ! allocation ) return
allocation . billId = billId
const bill = receiptBills . value . find ( ( item ) => item . id === billId )
allocation . amount = bill ? bill . outstandingAmount . toFixed ( 2 ) : ''
syncReceiptAmount ( )
}
function handleLineOrderChange ( line : BillLineDraft , orderId : string ) {
const order = billingOrders . value . find ( ( item ) => String ( item . orderId ) === orderId )
line . orderId = orderId
line . inboundId = order ? . inboundId == null ? '' : String ( order . inboundId )
line . sourceFeeId = ''
line . sourceFeeVersion = 1
line . feeItemName = ''
line . amount = ''
if ( line . inboundId ) void loadFeeSources ( line . inboundId )
}
function handleFeeSourceChange ( line : BillLineDraft , sourceFeeId : string ) {
const source = ( feeSourcesByInbound [ line . inboundId ] || [ ] ) . find ( ( item ) => String ( item . sourceFeeId ? ? '' ) === sourceFeeId )
line . sourceFeeId = sourceFeeId
line . sourceFeeVersion = Number ( source ? . sourceFeeVersion || 1 )
line . feeItemName = source ? . feeTypeNameSnapshot || ''
line . amount = source ? . amount == null ? '' : String ( source . amount )
}
function feeSourceOptions ( line : BillLineDraft ) {
return ( feeSourcesByInbound [ line . inboundId ] || [ ] ) . filter ( ( item ) => item . sourceFeeId != null && Number ( item . sourceFeeVersion ) > 0 )
}
function feeSourceLabel ( source : UnifiedOrderFeeDetailResource ) {
return ` ${ source . feeTypeNameSnapshot || '未命名费用' } · ${ source . amount ? ? 0 } ${ source . currency || 'CNY' } `
}
async function load ( ) {
if ( ! canView . value ) return
loading . value = true ; loadError . value = null
try {
const customerId = String ( filters . customerId || '' ) . trim ( )
if ( props . mode === 'bills' && ! customerId ) { allRows . value = [ ] ; total . value = 0 ; return }
if ( ! customerId ) { allRows . value = [ ] ; total . value = 0 ; return }
let source : TableRow [ ]
let responseTotal = 0
if ( props . mode === 'bills' ) {
const result = await listFinanceBills ( { customerId , status : String ( filters . status || '' ) || undefined , page : page . value , pageSize : pageSize . value } )
source = result . records . map ( mapBill )
responseTotal = result . total ; serverPaged . value = result . serverPaged
const result = await listFinanceStatements ( customerId )
source = result . map ( mapStatement )
responseTotal = result . length ; serverPaged . value = false
} else if ( props . mode === 'receipts' ) {
const result = await listFinanceReceipts ( customerId ) ; source = result . records . map ( mapReceipt ) ; responseTotal = result . total ; serverPaged . value = result . serverPaged
const result = await listFinanceReceivableSettlements ( customerId ) ; source = result . map ( mapReceivable ) ; responseTotal = result . length ; serverPaged . value = false
} else {
const result = await listFinanceCosts ( customerId ) ; source = result . records . map ( mapCost ) ; responseTotal = result . total ; serverPaged . value = result . serverPaged
const result = await listFinancePayableSettlements ( customerId ) ; source = result . map ( mapPayable ) ; responseTotal = result . length ; serverPaged . value = false
}
const keyword = String ( filters . keyword || '' ) . trim ( ) . toLowerCase ( )
const status = String ( filters . status || '' )
@@ -109,11 +346,12 @@ async function load() {
function applyFilters ( model : QueryModel ) { Object . assign ( filters , model ) ; page . value = 1 ; void load ( ) }
function changePageSize ( ) { page . value = 1 ; void load ( ) }
function openCreate ( ) {
drawerMode . value = 'create' ; selectedBill . value = null ; drawerError . value = null
billingOrdersRequest + = 1 ; receiptBillsRequest + = 1 ; costOptionsRequest + = 1
drawerMode . value = 'create' ; selectedBill . value = null ; selectedLedgerRow . value = null ; billRevisions . value = [ ] ; revisionError . value = null ; drawerError . value = null
Object . assign ( form , { customerId : '' , billDate : new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) , amount : '' , paymentMethod : 'BANK_TRANSFER' , referenceNo : '' , costType : 'SINGLE_ORDER' , feeName : '' , remark : '' } )
allocations . splice ( 0 , allocations . length , { billId : '' , amount : '' } )
billLines . splice ( 0 , billLines . length , { sourceFeeId : '' , sourceFeeVersion : 1 , feeItemName : '' , amount : '' , orderId : '' , inboundId : '' } )
containerOrders . splice ( 0 , containerOrders . length , { orderId : '' , inboundId : '' , volumeCbm : '' } )
billLines . splice ( 0 , billLines . length , emptyBillLine ( ) )
billingOrders . value = [ ] ; billingOrdersError . value = '' ; receiptBills . value = [ ] ; receiptBillsError . value = '' ; costOrders . value = [ ] ; costContainers . value = [ ] ; costOptionsError . value = ''
file . value = null ; uploadedVoucher . value = null ; voucherError . value = '' ; drawerOpen . value = true
}
function selectFile ( event : Event ) {
@@ -124,22 +362,22 @@ function selectFile(event: Event) {
}
function positiveNumber ( value : string ) { const amount = Number ( value ) ; return Number . isFinite ( amount ) && amount > 0 }
function addAllocation ( ) { allocations . push ( { billId : '' , amount : '' } ) }
function removeAllocation ( index : number ) { if ( allocations . length > 1 ) allocations . splice ( index , 1 ) }
function addBillLine ( ) { billLines . push ( { sourceFeeId : '' , sourceFeeVersion : 1 , feeItemName : '' , amount : '' , orderId : '' , inboundId : '' } ) }
function removeAllocation ( index : number ) { if ( allocations . length > 1 ) { allocations . splice ( index , 1 ) ; syncReceiptAmount ( ) } }
function addBillLine ( ) { billLines . push ( emptyBillLine ( ) ) }
function removeBillLine ( index : number ) { if ( billLines . length > 1 ) billLines . splice ( index , 1 ) }
function addContainerOrder ( ) { containerOrders . push ( { orderId : '' , inboundId : '' , volumeCbm : '' } ) }
function removeContainerOrder ( index : number ) { if ( containerOrders . length > 1 ) containerOrders . splice ( index , 1 ) }
function billPayloadLines ( ) { return billLines . map ( ( line ) => ( { sourceFeeId : line . sourceFeeId , sourceFeeVersion : line . sourceFeeVersion , orderId : line . orderId || undefined , inboundId : line . inboundId || undefined , feeItemName : line . feeItemName . trim ( ) , amount : Number ( line . amount ) } ) ) }
function validateBusinessForm ( ) {
if ( ! /^\d+$/ . test ( form . customerId ) ) return '客户ID必须为数字 '
if ( ! form . billDate ) return '请选择日期'
if ( ! /^\d+$/ . test ( form . customerId ) ) return '请选择有效 客户'
if ( props . mode !== 'costs' && ! form . billDate ) return '请选择日期'
if ( props . mode !== 'bills' && ! positiveNumber ( form . amount ) ) return '金额必须大于 0'
if ( props . mode === 'bills' && billLines . some ( ( line ) => ! /^\d+$/ . test ( line . sourceFeeId ) || ! line . feeItemName . trim ( ) || ! positiveNumber ( line . amount ) ) ) return '每条账单费用都必须填写数字格式的费用来源ID、费用项目和大于 0 的金额 '
if ( props . mode === 'bills' && billLines . some ( ( line ) => ! /^\d+$/ . test ( line . sourceFeeId ) || ! line . orderId || ! line . inboundId || ! line . feeItemName . trim ( ) || ! positiveNumber ( line . amount ) ) ) return '请为 每条账单明细选择订单和已生成的费用来源 '
if ( drawerMode . value === 'adjust' && ! form . remark . trim ( ) ) return '调整原因不能为空'
if ( props . mode === 'costs' && ( ! /^\d+$/ . test ( form . referenceNo ) || ! form . feeName . trim ( ) ) ) return ` 请填写数字格式的 ${ form . costType === 'SINGLE_ORDER' ? '订单ID ' : '柜次ID ' } 和 费用项目`
if ( props . mode === 'costs' && form . costType === 'CONTAINER_SHARED' && containerOrders . some ( ( item ) => ! /^\d+$/ . test ( item . orderId ) || ( item . inboundId && ! /^\d+$/ . test ( item . inboundId ) ) || ! positiveNumber ( item . volumeCbm ) ) ) return '整柜成本必须填写每票数字格式的订单ID、可选入库单ID和大于 0 的体积快照'
if ( props . mode === 'costs' && ( ! form . referenceNo || ! form . feeName . trim ( ) ) ) return ` 请选择 ${ form . costType === 'SINGLE_ORDER' ? '订单' : '柜次' } 并填写 费用项目`
if ( props . mode === 'receipts' ) {
if ( allocations . some ( ( item ) => ! /^\d+$/ . test ( item . billId ) || ! positiveNumber ( item . amount ) ) ) return '每条分配都必须填写数字格式的账单ID和 大于 0 的金额'
if ( allocations . some ( ( item ) => ! receiptBills . value . some ( ( bill ) => bill . id === item . billId ) || ! positiveNumber ( item . amount ) ) ) return '每条分配都必须选择未结清账单并 填写大于 0 的金额'
if ( new Set ( allocations . map ( ( item ) => item . billId ) ) . size !== allocations . length ) return '同一账单不能重复分配'
const exceededBill = allocations . find ( ( item ) => Number ( item . amount ) > ( receiptBills . value . find ( ( bill ) => bill . id === item . billId ) ? . outstandingAmount || 0 ) + 0.000001 )
if ( exceededBill ) return '账单分配金额不能超过该账单的未收金额'
const allocated = allocations . reduce ( ( sum , item ) => sum + Number ( item . amount ) , 0 )
if ( Math . abs ( allocated - Number ( form . amount ) ) > 0.000001 ) return ` 分配金额合计 ${ allocated . toFixed ( 2 ) } 元,必须等于收款金额 `
}
@@ -165,7 +403,7 @@ async function submit() {
if ( props . mode === 'receipts' ) {
await createFinanceReceipt ( { customerId : form . customerId , receivedDate : form . billDate , amount : String ( form . amount ) , paymentMethod : form . paymentMethod , remark : form . remark , allocations : allocations . map ( ( item ) => ( { billId : item . billId , amount : String ( item . amount ) } ) ) } , voucherFileIds )
} else {
await createFinanceCost ( { customerId : form . customerId , costType : form . costType , orderId : form . costType === 'SINGLE_ORDER' ? form . referenceNo : undefined , containerId : form . costType === 'CONTAINER_SHARED' ? form . referenceNo : undefined , expenseName : form . feeName . trim ( ) , amount : String ( form . amount ) , remark : form . remark , containerOrders : form . costType === 'CONTAINER_SHARED' ? containerOrders . map ( ( item ) => ( { orderId : item . orderId , inboundId : item . inboundId || undefined , containerId : form . referenceNo , volumeCbm : String ( item . volumeCbm ) } ) ) : [ ] } , voucherFileIds )
await createFinanceCost ( { customerId : form . customerId , costType : form . costType , orderId : form . costType === 'SINGLE_ORDER' ? form . referenceNo : undefined , containerId : form . costType === 'CONTAINER_SHARED' ? form . referenceNo : undefined , expenseName : form . feeName . trim ( ) , amount : String ( form . amount ) , remark : form . remark } , voucherFileIds )
}
}
drawerOpen . value = false ; ElMessage . success ( ` ${ title . value } 已保存 ` ) ; await load ( )
@@ -184,15 +422,29 @@ async function issueBill(row: TableRow) {
}
}
async function openBill ( row : TableRow , mode : DrawerMode ) {
drawerMode . value = mode ; drawerOpen . value = true ; drawerLoading . value = true ; drawerError . value = null ; selectedBill . value = null
drawerMode . value = mode ; drawerOpen . value = true ; drawerLoading . value = true ; drawerError . value = null ; revisionError . value = null ; billRevisions . value = [ ] ; selectedBill . value = null
try {
const detail = await getFinanceBill ( String ( row . id ) ) ; selectedBill . value = detail
Object . assign ( form , { customerId : detail . customerId , billDate : detail . billDate , remark : '' } )
billLines . splice ( 0 , billLines . length , ... detail . lines . map ( ( line ) => ( { sourceFeeId : line . sourceFeeId , sourceFeeVersion : line . sourceFeeVersion , feeItemName : line . feeItemName , amount : String ( line . amount ) , orderId : line . orderId || '' , inboundId : line . inboundId || '' } ) ) )
if ( ! billLines . length ) addBillLine ( )
await loadBillingOrders ( detail . customerId )
await Promise . all ( [ ... new Set ( billLines . map ( ( line ) => line . inboundId ) . filter ( Boolean ) ) ] . map ( loadFeeSources ) )
if ( mode === 'detail' ) {
revisionLoading . value = true
try { billRevisions . value = await listFinanceBillRevisions ( detail . id , detail . customerId ) }
catch ( error ) { revisionError . value = getApiErrorDetails ( error , '账单修订记录加载失败,请重试' ) }
finally { revisionLoading . value = false }
}
} catch ( error ) { drawerError . value = getApiErrorDetails ( error , '账单详情加载失败,请重试' ) }
finally { drawerLoading . value = false }
}
function openLedgerDetail ( row : TableRow ) {
drawerMode . value = 'detail'
selectedLedgerRow . value = row
drawerError . value = null
drawerOpen . value = true
}
async function voidBill ( row : TableRow ) {
actionError . value = null
try {
@@ -208,7 +460,7 @@ function handleBillCommand(command: string, row: TableRow) {
if ( command === 'issue' ) void issueBill ( row )
else if ( command === 'void' ) void voidBill ( row )
}
onMounted ( ( ) => void load ( ) )
onMounted ( ( ) => { void load ( ) ; void loadCustomerOptions ( ) } )
< / script >
< template >
@@ -216,11 +468,11 @@ onMounted(() => void load())
< el-result v-if = "!canView" icon="warning" title="无权访问财务模块" sub-title="请联系管理员开通平台财务权限" / >
< ListPageLayout v-else aria -label = " 财务人工模块 " >
< template # toolbar > < QueryToolbar v-model = "filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="queryFields" :preference-key="`finance-${props.mode}-query`" @search="applyFilters" / > < / template >
< template v-if = "load Error" #state><LoadErrorState :title="`${title}加载失败`" :message="load Error.message" :loading="loading" @retry="load "><template #details><ApiErrorMeta :details="load Error" / > < / template > < / LoadErrorState > < / template >
< template v-if = "state Error" #state><LoadErrorState :title="customerOptionsError ? '客户选项加载失败' : `${title}加载失败`" :message="state Error.message" :loading="customerOptionsLoading || loading" @retry="customerOptionsError ? loadCustomerOptions() : load() "><template #details><ApiErrorMeta :details="state Error" / > < / template > < / LoadErrorState > < / template >
< div v-if = "actionError" class="surface-panel operation-error" role="alert"><el-alert type="error" :closable="true" :title="actionError.message" @close="actionError = null" /><ApiErrorMeta :details="actionError" / > < / div >
< section v-if = "!loadError" class="surface-panel table-panel" >
< section v-if = "!loadError && !customerOptionsError " class="surface-panel table-panel" >
< DataTable v -model :page = "page" v -model :page-size = "pageSize" :data = "rows" :columns = "columns" :loading = "loading" :total = "total" :preference-key = "`finance-${props.mode}-table`" :aria-label = "`${title}列表`" row -key = " id " empty -text = " 暂无数据 " @ update :page = "load" @ update :page-size = "changePageSize" >
< template # toolbar -actions > < el-button v-if = "canCreate" type="primary" @click="openCreate"><Plus :size="16" / > { { props . mode === 'bills' ? '新建账单' : props . mode === 'receipts' ? '登记收款' : '录入 成本' } } < / el-button > < / template >
< template # toolbar -actions > < el-button v-if = "canCreate" type="primary" @click="openCreate"><Plus :size="16" / > { { props . mode === 'bills' ? '新建对 账单' : props . mode === 'receipts' ? '登记收款' : '登记应付 成本' } } < / el-button > < / template >
< template # actions = "{ row }" >
< div v-if = "props.mode === 'bills'" class="row-actions" >
< el -button link type = "primary" @click ="openBill(row, 'detail')" > < Eye :size = "15" / > 查看 < / el-button >
@@ -232,11 +484,11 @@ onMounted(() => void load())
< template # dropdown > < el-dropdown-menu > < el-dropdown-item v-if = "canIssue" command="issue"><FileCheck2 :size="15" />出账</el-dropdown-item><el-dropdown-item v-if="canVoid" command="void" divided><Trash2 :size="15" / > 作废 < / el-dropdown-item > < / el-dropdown-menu > < / template >
< / el-dropdown >
< / div >
< span v-else class = "muted-value" > 人工台账 < / span >
< el-button v-else link type = "primary" @click ="openLedgerDetail(row)" > < Eye :size = "15" / > 查看 < / el-button >
< / template >
< template # status = "{ row }" > < el-tag : type = "row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'" > { { row . status === 'DRAFT' ? '草稿' : row . status === 'ISSUED' ? '已出账' : row . status === 'PARTIALLY_PAID' ? '部分收款' : row . status === 'PAID' ? '已收清' : '作废' } } < / el-tag > < / template >
< template # status = "{ row }" > < el-tag : type = "row.status === 'PAID' || row.status === 'CONFIRMED' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'" > { { row . status === 'DRAFT' ? '草稿' : row . status === 'ISSUED' ? '已出账' : row . status === 'PARTIALLY_PAID' ? '部分收款' : row . status === 'PAID' ? '已收清' : row . status === 'CONFIRMED' ? '已确认' : row . status === 'VOIDED' ? '作废' : row . status } } < / el-tag > < / template >
< template # costType = "{ row }" > { { row . costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' } } < / template >
< template # empty > < el-empty description = "暂无数据;后端接口接通后将显示真实 财务记录" / > < / template >
< template # empty > < el-empty description = "暂无符合条件的 财务记录" / > < / template >
< template # pagination -left > 币种 : CNY · 数据由平台财务人工维护 < / template >
< / DataTable >
< / section >
@@ -251,53 +503,70 @@ onMounted(() => void load())
< el-descriptions-item label = "账单金额" > { { selectedBill . totalAmount } } CNY < / el-descriptions-item > < el-descriptions-item label = "未收金额" > { { selectedBill . outstandingAmount } } CNY < / el-descriptions-item >
< / el-descriptions >
< FormSection title = "费用明细" description = "当前账单版本中的真实费用明细。" >
< div v-for = "(line, index) in selectedBill.lines" :key="`${line.sourceFeeId}-${index}`" class="detail-line" > < strong > { { line . feeItemName } } < / strong > < span > 来源ID : { { line . sourceFeeId } } < / span > < b > { { line . amount } } CNY < / b > < / div >
< div v-for = "(line, index) in selectedBill.lines" :key="`${line.sourceFeeId}-${index}`" class="detail-line" > < strong > { { line . feeItemName } } < / strong > < span > { { line . inboundId ? ` 入库单 ${ line . inboundId } · ` : '' } } { { line . orderId ? ` 订单 ${ line . orderId } · ` : '' } } 来源 { { line . sourceFeeId } } / v { { line . sourceFeeVersion } } < / span > < b > { { line . amount } } CNY < / b > < / div >
< el-empty v-if = "!selectedBill.lines.length" description="暂无费用明细" / >
< / FormSection >
< el-alert type = "info" :closable = "false" show -icon title = " 后端已 保存调整快照;修订记录查询接口尚未提供,当前只能查看最新版本号。" / >
< FormSection title = "修订记录" description = "按版本倒序展示 后端保存的 调整前账单快照。" >
< el-skeleton v-if = "revisionLoading" :rows="2" animated / >
< div v-else-if = "revisionError" class="revision-error" role="alert"><el-alert type="error" :closable="false" :title="revisionError.message" /><ApiErrorMeta :details="revisionError" / > < / div >
< el-empty v-else-if = "!billRevisions.length" description="暂无历史修订记录" / >
< div v-else class = "revision-list" >
< section v-for = "revision in billRevisions" :key="revision.id" class="revision-item" >
< div class = "revision-heading" > < strong > 调整前版本 v { { revision . version } } < / strong > < span > { { revision . createdAt || '时间未知' } } < / span > < / div >
< div class = "revision-meta" > < span > 原因 : { { revision . reason || '未填写' } } < / span > < span > 操作人ID : { { revision . createdBy || '—' } } < / span > < span > 状态 : { { revision . status || '—' } } < / span > < span > 金额 : { { revision . totalAmount } } CNY < / span > < / div >
< div v-for = "(line, index) in revision.lines" :key="`${revision.id}-${line.sourceFeeId}-${index}`" class="revision-line" > < span > { { line . feeItemName || ` 费用来源 ${ line . sourceFeeId } ` } } < / span > < b > { { line . amount } } CNY < / b > < / div >
< p v-if = "!revision.lines.length" class="revision-empty" > 该版本没有费用明细快照 < / p >
< / section >
< / div >
< / FormSection >
< / template >
< template v-else-if = "props.mode !== 'bills' && drawerMode === 'detail' && selectedLedgerRow" >
< el -descriptions :column = "2" border class = "bill-summary" >
< el-descriptions-item label = "结算单号" > { { selectedLedgerRow . settlementNo } } < / el-descriptions-item > < el-descriptions-item label = "客户" > { { customerNameById ( selectedLedgerRow . customerId ) } } < / el-descriptions-item >
< template v-if = "props.mode === 'receipts'"><el-descriptions-item label="账单日期">{{ selectedLedgerRow.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedLedgerRow.dueDate }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态" > {{ selectedLedgerRow.status }} < / el -descriptions -item > < / template >
< template v-else > < el -descriptions -item label = "费用项目" > { { selectedLedgerRow . feeName } } < / el-descriptions-item > < el-descriptions-item label = "成本类型" > { { selectedLedgerRow . costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' } } < / el-descriptions-item > < el-descriptions-item label = "关联来源" > { { selectedLedgerRow . referenceNo } } < / el-descriptions-item > < el-descriptions-item label = "应付金额" > { { selectedLedgerRow . amount } } CNY < / el-descriptions-item > < el-descriptions-item label = "已付金额" > { { selectedLedgerRow . paidAmount } } CNY < / el-descriptions-item > < el-descriptions-item label = "未付金额" > { { selectedLedgerRow . outstandingAmount } } CNY < / el-descriptions-item > < el-descriptions-item label = "登记时间" > { { selectedLedgerRow . createdAt } } < / el-descriptions-item > < el-descriptions-item label = "状态" > { { selectedLedgerRow . status } } < / el-descriptions-item > < / template >
< / el-descriptions >
< / template >
< el-form v-else-if = "drawerMode !== 'detail'" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || drawerLoading" >
< FormSection title = "基本信息" description = "首期只支持 CNY,由平台财务人工填写并留存凭证。" >
< div class = "form-grid two-cols" >
< el-form-item label = "客户ID " prop = "customerId" > < el-input v-model = "form.customerId" inputmo de="numeric" placeholder="请输入客户ID" :disabled="props.mode === 'bills' && drawerMode !== 'create'" / > < / el-form-item >
< el-form-item label = "日期" prop = "billDate" > < el-date-picker v-model = "form.billDate" type="date" value-format="YYYY-MM-DD " style="width: 100%" :disabled="props.mode === 'bills' && drawerMode === 'adjust'" / > < / el-form-item >
< el-form-item label = "客户" prop = "customerId" > < el-select v-model = "form.customerId" filterable placehol der ="请选择客户" :loading="customerOptionsLoading" :loading-text="'客户加载中'" :no-data-text="customerOptionsError?.message || '暂无可选客户'" :disabled="props.mode === 'bills' && drawerMode !== 'create'" style="width: 100%" @change="handleCustomerChange"><el-option v-for="customer in selectableFormCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" / > < / el-select > < / el-form-item >
< el-form-item v-if = "props.mode !== 'costs'" :label="props.mode === 'receipts' ? '收款日期' : '账单日期'" prop="billDate"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" :placeholder="props.mode === 'receipts' ? '请选择收款日期' : '请选择账单日期' " style="width: 100%" :disabled="props.mode === 'bills' && drawerMode === 'adjust'" / > < / el-form-item >
< / div >
< div v-if = "customerOptionsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="customerOptionsError.message" show-icon /><ApiErrorMeta :details="customerOptionsError" /><el-button link type="primary" :loading="customerOptionsLoading" @click="loadCustomerOptions" > 重新加载客户 < / el -button > < / div >
< template v-if = "props.mode === 'bills'" >
< div class = "allocation-title" > 费用明细 < / div >
< div v-if = "billingOrdersError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="billingOrdersError" show-icon /><el-button link type="primary" :loading="billingOrdersLoading" @click="loadBillingOrders(form.customerId)" > 重新加载客户订单 < / el -button > < / div >
< p v-else class = "form-hint" > 先选择客户订单 , 再选择该入库单已生成的费用来源 ; 系统会自动带出费用项目 、 版本和金额 。 < / p >
< div v-for = "(line, index) in billLines" :key="index" class="bill-line-row" >
< el -input v-model = "line.sourceFeeId" inputmode="numeric " p laceholder="费用来源ID" / >
< el-input v-model = "line.feeItemName" placeholder="费用项目" / >
< el-input v-model = "line.amount" inputmode="decimal " placeholder="金额(CNY) " / >
< el -select :model-value = "line.orderId" filterable placeholder = "选择订单 / 入库单" :loading = "billingOrdersLoading" loading -text = " 订单加载中 " : no -data -text = " form.customerId ? ' 该客户暂无已入库订单 ' : ' 请先选择客户 ' " :disabled = "!form.customerId" @ update : model -value = " handleLineOrderChange ( line , String ( $ event | | ' ' ) ) " > < el-option v-for = "order in billingOrders" :key="String(order.orderId) " : label="orderLabel(order)" :value="String(order.orderId)" / > < / el-select >
< el-select :model-value = "line.sourceFeeId" filterable placeholder = "选择费用来源" :loading = "Boolean(feeSourcesLoading[line.inboundId])" loading -text = " 费用来源加载中 " : no -data -text = " line.inboundId ? ' 该入库单暂无可选费用 ' : ' 请先选择订单 ' " :disabled = "!line.inboundId" @ update : model -value = " handleFeeSourceChange ( line , String ( $ event | | ' ' ) ) " > < el-option v-for = "source in feeSourceOptions(line)" :key="`${source.sourceFeeId}-${source.sourceFeeVersion}`" :label="feeSourceLabel(source)" :value="String(source.sourceFeeId)" / > < / el-select >
< el-input v-model = "line.feeItemName " placeholder="费用项目(自动带出) " disabled / >
< el-input v-model = "line.amount" inputmode="decimal" placeholder="金额(CNY) " / >
< el-button link type = "danger" aria -label = " 删除费用明细 " @click ="removeBillLine(index)" > < Trash2 :size = "16" / > < / el-button >
< / div >
< el-button link type = "primary" @click ="addBillLine" > < Plus :size = "15" / > 添加费用明细 < / el-button >
< el-form-item v-if = "drawerMode === 'adjust'" label="调整原因" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" / > < / el-form-item >
< el-form-item v-if = "drawerMode === 'adjust'" label="调整原因" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请说明本次调整原因" / > < / el-form-item >
< / template >
< template v-else >
< div class = "form-grid two-cols" >
< el-form-item v-if = "props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" style="width: 100% "><el-option label="单票成本" value="SINGLE_ORDER" /><el-option label="整柜公共成本" value="CONTAINER_SHARED" / > < / el-select > < / el-form-item >
< el-form-item label = "金额(CNY) " prop = "amount" > < el-input v-model = "form.amount" inputmode="decimal" placeholder=" 请输入金额" / > < / el-form-item >
< el-form-item v-if = "props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" placeholder="请选择成本类型" style="width: 100%" @change="handleCostTypeChange "><el-option label="单票成本" value="SINGLE_ORDER" /><el-option label="整柜公共成本" value="CONTAINER_SHARED" / > < / el-select > < / el-form-item >
< el-form-item label = "金额(CNY) " prop = "amount" > < el-input v-model = "form.amount" inputmode="decimal" :readonly="props.mode === 'receipts'" :placeholder="props.mode === 'receipts' ? '由账单分配金额自动汇总' : ' 请输入成本 金额' " / > < / el-form-item >
< / div >
< el-form-item v-if = "props.mode === 'costs'" label="关联ID" prop="referenceNo"><el-input v-model="form.referenceNo" :placeholder=" form.co stType === 'SINGLE_ORDER' ? '请输入订单ID' : '请输入柜次ID'" / > < / el-form-item >
< div v-if = "props.mode === 'costs' && costOptionsError " c lass="option-error" role="alert"><el-alert type="error" :closable="false" :title="costOptionsError" show-icon /><el-button link type="primary" :loading="costOptionsLoading" @click="loadCostOptions( form.cu stomerId)" > 重新加载业务对象 < / el -button > < / div >
< el-form-item v-if = "props.mode === 'costs' && form.costType === 'SINGLE_ORDER'" label="关联订单" prop="referenceNo"><el-select v-model="form.referenceNo" filterable placeholder="请选择客户订单" :loading="costOptionsLoading" loading-text="订单加载中" :no-data-text="form.customerId ? '该客户暂无可选订单' : '请先选择客户'" :disabled="!form.customerId" style="width: 100%"><el-option v-for="order in costOrders" :key="String(order.orderId)" :label="orderOptionLabel(order)" :value="String(order.orderId)" / > < / el-select > < / el-form-item >
< el-form-item v-if = "props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" label="关联柜次" prop="referenceNo"><el-select v-model="form.referenceNo" filterable placeholder="请选择客户订单关联的柜次" :loading="costOptionsLoading" loading-text="柜次加载中" :no-data-text="form.customerId ? '该客户订单暂无关联柜次' : '请先选择客户'" :disabled="!form.customerId" style="width: 100%"><el-option v-for="container in costContainers" :key="container.id" :label="containerOptionLabel(container)" :value="container.id" / > < / el-select > < / el-form-item >
< template v-if = "props.mode === 'receipts'" >
< el -form -item label = "收款方式" prop = "paymentMethod" > < el-select v-model = "form.paymentMethod" style="width: 100%"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" / > < / el-select > < / el-form-item >
< el -form -item label = "收款方式" prop = "paymentMethod" > < el-select v-model = "form.paymentMethod" placeholder="请选择收款方式" style="width: 100%"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" / > < / el-select > < / el-form-item >
< div class = "allocation-title" > 账单分配 < / div >
< div v-for = "(allocation, index) in allocations" :key="index" class="allocation-row"><el-input v-model="allocation.billId" placeholder="账单ID" /><el-input v-model ="allocation.amount" inputmod e="d ecimal" placeholder="分配金额(CNY)" /><el-button link type="danger" aria-label="删除分配" @click="removeAllocation(index)"><Trash2 :size="16" / > < / el-button > < / div >
< div v-if = "receiptBillsError" class="option-error" role="alert"><el-alert type="error" :closable ="f alse" :titl e="r eceiptBillsError" show-icon /><el-button link type="primary" :loading="receiptBillsLoading" @click="loadReceiptBills(form.customerId)" > 重新加载未结清账单 < / el -button > < / div >
< p v-else class = "form-hint" > 选择未结清对账单后自动带出未收金额 , 可按本次实际到账金额调整分配 。 < / p >
< div v-for = "(allocation, index) in allocations" :key="index" class="allocation-row"><el-select :model-value="allocation.billId" filterable placeholder="请选择未结清账单" :loading="receiptBillsLoading" loading-text="账单加载中" :no-data-text="form.customerId ? '该客户暂无未结清账单' : '请先选择客户'" :disabled="!form.customerId" @update:model-value="handleAllocationBillChange(index, String($event || ''))"><el-option v-for="bill in receiptBills" :key="bill.id" :label="receiptBillLabel(bill)" :value="bill.id" :disabled="allocationBillDisabled(bill.id, index)" /></el-select><el-input v-model="allocation.amount" inputmode="decimal" placeholder="本次分配金额(CNY) " @input="syncReceiptAmount" /><el-button link type="danger" aria-label="删除分配" @click="removeAllocation(index)"><Trash2 :size="16" / > < / el-button > < / div >
< el-button link type = "primary" @click ="addAllocation" > < Plus :size = "15" / > 添加账单分配 < / el-button >
< / template >
< el-form-item v-if = "props.mode === 'costs'" label="费用项目" prop="feeName"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" / > < / el-form-item >
< template v-if = "props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" >
< div class = "allocation-title" > 柜内订单体积快照 < / div >
< el-alert type = "info" :closable = "false" show -icon title = "按当前柜内同客户有效订单的实际体积填写;该快照将随成本保存并用于分摊。" / >
< div v-for = "(item, index) in containerOrders" :key="index" class="container-volume-row" >
< el -input v-model = "item.orderId" inputmode="numeric" placeholder="订单ID" / >
< el-input v-model = "item.inboundId" inputmode="numeric" placeholder="入库单ID(可选)" / >
< el-input v-model = "item.volumeCbm" inputmode="decimal" placeholder="体积(CBM)" / >
< el-button link type = "danger" aria -label = " 删除体积快照 " @click ="removeContainerOrder(index)" > < Trash2 :size = "16" / > < / el-button >
< / div >
< el-button link type = "primary" @click ="addContainerOrder" > < Plus :size = "15" / > 添加柜内订单 < / el-button >
< / template >
< el-form-item label = "备注" > < el-input v-model = "form.remark" type="textarea" :rows="3" maxlength="500" / > < / el-form-item >
< el-alert v-if = "props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" type="info" :closable="false" show-icon title="整柜成本保存时由后端从 Firstleg 获取真实柜内订单体积快照并完成分摊。" / >
< el-form-item label = "备注" > < el-input v-model = "form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(可选)" / > < / el-form-item >
< / template >
< / FormSection >
< FormSection v-if = "props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。" >
@@ -323,13 +592,24 @@ onMounted(() => void load())
. voucher - success { margin : 8 px 0 0 ; color : var ( -- el - color - success ) ; overflow - wrap : anywhere ; }
. drawer - footer { display : flex ; justify - content : flex - end ; gap : 10 px ; }
. allocation - title { margin : 4 px 0 8 px ; font - weight : 600 ; }
. option - error { display : grid ; justify - items : start ; gap : 4 px ; margin - bottom : 10 px ; }
. option - error . el - alert { width : 100 % ; }
. allocation - row { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 0 , 1 fr ) 32 px ; gap : 8 px ; margin - bottom : 8 px ; align - items : center ; }
. bill - line - row { display : grid ; grid - template - columns : minmax ( 110 px , .8 fr ) minmax ( 140 px , 1.2 fr ) minmax ( 110 px , .7 fr ) 32 px ; gap : 8 px ; margin - bottom : 8 px ; align - items : center ; }
. container - volume - row { display : grid ; grid - template - columns : minmax ( 110 px , 1 fr ) minmax ( 130 px , 1 fr ) minmax ( 110 px , .8 fr ) 32 px ; gap : 8 px ; margin : 8 px 0 ; align - items : center ; }
. bill - line - row { display : grid ; grid - template - columns : minmax ( 180 px , 1.2 fr ) minmax ( 180 px , 1.2 fr ) minmax ( 140 px , 1 fr ) minmax ( 110 px , .7 fr ) 32 px ; gap : 8 px ; margin - bottom : 8 px ; align - items : center ; }
. form - hint { margin : 0 0 10 px ; color : var ( -- cc - muted ) ; font - size : 12 px ; line - height : 1.6 ; }
. bill - summary { margin - bottom : 16 px ; }
. revision - error { display : grid ; gap : 8 px ; }
. revision - list { display : grid ; gap : 12 px ; }
. revision - item { padding - bottom : 12 px ; border - bottom : 1 px solid var ( -- el - border - color - lighter ) ; }
. revision - heading , . revision - meta , . revision - line { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; }
. revision - heading span , . revision - meta , . revision - empty { color : var ( -- cc - muted ) ; }
. revision - meta { flex - wrap : wrap ; justify - content : flex - start ; margin : 6 px 0 ; font - size : 13 px ; }
. revision - line { padding : 5 px 0 ; }
. revision - line b { white - space : nowrap ; }
. revision - empty { margin : 6 px 0 0 ; }
. detail - line { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 0 , .8 fr ) auto ; gap : 12 px ; align - items : center ; padding : 10 px 0 ; border - bottom : 1 px solid var ( -- el - border - color - lighter ) ; }
. detail - line span { color : var ( -- cc - muted ) ; overflow - wrap : anywhere ; }
. detail - line b { white - space : nowrap ; }
@ media ( max - width : 720 px ) { : deep ( . form - grid . two - cols ) { grid - template - columns : 1 fr ; } . bill - line - row , . container - volume - row { grid - template - columns : 1 fr 1 fr ; } . drawer - footer . el - button { min - height : 44 px ; } : deep ( . bill - summary . el - descriptions _ _body . el - descriptions _ _table ) { table - layout : fixed ; } }
@ media ( max - width : 480 px ) { . drawer - footer { flex - wrap : wrap ; } . drawer - footer . el - button { flex : 1 1 120 px ; } . allocation - row , . bill - line - row , . container - volume - row , . detail - line { grid - template - columns : 1 fr ; } . allocation - row . el - button , . bill - line - row . el - button , . container - volume - row . el - button { justify - self : end ; } : deep ( . bill - summary . el - descriptions _ _cell ) { display : block ; width : 100 % ; } }
@ media ( max - width : 720 px ) { : deep ( . form - grid . two - cols ) { grid - template - columns : 1 fr ; } . bill - line - row { grid - template - columns : 1 fr 1 fr ; } . drawer - footer . el - button { min - height : 44 px ; } : deep ( . bill - summary . el - descriptions _ _body . el - descriptions _ _table ) { table - layout : fixed ; } }
@ media ( max - width : 480 px ) { . drawer - footer { flex - wrap : wrap ; } . drawer - footer . el - button { flex : 1 1 120 px ; } . allocation - row , . bill - line - row , . detail - line { grid - template - columns : 1 fr ; } . allocation - row . el - button , . bill - line - row . el - button { justify - self : end ; } . revision - heading , . revision - line { align - items : flex - start ; } : deep ( . bill - summary . el - descriptions _ _cell ) { display : block ; width : 100 % ; } }
< / style >