@@ -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 , getFinanceBill , issueFinanceBill , listFinanceBills , listFinanceCosts , listFinanceReceipts , updateFinanceBill , voidFinanceBill } from '../api/finance'
import type { FinanceBillDetail , FinanceBillResource , FinanceCostResource , FinanceReceiptResource } from '../types'
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 AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -37,9 +37,12 @@ const selectedBill = ref<FinanceBillDetail | 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 form = reactive ( { customerId : '' , billDate : '' , amount : '' , paymentMethod : 'BANK_TRANSFER' , referenceNo : '' , costType : 'SINGLE_ORDER' , feeName : '' , remark : '' } )
const allocations = reactive ( [ { billId : '' , amount : '' } ] )
const billLines = reactive ( [ { sourceFeeId : '' , sourceFeeVersion : 1 , feeItemName : '' , amount : '' , orderId : '' , inboundId : '' } ] )
const containerOrders = reactive ( [ { orderId : '' , inboundId : '' , volumeCbm : '' } ] )
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 ] ) )
@@ -110,14 +113,22 @@ function openCreate() {
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 : '' } )
file . value = null ; drawerOpen . value = true
containerOrders . splice ( 0 , containerOrders . length , { orderId : '' , inboundId : '' , volumeCbm : '' } )
file . value = null ; uploadedVoucher . value = null ; voucherError . value = '' ; drawerOpen . value = true
}
function selectFile ( event : Event ) {
const selected = ( event . target as HTMLInputElement ) . files ? . [ 0 ] || null
uploadedVoucher . value = null ; voucherError . value = ''
if ( selected && ! selected . type . startsWith ( 'image/' ) ) { file . value = null ; voucherError . value = '凭证仅支持图片文件' ; return }
file . value = selected
}
function selectFile ( event : Event ) { const selected = ( event . target as HTMLInputElement ) . files ? . [ 0 ] || null ; file . value = selected }
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 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必须为数字'
@@ -126,24 +137,37 @@ function validateBusinessForm() {
if ( props . mode === 'bills' && billLines . some ( ( line ) => ! /^\d+$/ . test ( line . sourceFeeId ) || ! line . feeItemName . trim ( ) || ! positiveNumber ( line . amount ) ) ) return '每条账单费用都必须填写数字格式的费用来源ID、费用项目和大于 0 的金额'
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 === 'receipts' ) {
if ( allocations . some ( ( item ) => ! /^\d+$/ . test ( item . billId ) || ! positiveNumber ( item . amount ) ) ) return '每条分配都必须填写数字格式的账单ID和大于 0 的金额'
const allocated = allocations . reduce ( ( sum , item ) => sum + Number ( item . amount ) , 0 )
if ( Math . abs ( allocated - Number ( form . amount ) ) > 0.000001 ) return ` 分配金额合计 ${ allocated . toFixed ( 2 ) } 元,必须等于收款金额 `
}
if ( props . mode !== 'bills' && uploadedVoucher . value && uploadedVoucher . value . customerId !== form . customerId ) return '客户ID已变化,请重新选择并上传对应客户的凭证'
if ( props . mode !== 'bills' && ! file . value && ! uploadedVoucher . value ) return '请先选择收款或成本凭证图片'
return ''
}
async function submit ( ) {
const valid = await formRef . value ? . validate ( ) . catch ( ( ) => false ) ; if ( ! valid ) return
const validationMessage = validateBusinessForm ( ) ; if ( validationMessage ) { ElMessage . warning ( validationMessage ) ; return }
if ( props . mode !== 'bills' ) { ElMessage . error ( '凭证上传接口尚未接通,当前不能保存收款或成本,避免形成无凭证台账' ) ; return }
saving . value = true
drawerError . value = null
try {
const lines = billPayloadLines ( )
if ( drawerMode . value === 'edit' && selectedBill . value ) await updateFinanceBill ( selectedBill . value . id , { customerId : form . customerId , billDate : form . billDate , lines , expectedVersion : selectedBill . value . version } )
else if ( drawerMode . value === 'adjus t' && selectedBill . value ) await adjustFinanceBill ( selectedBill . value . id , { expectedVersion : selectedBill . value . version , reason : form . remark . trim ( ) , lines } )
else await createFinanceBill ( { customerId : form . customerId , billDate : form . billDate , lines } )
if ( props . mode === 'bills' ) {
const lines = billPayloadLines ( )
if ( drawerMode . value === 'edi t' && selectedBill . value ) await updateFinanceBill ( selectedBill . value . id , { customerId : form . customerId , billDate : form . billDate , lines , expectedVersion : selectedBill . value . version } )
else if ( drawerMode . value === 'adjust' && selectedBill . value ) await adjustFinanceBill ( selectedBill . value . id , { expectedVersion : selectedBill . value . version , reason : form . remark . trim ( ) , lines } )
else await createFinanceBill ( { customerId : form . customerId , billDate : form . billDate , lines } )
} else {
if ( ! uploadedVoucher . value && file . value ) uploadedVoucher . value = await uploadFinanceVoucher ( form . customerId , file . value )
const voucherFileIds = uploadedVoucher . value ? [ uploadedVoucher . value . fileId ] : [ ]
if ( ! voucherFileIds . length ) throw new Error ( '凭证上传未返回有效 fileId' )
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 )
}
}
drawerOpen . value = false ; ElMessage . success ( ` ${ title . value } 已保存 ` ) ; await load ( )
} catch ( error ) { const details = getApiErrorDetails ( error , ` ${ title . value } 保存失败,请检查 Finance 接口后重试 ` ) ; drawerError . value = details ; ElMessage . error ( details . message ) }
finally { saving . value = false }
@@ -262,12 +286,27 @@ onMounted(() => void load())
< 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 >
< / template >
< / FormSection >
< FormSection v-if = "props.mode !== 'bills'" title="凭证图片" description="后端上传契约提供后才允许形成财务台账。"><el-alert type="warning" :closable="false" show-icon title="凭证上传接口尚未接通;收款和成本保存已暂时阻止。" /><label class="file-picker is-disabled"><FileUp :size="16" />凭证选择将在接口接通后启用<input type="file" accept="image/*" disabled @change="selectFile" / > < / label > < / FormSection >
< FormSection v-if = "props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。" >
< label class = "file-picker" : class = "{ 'is-disabled': saving }" > < FileUp :size = "16" / > { { uploadedVoucher ? ` 已上传: ${ uploadedVoucher . fileName } ` : file ? . name || '选择凭证图片' } } < input type = "file" accept = "image/*" :disabled = "saving" @change ="selectFile" / > < / label >
< p v-if = "voucherError" class="voucher-error" role="alert" > {{ voucherError }} < / p >
< p v-else-if = "uploadedVoucher" class="voucher-success" > 凭证 fileId : {{ uploadedVoucher.fileId }} , 创建失败时可直接重试 。 < / p >
< / FormSection >
< / el -form >
< template # footer > < div class = "drawer-footer" > < el-button :disabled = "saving" @click ="drawerOpen = false" > {{ drawerMode = = = ' detail ' ? ' 关闭 ' : ' 取消 ' }} < / el -button > < el-button v-if = "drawerMode !== 'detail'" type="primary" :loading="saving" :disabled="props.mode !== 'bills'" @click="submit"><Save :size="16" / > { { props . mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : '等待凭证接口 ' } } < / el-button > < / div > < / template >
< template # footer > < div class = "drawer-footer" > < el-button :disabled = "saving" @click ="drawerOpen = false" > {{ drawerMode = = = ' detail ' ? ' 关闭 ' : ' 取消 ' }} < / el -button > < el-button v-if = "drawerMode !== 'detail'" type="primary" :loading="saving" @click="submit"><Save :size="16" / > { { props . mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : saving ? '上传并保存中' : '上传并保存 ' } } < / el-button > < / div > < / template >
< / AppDrawer >
< / AdminPage >
< / template >
@@ -280,14 +319,17 @@ onMounted(() => void load())
. file - picker { display : inline - flex ; align - items : center ; gap : 8 px ; max - width : 100 % ; padding : 10 px 14 px ; border : 1 px dashed var ( -- el - border - color ) ; border - radius : 6 px ; cursor : pointer ; color : var ( -- cc - text ) ; overflow - wrap : anywhere ; }
. file - picker . is - disabled { cursor : not - allowed ; color : var ( -- cc - muted ) ; background : var ( -- el - fill - color - light ) ; }
. file - picker input { display : none ; }
. voucher - error { margin : 8 px 0 0 ; color : var ( -- el - color - danger ) ; }
. 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 ; }
. 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 - summary { margin - bottom : 16 px ; }
. 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 { 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 ; } : 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 , . 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 % ; } }
< / style >