2026-08-12 16:31:46 +08:00
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
import { Check , MoreHorizontal , Pencil , Plus , RefreshCw , Trash2 , Undo2 , X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import {
adjustInboundFeeDetail , createInboundFeeDetail , deleteInboundFeeDetail ,
listInboundFeeDetails , listInboundFeeItemOptions , listInboundFeePartnerOptions ,
reviewInboundFeeDetail , updateInboundFeeDetail , voidInboundFeeDetail ,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import type {
InboundFeeDetail , InboundFeeItemOption ,
InboundFeePartnerOption , SaveInboundFeeDetailRequest ,
} from '@/modules/unified-orders/api/inboundFeeDetails'
const props = defineProps < { accountId : string ; inboundId : string } > ()
const emit = defineEmits < { changed : [] } > ()
const access = useAccessPreviewStore ()
const rows = ref < InboundFeeDetail [] >([])
const feeItems = ref < InboundFeeItemOption [] >([])
const partners = ref < InboundFeePartnerOption [] >([])
2026-08-12 16:40:09 +08:00
const feeItemsLoading = ref ( false )
const partnersLoading = ref ( false )
const feeItemsError = ref < ApiErrorDetails | null >( null )
const partnersError = ref < ApiErrorDetails | null >( null )
const partnersLoadAttempted = ref ( false )
2026-08-12 16:31:46 +08:00
const loading = ref ( false )
const saving = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const actionError = ref < ApiErrorDetails | null >( null )
const editorOpen = ref ( false )
const editorMode = ref < 'create' | 'edit' | 'adjust' > ( 'create' )
const target = ref < InboundFeeDetail | null >( null )
const formRef = ref < FormInstance >()
const rejectOpen = ref ( false )
const rejectReason = ref ( '' )
const emptyForm = () : SaveInboundFeeDetailRequest => ({
feeItemId : '' , direction : 'RECEIVABLE' , businessDate : new Date (). toISOString (). slice ( 0 , 10 ),
unitPrice : 0 , currency : 'CNY' , amount : 0 , settlementIncluded : true ,
partnerId : null , partnerName : null , remark : null ,
})
const form = reactive < SaveInboundFeeDetailRequest >( emptyForm ())
const rules : FormRules = {
feeItemId : [{ required : true , message : '请选择费用项目' , trigger : 'change' }],
businessDate : [{ required : true , message : '请选择业务发生日' , trigger : 'change' }],
unitPrice : [{ required : true , type : 'number' , min : 0 , message : '单价不能小于 0' , trigger : 'change' }],
amount : [{ required : true , type : 'number' , min : 0 , message : '金额不能小于 0' , trigger : 'change' }],
currency : [{ required : true , pattern : /^[A-Z]{3}$/ , message : '货币须为三个大写字母' , trigger : 'blur' }],
}
const canView = computed (() => access . can ( 'firstleg:inbound:fee-detail:view' ))
2026-08-12 16:40:09 +08:00
const canViewFeeItems = computed (() => access . can ( 'firstleg:fee-item:view' ))
const canViewPartners = computed (() => access . can ( 'platform:counterparty:list' ))
const directionOptions = computed (() => [
{ label : '应收' , value : 'RECEIVABLE' },
{ label : '应付' , value : 'PAYABLE' , disabled : ! canViewPartners . value },
])
2026-08-12 16:31:46 +08:00
const applicableItems = computed (() => feeItems . value . filter (( item ) => form . direction === 'RECEIVABLE' ? item . warehouseReceivable : item . warehousePayable ))
function canEdit ( row : InboundFeeDetail ) { return access . can ( 'firstleg:inbound:fee-detail:edit' ) && row . reviewStatus !== 'APPROVED' }
function canDelete ( row : InboundFeeDetail ) { return access . can ( 'firstleg:inbound:fee-detail:delete' ) && row . reviewStatus !== 'APPROVED' }
function canReview ( row : InboundFeeDetail ) { return access . can ( 'firstleg:inbound:fee-detail:review' ) && row . reviewStatus === 'PENDING' }
function canAdjust ( row : InboundFeeDetail ) { return access . can ( 'firstleg:inbound:fee-detail:edit' ) && row . reviewStatus === 'APPROVED' && row . publicationStatus !== 'VOIDED' }
function canVoid ( row : InboundFeeDetail ) { return access . can ( 'firstleg:inbound:fee-detail:review' ) && row . reviewStatus === 'APPROVED' && row . publicationStatus !== 'VOIDED' }
function reviewLabel ( value : string ) { return ({ PENDING : '待审核' , APPROVED : '已审核' , REJECTED : '已驳回' } as Record < string , string >)[ value ] || value }
function directionLabel ( value : string ) { return value === 'RECEIVABLE' ? '应收' : '应付' }
async function load () {
if ( ! canView . value ) return
loading . value = true ; loadError . value = null
try { rows . value = await listInboundFeeDetails ( props . accountId , props . inboundId ) }
catch ( error ) { loadError . value = getApiErrorDetails ( error , '费用明细加载失败' ) }
finally { loading . value = false }
}
2026-08-12 16:40:09 +08:00
async function loadFeeItems () {
if ( ! canViewFeeItems . value || feeItemsLoading . value ) return
feeItemsLoading . value = true ; feeItemsError . value = null
try { feeItems . value = await listInboundFeeItemOptions () }
catch ( error ) { feeItemsError . value = getApiErrorDetails ( error , '费用项目加载失败' ) }
finally { feeItemsLoading . value = false }
}
async function loadPartners () {
if ( ! canViewPartners . value || partnersLoading . value || partners . value . length || partnersLoadAttempted . value ) return
partnersLoadAttempted . value = true
partnersLoading . value = true ; partnersError . value = null
try { partners . value = await listInboundFeePartnerOptions () }
catch ( error ) { partnersError . value = getApiErrorDetails ( error , '往来单位加载失败' ) }
finally { partnersLoading . value = false }
2026-08-12 16:31:46 +08:00
}
function assignForm ( row ?: InboundFeeDetail ) {
Object . assign ( form , row ? {
feeItemId : row . feeItemId , direction : row . direction , businessDate : row . businessDate ,
unitPrice : Number ( row . unitPrice ), currency : row . currency , amount : Number ( row . amount ),
settlementIncluded : Boolean ( row . settlementIncluded ), partnerId : row . partnerId ,
partnerName : row . partnerName , remark : row . remark ,
} : emptyForm ())
}
async function openEditor ( mode : 'create' | 'edit' | 'adjust' , row ?: InboundFeeDetail ) {
2026-08-12 16:40:09 +08:00
partnersLoadAttempted . value = false
2026-08-12 16:31:46 +08:00
editorMode . value = mode ; target . value = row || null ; assignForm ( row ); actionError . value = null
2026-08-12 16:40:09 +08:00
feeItemsError . value = null ; partnersError . value = null
2026-08-12 16:31:46 +08:00
editorOpen . value = true
2026-08-12 16:40:09 +08:00
if ( ! feeItems . value . length ) await loadFeeItems ()
if ( form . direction === 'PAYABLE' ) await loadPartners ()
2026-08-12 16:31:46 +08:00
}
function normalizePayload () : SaveInboundFeeDetailRequest {
const partner = partners . value . find (( item ) => String ( item . id ) === String ( form . partnerId ))
return { ... form , currency : 'CNY' , partnerId : form . direction === 'PAYABLE' ? form . partnerId : null ,
partnerName : form . direction === 'PAYABLE' ? partner ? . partnerName || form . partnerName : null ,
remark : form . remark ? . trim () || null }
}
async function finishAction ( message : string ) {
await load (); emit ( 'changed' ); ElMessage . success ( message )
}
async function save () {
if ( ! await formRef . value ? . validate ()) return
2026-08-12 16:40:09 +08:00
if ( ! canViewFeeItems . value ) { actionError . value = { message : '当前账号缺少费用项目查看权限,无法保存费用' }; return }
if ( form . direction === 'PAYABLE' && ! canViewPartners . value ) { actionError . value = { message : '当前账号缺少往来单位查看权限,无法维护应付费用' }; return }
2026-08-12 16:31:46 +08:00
if ( form . direction === 'PAYABLE' && ! form . partnerId ) { actionError . value = { message : '应付费用必须选择往来单位' }; return }
saving . value = true ; actionError . value = null
try {
const payload = normalizePayload ()
if ( editorMode . value === 'create' ) await createInboundFeeDetail ( props . accountId , props . inboundId , payload )
else if ( editorMode . value === 'edit' && target . value ) await updateInboundFeeDetail ( props . accountId , props . inboundId , String ( target . value . id ), payload )
else if ( target . value ) await adjustInboundFeeDetail ( props . accountId , props . inboundId , String ( target . value . id ), target . value . sourceFeeVersion , payload )
editorOpen . value = false
await finishAction ( editorMode . value === 'adjust' ? '费用调整已提交审核' : '费用明细已保存' )
} catch ( error ) { actionError . value = getApiErrorDetails ( error , '费用明细保存失败' ) }
finally { saving . value = false }
}
async function remove ( row : InboundFeeDetail ) {
try {
await ElMessageBox . confirm ( '删除后无法恢复,是否继续?' , '删除费用明细' , { type : 'warning' , confirmButtonText : '删除' })
await deleteInboundFeeDetail ( props . accountId , props . inboundId , String ( row . id )); await finishAction ( '费用明细已删除' )
} catch ( error ) { if ( error !== 'cancel' && error !== 'close' ) actionError . value = getApiErrorDetails ( error , '费用明细删除失败' ) }
}
async function approve ( row : InboundFeeDetail ) {
try { await reviewInboundFeeDetail ( props . accountId , props . inboundId , String ( row . id ), 'approve' ); await finishAction ( '费用审核通过' ) }
catch ( error ) { actionError . value = getApiErrorDetails ( error , '费用审核失败' ) }
}
function openReject ( row : InboundFeeDetail ) { target . value = row ; rejectReason . value = '' ; actionError . value = null ; rejectOpen . value = true }
async function reject () {
if ( ! target . value || ! rejectReason . value . trim ()) { actionError . value = { message : '请填写驳回原因' }; return }
saving . value = true
try { await reviewInboundFeeDetail ( props . accountId , props . inboundId , String ( target . value . id ), 'reject' , rejectReason . value . trim ()); rejectOpen . value = false ; await finishAction ( '费用已驳回' ) }
catch ( error ) { actionError . value = getApiErrorDetails ( error , '费用驳回失败' ) }
finally { saving . value = false }
}
async function voidRow ( row : InboundFeeDetail ) {
try {
await ElMessageBox . confirm ( '作废会同步撤销尚未结算的财务费用来源,是否继续?' , '作废费用明细' , { type : 'warning' , confirmButtonText : '作废' })
await voidInboundFeeDetail ( props . accountId , props . inboundId , String ( row . id ), row . sourceFeeVersion ); await finishAction ( '费用明细已作废' )
} catch ( error ) { if ( error !== 'cancel' && error !== 'close' ) actionError . value = getApiErrorDetails ( error , '费用作废失败' ) }
}
watch (() => [ props . accountId , props . inboundId ], () => void load ())
2026-08-12 16:40:09 +08:00
watch (() => form . direction , ( direction ) => {
if ( direction === 'RECEIVABLE' ) { form . partnerId = null ; form . partnerName = null ; partnersError . value = null }
else void loadPartners ()
})
2026-08-12 16:31:46 +08:00
onMounted (() => void load ())
</ script >
< template >
< section class = "fee-detail-manager" aria -label =" 运单费用维护 " >
< div class = "fee-toolbar" >
< span > 共 {{ rows . length }} 条应收应付费用明细 </ span >
< div >
< el-tooltip content = "刷新费用明细" placement = "top" >< el-button circle aria -label =" 刷新费用明细 " :loading = "loading" @click ="load" >< RefreshCw :size = "16" /></ el-button ></ el-tooltip >
2026-08-12 16:40:09 +08:00
< el-button v-if = "access.can('firstleg:inbound:fee-detail:edit') && canViewFeeItems" type="primary" @click="openEditor('create')"><Plus :size="16" /> 新增费用 </ el-button >
2026-08-12 16:31:46 +08:00
</ div >
</ div >
< el-alert v-if = "!canView" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show -icon />
< LoadErrorState v-else-if = "loadError" title="费用明细加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState >
< template v-else >
< el-alert v-if = "actionError && !editorOpen && !rejectOpen" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></ el-alert >
< div v-loading = "loading" class="fee-table-shell" >
< el-table :data = "rows" empty -text =" 暂无费用数据 " row -key =" id " >
< el-table-column label = "操作" fixed = "left" width = "250" >
< template # default = "{ row }" >
< el-button v-if = "canEdit(row as InboundFeeDetail)" link type="primary" @click="openEditor('edit', row as InboundFeeDetail)"><Pencil :size="15" /> 编辑 </ el-button >
< el-button v-if = "canReview(row as InboundFeeDetail)" link type="success" @click="approve(row as InboundFeeDetail)"><Check :size="15" /> 审核 </ el-button >
< el-button v-if = "canReview(row as InboundFeeDetail)" link type="warning" @click="openReject(row as InboundFeeDetail)"><Undo2 :size="15" /> 驳回 </ el-button >
< el-button v-if = "canAdjust(row as InboundFeeDetail)" link type="primary" @click="openEditor('adjust', row as InboundFeeDetail)"><Pencil :size="15" /> 调整 </ el-button >
< el-button v-if = "canVoid(row as InboundFeeDetail)" link type="danger" @click="voidRow(row as InboundFeeDetail)"><X :size="15" /> 作废 </ el-button >
< el-dropdown v-if = "canDelete(row as InboundFeeDetail)" >
< el-button link aria -label =" 更多费用操作 " >< MoreHorizontal :size = "17" /></ el-button >
< template # dropdown >< el-dropdown-menu >
< el-dropdown-item @click ="remove(row as InboundFeeDetail)" >< Trash2 :size = "15" /> 删除 </ el-dropdown-item >
</ el-dropdown-menu ></ template >
</ el-dropdown >
</ template >
</ el-table-column >
< el-table-column label = "审核状态" width = "120" >< template # default = "{ row }" >< StatusBadge :status = "reviewLabel(row.reviewStatus)" /></ template ></ el-table-column >
< el-table-column label = "应收/应付" width = "110" >< template # default = "{ row }" >{{ directionLabel ( row . direction ) }}</ template ></ el-table-column >
< el-table-column prop = "feeTypeNameSnapshot" label = "费用类型" min -width =" 150 " />
< el-table-column label = "往来单位/客户" min -width =" 170 " >< template # default = "{ row }" >{{ row . direction === 'PAYABLE' ? row . partnerName : row . customerName || '—' }}</ template ></ el-table-column >
< el-table-column prop = "businessDate" label = "业务发生日" width = "130" />
< el-table-column prop = "currency" label = "货币" width = "90" />
< el-table-column label = "金额" min -width =" 120 " >< template # default = "{ row }" >{{ Number ( row . amount ). toFixed ( 2 ) }}</ template ></ el-table-column >
< el-table-column label = "单价" min -width =" 110 " >< template # default = "{ row }" >{{ Number ( row . unitPrice ). toFixed ( 4 ) }}</ template ></ el-table-column >
< el-table-column label = "参与结算" width = "110" >< template # default = "{ row }" >{{ row . settlementIncluded ? '是' : '否' }}</ template ></ el-table-column >
< el-table-column prop = "sourceFeeVersion" label = "版本" width = "85" />
< el-table-column prop = "publicationStatus" label = "发布状态" min -width =" 110 " />
< el-table-column prop = "reviewReason" label = "驳回原因" min -width =" 180 " show -overflow-tooltip />
< el-table-column prop = "remark" label = "备注" min -width =" 180 " show -overflow-tooltip />
</ el-table >
</ div >
</ template >
< AppDialog v-model = "editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save" >
< el-alert v-if = "editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show -icon />
2026-08-12 16:40:09 +08:00
< el-alert v-if = "!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show -icon />
< el-alert v-if = "!canViewPartners" title="当前账号缺少往来单位查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show -icon />
< el-alert v-if = "feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></ el-alert >
< el-alert v-if = "partnersError" type="error" :closable="true" @close="partnersError = null"><template #title>{{ partnersError.message }}</template><ApiErrorMeta :details="partnersError" /></ el-alert >
2026-08-12 16:31:46 +08:00
< el-alert v-if = "actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></ el-alert >
< el-form ref = "formRef" :model = "form" :rules = "rules" label -position =" top " class = "fee-form" >
2026-08-12 16:40:09 +08:00
< el-form-item label = "应收/应付" prop = "direction" >< el-segmented v-model = "form.direction" :options="directionOptions" /></ el-form-item >
< el-form-item label = "费用项目" prop = "feeItemId" >< el-select v-model = "form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems"><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :value="item.id" /></ el-select ></ el-form-item >
< el-form-item v-if = "form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></ el-select ></ el-form-item >
2026-08-12 16:31:46 +08:00
< el-form-item label = "业务发生日" prop = "businessDate" >< el-date-picker v-model = "form.businessDate" type="date" value-format="YYYY-MM-DD" /></ el-form-item >
< el-form-item label = "货币" prop = "currency" >< el-input model -value =" CNY " readonly aria -label =" 货币 CNY " /></ el-form-item >
< el-form-item label = "单价" prop = "unitPrice" >< el-input-number v-model = "form.unitPrice" :min="0" :precision="4" /></ el-form-item >
< el-form-item label = "金额" prop = "amount" >< el-input-number v-model = "form.amount" :min="0" :precision="2" /></ el-form-item >
< el-form-item label = "参与结算" >< el-switch v-model = "form.settlementIncluded" /></ el-form-item >
< el-form-item label = "备注" >< el-input v-model = "form.remark" type="textarea" :rows="3" maxlength="500" show -word-limit /></ el-form-item >
</ el-form >
</ AppDialog >
< AppDialog v-model = "rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" @confirm="reject" >
< el-alert v-if = "actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></ el-alert >
< el-form label -position =" top " >< el-form-item label = "驳回原因" required >< el-input v-model = "rejectReason" type="textarea" :rows="4" maxlength="500" show -word-limit /></ el-form-item ></ el-form >
</ AppDialog >
</ section >
</ template >
< style scoped >
. fee - detail - manager { display : grid ; min - width : 0 ; max - width : 100 % ; gap : 12 px ; }
. fee - toolbar { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; color : var ( -- el - text - color - secondary ); font - size : 13 px ; }
. fee - toolbar > div { display : flex ; flex - wrap : wrap ; gap : 8 px ; }
. fee - toolbar : deep (. el - button + . el - button ) { margin - left : 0 ; }
. fee - form { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 0 16 px ; margin - top : 14 px ; }
. fee - form : deep (. el - select ), . fee - form : deep (. el - date - editor ), . fee - form : deep (. el - input - number ) { width : 100 % ; }
. fee - form : deep (. el - form - item : last - child ) { grid - column : 1 / - 1 ; }
. fee - table - shell { min - width : 0 ; max - width : 100 % ; overflow : hidden ; }
. fee - table - shell : deep (. el - table ) { width : 100 % ; max - width : 100 % ; }
@ media ( max - width : 720 px ) { . fee - toolbar { align - items : flex - start ; flex - direction : column ; } . fee - form { grid - template - columns : 1 fr ; } . fee - form : deep (. el - form - item : last - child ) { grid - column : auto ; } }
</ style >