2026-08-12 16:31:46 +08:00
< script setup lang = "ts" >
2026-10-20 15:48:23 +08:00
import InlineHelp from '@/shared/components/InlineHelp.vue'
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
2026-08-12 16:31:46 +08:00
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
2026-09-18 16:48:40 +08:00
import { FileUp , Plus , RefreshCw , X } from '@lucide/vue'
2026-08-12 16:31:46 +08:00
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'
2026-09-18 16:48:40 +08:00
import RowActions , { type RowActionItem } from '@/shared/components/RowActions.vue'
2026-08-12 16:31:46 +08:00
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import {
adjustInboundFeeDetail , createInboundFeeDetail , deleteInboundFeeDetail ,
2026-08-19 09:14:35 +08:00
listInboundFeeDetails , listInboundFeeItemOptions , listInboundFeeSupplierOptions ,
2026-08-20 18:02:24 +08:00
reviewInboundFeeDetail , updateInboundFeeDetail , uploadInboundFeePaymentVoucher , voidInboundFeeDetail ,
2026-08-12 16:31:46 +08:00
} from '@/modules/unified-orders/api/inboundFeeDetails'
import type {
InboundFeeDetail , InboundFeeItemOption ,
2026-08-19 09:14:35 +08:00
InboundFeeSupplierOption , SaveInboundFeeDetailRequest ,
2026-08-12 16:31:46 +08:00
} from '@/modules/unified-orders/api/inboundFeeDetails'
2026-09-08 17:38:09 +08:00
const props = defineProps < { accountId : string ; inboundId : string ; destinationType ?: string | null } > ()
2026-08-17 19:13:25 +08:00
interface OtherFeeSummary { currency : string ; approvedReceivable : number ; approvedPayable : number }
const emit = defineEmits < { changed : []; summary : [ value : OtherFeeSummary ] } > ()
2026-08-12 16:31:46 +08:00
const access = useAccessPreviewStore ()
const rows = ref < InboundFeeDetail [] >([])
const feeItems = ref < InboundFeeItemOption [] >([])
2026-08-19 09:14:35 +08:00
const suppliers = ref < InboundFeeSupplierOption [] >([])
2026-08-12 16:40:09 +08:00
const feeItemsLoading = ref ( false )
2026-08-19 09:14:35 +08:00
const suppliersLoading = ref ( false )
2026-08-19 09:42:12 +08:00
const loadedSupplierCategory = ref < string | null | undefined >( undefined )
2026-08-12 16:40:09 +08:00
const feeItemsError = ref < ApiErrorDetails | null >( null )
2026-08-19 09:14:35 +08:00
const suppliersError = ref < ApiErrorDetails | null >( null )
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 ( '' )
2026-08-20 18:02:24 +08:00
const voucherFiles = ref < Array < { fileId : string ; fileName : string }> > ([])
const voucherUploading = ref ( false )
const voucherError = ref < ApiErrorDetails | null >( null )
2026-08-19 09:14:35 +08:00
let supplierLoadSequence = 0
2026-08-12 16:31:46 +08:00
const emptyForm = () : SaveInboundFeeDetailRequest => ({
feeItemId : '' , direction : 'RECEIVABLE' , businessDate : new Date (). toISOString (). slice ( 0 , 10 ),
2026-08-19 09:14:35 +08:00
unitPrice : 0 , currency : 'CNY' , amount : 0 ,
2026-08-20 18:02:24 +08:00
supplierId : null , settlementMode : null , paidAt : null , paymentMethod : null ,
paymentReferenceNo : null , paymentVoucherFileIds : null , remark : null ,
2026-08-12 16:31:46 +08:00
})
const form = reactive < SaveInboundFeeDetailRequest >( emptyForm ())
2026-08-20 18:17:36 +08:00
const settlementModeModel = computed < string >({
get : () => form . settlementMode ?? '' ,
set : ( value ) => { form . settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
})
2026-08-12 16:31:46 +08:00
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-18 16:09:43 +08:00
const normalizedInboundId = computed (() => {
const identifier = String ( props . inboundId ?? '' ). trim ()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
2026-08-12 16:40:09 +08:00
const canViewFeeItems = computed (() => access . can ( 'firstleg:fee-item:view' ))
2026-08-19 09:14:35 +08:00
const canViewSuppliers = computed (() => access . can ( 'platform:supplier:list' ))
2026-08-12 16:40:09 +08:00
const directionOptions = computed (() => [
{ label : '应收' , value : 'RECEIVABLE' },
2026-08-19 09:14:35 +08:00
{ label : '应付' , value : 'PAYABLE' , disabled : ! canViewSuppliers . value },
2026-08-12 16:40:09 +08:00
])
2026-09-08 17:38:09 +08:00
const privateDeliveryFeeCode = 'PRIVATE_LAST_MILE_DELIVERY'
const applicableItems = computed (() => feeItems . value . filter (( item ) => {
if ( item . feeCode === privateDeliveryFeeCode && props . destinationType !== 'PRIVATE' ) return false
return form . direction === 'RECEIVABLE' ? item . warehouseReceivable : item . warehousePayable
}))
2026-08-19 09:14:35 +08:00
const selectedFeeItem = computed (() => feeItems . value . find (( item ) => String ( item . id ) === String ( form . feeItemId )))
2026-08-12 16:31:46 +08:00
2026-08-17 19:13:25 +08:00
function isManual ( row : InboundFeeDetail ) { return row . sourceType === 'MANUAL' }
function canEdit ( row : InboundFeeDetail ) { return isManual ( row ) && access . can ( 'firstleg:inbound:fee-detail:edit' ) && row . reviewStatus === 'PENDING' }
function canDelete ( row : InboundFeeDetail ) { return isManual ( row ) && access . can ( 'firstleg:inbound:fee-detail:delete' ) && row . reviewStatus === 'PENDING' }
function canReview ( row : InboundFeeDetail ) { return isManual ( row ) && access . can ( 'firstleg:inbound:fee-detail:review' ) && row . reviewStatus === 'PENDING' }
function canAdjust ( row : InboundFeeDetail ) { return isManual ( row ) && access . can ( 'firstleg:inbound:fee-detail:edit' ) && row . reviewStatus === 'APPROVED' && row . publicationStatus !== 'VOIDED' }
function canVoid ( row : InboundFeeDetail ) { return isManual ( row ) && access . can ( 'firstleg:inbound:fee-detail:review' ) && row . reviewStatus === 'APPROVED' && row . publicationStatus !== 'VOIDED' }
2026-09-18 16:48:40 +08:00
function feeRowActions ( row : InboundFeeDetail ) : RowActionItem [] {
return [
{ key : 'edit' , label : '编辑' , priority : 400 , visible : canEdit ( row ) },
{ key : 'approve' , label : '审核' , priority : 350 , tone : 'warning' , visible : canReview ( row ) },
{ key : 'reject' , label : '驳回' , priority : 300 , tone : 'warning' , visible : canReview ( row ) },
{ key : 'adjust' , label : '调整' , priority : 250 , visible : canAdjust ( row ) },
{ key : 'void' , label : '作废' , priority : 200 , tone : 'danger' , visible : canVoid ( row ) },
{ key : 'delete' , label : '删除' , tone : 'danger' , divided : true , visible : canDelete ( row ) },
]
}
function handleFeeRowAction ( command : string , row : InboundFeeDetail ) {
if ( command === 'edit' ) openEditor ( 'edit' , row )
else if ( command === 'approve' ) void approve ( row )
else if ( command === 'reject' ) openReject ( row )
else if ( command === 'adjust' ) openEditor ( 'adjust' , row )
else if ( command === 'void' ) void voidRow ( row )
else if ( command === 'delete' ) void remove ( row )
2026-09-15 09:58:15 +08:00
}
2026-08-12 16:31:46 +08:00
function reviewLabel ( value : string ) { return ({ PENDING : '待审核' , APPROVED : '已审核' , REJECTED : '已驳回' } as Record < string , string >)[ value ] || value }
2026-08-26 15:32:22 +08:00
function publicationLabel ( value : string ) {
return ({ DRAFT : '草稿' , PUBLISHED : '已发布' , SUPERSEDED : '已被替代' , VOIDED : '已作废' } as Record < string , string >)[ value ] || value || '未知状态'
}
2026-08-12 16:31:46 +08:00
function directionLabel ( value : string ) { return value === 'RECEIVABLE' ? '应收' : '应付' }
2026-08-20 18:02:24 +08:00
function settlementLabel ( row : InboundFeeDetail ) { return row . direction !== 'PAYABLE' ? '—' : row . settlementMode === 'DIRECT_PAID' ? '已付成本' : row . settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' }
function paymentMethodLabel ( value : string | null | undefined ) { return ({ ONLINE : '线上支付' , BANK_TRANSFER : '银行转账' , CASH : '现金' , OTHER : '其他' } as Record < string , string >)[ value || '' ] || '—' }
function paidSummary ( row : InboundFeeDetail ) {
if ( row . settlementMode !== 'DIRECT_PAID' ) return '—'
const evidence = row . paymentReferenceNo || ( row . paymentVoucherFileIds ? . length ? ` ${ row . paymentVoucherFileIds . length } 个凭证` : '凭证缺失' )
return ` ${ row . paidAt ? . replace ( 'T' , ' ' ). slice ( 0 , 16 ) || '时间缺失' } · ${ paymentMethodLabel ( row . paymentMethod ) } · ${ evidence } `
}
function clearPaidFields () {
form . paidAt = null ; form . paymentMethod = null ; form . paymentReferenceNo = null ; form . paymentVoucherFileIds = null
voucherFiles . value = []; voucherError . value = null
}
2026-08-12 16:31:46 +08:00
async function load () {
2026-08-18 16:09:43 +08:00
if ( ! canView . value || ! normalizedInboundId . value ) {
rows . value = []
return
}
2026-08-12 16:31:46 +08:00
loading . value = true ; loadError . value = null
2026-08-17 19:13:25 +08:00
try {
2026-08-18 16:09:43 +08:00
rows . value = await listInboundFeeDetails ( props . accountId , normalizedInboundId . value )
2026-08-19 09:14:35 +08:00
const approved = rows . value . filter (( row ) => isManual ( row ) && row . reviewStatus === 'APPROVED' && row . publicationStatus !== 'VOIDED' )
2026-08-17 19:13:25 +08:00
emit ( 'summary' , {
currency : approved . find (( row ) => row . currency ) ? . currency || 'CNY' ,
approvedReceivable : approved . filter (( row ) => row . direction === 'RECEIVABLE' ). reduce (( sum , row ) => sum + Number ( row . amount || 0 ), 0 ),
approvedPayable : approved . filter (( row ) => row . direction === 'PAYABLE' ). reduce (( sum , row ) => sum + Number ( row . amount || 0 ), 0 ),
})
}
2026-08-12 16:31:46 +08:00
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
2026-10-19 17:56:31 +08:00
// 刷新期间移除旧候选,加载失败时不能继续使用过期默认价格。
feeItems . value = []
2026-08-12 16:40:09 +08:00
feeItemsLoading . value = true ; feeItemsError . value = null
try { feeItems . value = await listInboundFeeItemOptions () }
catch ( error ) { feeItemsError . value = getApiErrorDetails ( error , '费用项目加载失败' ) }
finally { feeItemsLoading . value = false }
}
2026-08-19 09:14:35 +08:00
async function loadSuppliers () {
const sequence = ++ supplierLoadSequence
const item = selectedFeeItem . value
2026-08-19 09:42:12 +08:00
suppliers . value = []; loadedSupplierCategory . value = undefined ; suppliersError . value = null
if ( ! canViewSuppliers . value || form . direction !== 'PAYABLE' || ! item ) return
2026-08-19 09:14:35 +08:00
const requestedCategory = item . supplierCategory
suppliersLoading . value = true
try {
const result = await listInboundFeeSupplierOptions ( requestedCategory )
if ( sequence === supplierLoadSequence && form . direction === 'PAYABLE' && selectedFeeItem . value ? . supplierCategory === requestedCategory ) {
suppliers . value = result
loadedSupplierCategory . value = requestedCategory
}
}
catch ( error ) { if ( sequence === supplierLoadSequence ) suppliersError . value = getApiErrorDetails ( error , '供应商加载失败' ) }
finally { if ( sequence === supplierLoadSequence ) suppliersLoading . 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 ),
2026-08-20 18:02:24 +08:00
supplierId : row . supplierId , settlementMode : row . settlementMode ?? null , paidAt : row . paidAt ?? null ,
paymentMethod : row . paymentMethod ?? null , paymentReferenceNo : row . paymentReferenceNo ?? null ,
paymentVoucherFileIds : row . paymentVoucherFileIds ?? null , remark : row . remark ,
2026-08-12 16:31:46 +08:00
} : emptyForm ())
2026-08-20 18:02:24 +08:00
voucherFiles . value = ( row ? . paymentVoucherFileIds || []). map (( fileId ) => ({ fileId : String ( fileId ), fileName : `凭证 ${ String ( fileId ) } ` }))
voucherError . value = null
2026-08-12 16:31:46 +08:00
}
2026-10-19 17:56:31 +08:00
// 仅在用户主动选费时带价,编辑回显不覆盖已保存的单价和金额。
function applyFeeItemDefaultPrice () {
const price = selectedFeeItem . value ? . defaultPrice ?? 0
form . unitPrice = price
form . amount = price
}
2026-08-12 16:31:46 +08:00
async function openEditor ( mode : 'create' | 'edit' | 'adjust' , row ?: InboundFeeDetail ) {
editorMode . value = mode ; target . value = row || null ; assignForm ( row ); actionError . value = null
2026-08-19 09:14:35 +08:00
feeItemsError . value = null ; suppliersError . value = null
2026-08-12 16:31:46 +08:00
editorOpen . value = true
2026-10-19 17:56:31 +08:00
// 每次进入录入窗口都读取当前项目配置,不修改已有费用的回显金额。
await loadFeeItems ()
2026-08-19 09:14:35 +08:00
if ( form . direction === 'PAYABLE' ) await loadSuppliers ()
2026-08-12 16:31:46 +08:00
}
function normalizePayload () : SaveInboundFeeDetailRequest {
2026-08-20 18:02:24 +08:00
const directPaid = form . direction === 'PAYABLE' && form . settlementMode === 'DIRECT_PAID'
2026-08-19 09:14:35 +08:00
return { ... form , currency : 'CNY' , supplierId : form . direction === 'PAYABLE' ? form . supplierId : null ,
2026-08-20 18:02:24 +08:00
settlementMode : form . direction === 'PAYABLE' ? form . settlementMode : null ,
paidAt : directPaid ? form . paidAt : null , paymentMethod : directPaid ? form . paymentMethod : null ,
paymentReferenceNo : directPaid ? form . paymentReferenceNo ? . trim () || null : null ,
paymentVoucherFileIds : directPaid && voucherFiles . value . length ? voucherFiles . value . map (( item ) => item . fileId ) : null ,
2026-08-12 16:31:46 +08:00
remark : form . remark ? . trim () || null }
}
2026-08-20 18:02:24 +08:00
async function selectVoucher ( event : Event ) {
const input = event . target as HTMLInputElement
const file = input . files ? .[ 0 ]
input . value = ''
if ( ! file || voucherUploading . value ) return
if ( voucherFiles . value . length >= 20 ) { voucherError . value = { message : '付款凭证最多上传 20 个' }; return }
voucherUploading . value = true ; voucherError . value = null
try {
const uploaded = await uploadInboundFeePaymentVoucher ( props . accountId , file )
if ( ! voucherFiles . value . some (( item ) => item . fileId === uploaded . fileId )) voucherFiles . value . push ( uploaded )
form . paymentVoucherFileIds = voucherFiles . value . map (( item ) => item . fileId )
} catch ( error ) { voucherError . value = getApiErrorDetails ( error , '付款凭证上传或校验失败' ) }
finally { voucherUploading . value = false }
}
function removeVoucher ( fileId : string ) {
voucherFiles . value = voucherFiles . value . filter (( item ) => item . fileId !== fileId )
form . paymentVoucherFileIds = voucherFiles . value . length ? voucherFiles . value . map (( item ) => item . fileId ) : null
}
2026-08-12 16:31:46 +08:00
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 }
2026-10-19 17:56:31 +08:00
if ( feeItemsLoading . value || feeItemsError . value ) { actionError . value = { message : feeItemsLoading . value ? '费用项目正在加载,请稍候' : '费用项目加载失败,请重新打开费用窗口后重试' }; return }
2026-08-19 09:14:35 +08:00
const selectedItem = feeItems . value . find (( item ) => String ( item . id ) === String ( form . feeItemId ))
2026-10-19 17:56:31 +08:00
if ( ! selectedItem ) { actionError . value = { message : '所选费用项目不可用,请重新选择' }; return }
2026-08-19 09:14:35 +08:00
if ( form . direction === 'PAYABLE' && ! canViewSuppliers . value ) { actionError . value = { message : '当前账号缺少供应商查看权限,无法维护应付费用' }; return }
if ( form . direction === 'PAYABLE' && suppliersError . value ) { actionError . value = { message : '供应商列表加载失败,无法校验应付费用供应商' }; return }
if ( form . direction === 'PAYABLE' && loadedSupplierCategory . value !== selectedItem ? . supplierCategory ) { actionError . value = { message : '供应商候选尚未按费用项目类别加载完成,无法保存' }; return }
if ( form . direction === 'PAYABLE' && ! form . supplierId ) { actionError . value = { message : '应付费用必须选择供应商' }; return }
const selectedSupplier = suppliers . value . find (( supplier ) => supplier . id === String ( form . supplierId ))
2026-08-19 09:42:12 +08:00
if ( form . direction === 'PAYABLE' && ( ! selectedSupplier || ( selectedItem ? . supplierCategory && selectedSupplier . category !== selectedItem . supplierCategory ))) { actionError . value = { message : '所选供应商与费用项目要求的供应商类别不匹配' }; return }
2026-08-20 18:02:24 +08:00
if ( form . direction === 'PAYABLE' && ! form . settlementMode ) { actionError . value = { message : '应付费用必须选择结算方式' }; return }
if ( voucherUploading . value || voucherError . value ) { actionError . value = { message : voucherUploading . value ? '付款凭证正在上传,请稍候' : '付款凭证上传或校验失败,请处理后再保存' }; return }
if ( form . settlementMode === 'DIRECT_PAID' && ( ! form . paidAt || ! form . paymentMethod || ( ! form . paymentReferenceNo ? . trim () && ! voucherFiles . value . length ))) {
actionError . value = { message : '直接已付必须填写已付时间、付款方式,并提供流水号或付款凭证' }; return
}
2026-08-12 16:31:46 +08:00
saving . value = true ; actionError . value = null
try {
const payload = normalizePayload ()
2026-08-18 16:09:43 +08:00
if ( editorMode . value === 'create' ) await createInboundFeeDetail ( props . accountId , normalizedInboundId . value , payload )
else if ( editorMode . value === 'edit' && target . value ) await updateInboundFeeDetail ( props . accountId , normalizedInboundId . value , String ( target . value . id ), payload )
else if ( target . value ) await adjustInboundFeeDetail ( props . accountId , normalizedInboundId . value , String ( target . value . id ), target . value . sourceFeeVersion , payload )
2026-08-12 16:31:46 +08:00
editorOpen . value = false
await finishAction ( editorMode . value === 'adjust' ? '费用调整已提交审核' : '费用明细已保存' )
} catch ( error ) { actionError . value = getApiErrorDetails ( error , '费用明细保存失败' ) }
finally { saving . value = false }
}
async function remove ( row : InboundFeeDetail ) {
try {
2026-09-15 09:58:15 +08:00
await ElMessageBox . confirm ( '删除后无法恢复,是否继续?' , '删除费用明细' , { customClass : 'admin-density-confirm' , type : 'warning' , confirmButtonText : '删除' })
2026-08-18 16:09:43 +08:00
await deleteInboundFeeDetail ( props . accountId , normalizedInboundId . value , String ( row . id )); await finishAction ( '费用明细已删除' )
2026-08-12 16:31:46 +08:00
} catch ( error ) { if ( error !== 'cancel' && error !== 'close' ) actionError . value = getApiErrorDetails ( error , '费用明细删除失败' ) }
}
async function approve ( row : InboundFeeDetail ) {
2026-08-18 16:09:43 +08:00
try { await reviewInboundFeeDetail ( props . accountId , normalizedInboundId . value , String ( row . id ), 'approve' ); await finishAction ( '费用审核通过' ) }
2026-08-12 16:31:46 +08:00
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
2026-08-18 16:09:43 +08:00
try { await reviewInboundFeeDetail ( props . accountId , normalizedInboundId . value , String ( target . value . id ), 'reject' , rejectReason . value . trim ()); rejectOpen . value = false ; await finishAction ( '费用已驳回' ) }
2026-08-12 16:31:46 +08:00
catch ( error ) { actionError . value = getApiErrorDetails ( error , '费用驳回失败' ) }
finally { saving . value = false }
}
async function voidRow ( row : InboundFeeDetail ) {
try {
2026-09-15 09:58:15 +08:00
await ElMessageBox . confirm ( '作废会同步撤销尚未结算的财务费用来源,是否继续?' , '作废费用明细' , { customClass : 'admin-density-confirm' , type : 'warning' , confirmButtonText : '作废' })
2026-08-18 16:09:43 +08:00
await voidInboundFeeDetail ( props . accountId , normalizedInboundId . value , String ( row . id ), row . sourceFeeVersion ); await finishAction ( '费用明细已作废' )
2026-08-12 16:31:46 +08:00
} catch ( error ) { if ( error !== 'cancel' && error !== 'close' ) actionError . value = getApiErrorDetails ( error , '费用作废失败' ) }
}
2026-08-18 16:09:43 +08:00
watch (() => [ props . accountId , normalizedInboundId . value ], () => void load ())
2026-08-12 16:40:09 +08:00
watch (() => form . direction , ( direction ) => {
2026-08-20 18:02:24 +08:00
if ( direction === 'RECEIVABLE' ) { supplierLoadSequence += 1 ; form . supplierId = null ; form . settlementMode = null ; clearPaidFields (); suppliers . value = []; loadedSupplierCategory . value = undefined ; suppliersError . value = null ; suppliersLoading . value = false }
2026-08-19 09:14:35 +08:00
else void loadSuppliers ()
2026-08-12 16:40:09 +08:00
})
2026-08-20 18:02:24 +08:00
watch (() => form . settlementMode , ( mode ) => { if ( mode !== 'DIRECT_PAID' ) clearPaidFields () })
2026-08-19 09:42:12 +08:00
watch (() => form . feeItemId , () => { form . supplierId = null ; suppliers . value = []; loadedSupplierCategory . value = undefined ; suppliersError . value = null ; if ( form . direction === 'PAYABLE' ) void loadSuppliers () })
2026-08-12 16:31:46 +08:00
onMounted (() => void load ())
</ script >
< template >
2026-09-15 09:58:15 +08:00
< section class = "fee-detail-manager admin-density" aria -label =" 运单费用维护 " >
2026-08-12 16:31:46 +08:00
< div class = "fee-toolbar" >
< span > 共 {{ rows . length }} 条应收应付费用明细 </ span >
< div >
2026-09-15 18:16:09 +08:00
< el-tooltip content = "刷新费用明细" placement = "top" >< el-button class = "fee-refresh-button" circle aria -label =" 刷新费用明细 " :aria-busy = "loading" :disabled = "loading" @click ="load" >< RefreshCw :size = "16" : class = "{ 'is-spinning': loading }" /></ 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" >
2026-08-12 18:52:22 +08:00
< el-table :data = "rows" border stripe empty -text =" 暂无费用数据 " row -key =" id " >
2026-08-12 16:31:46 +08:00
< el-table-column label = "操作" fixed = "left" width = "250" >
2026-09-18 16:48:40 +08:00
< template # default = "{ row }" >< RowActions : actions = "feeRowActions(row as InboundFeeDetail)" : more -label ="`$ { ( row as InboundFeeDetail ) .feeTypeNameSnapshot || ' 当前费用 ' } 的更多操作 `" @action ="handleFeeRowAction($event, row as InboundFeeDetail)" /></ template >
2026-08-12 16:31:46 +08:00
</ el-table-column >
< el-table-column label = "审核状态" width = "120" >< template # default = "{ row }" >< StatusBadge :status = "reviewLabel(row.reviewStatus)" /></ template ></ el-table-column >
2026-08-17 19:13:25 +08:00
< el-table-column label = "来源" width = "120" >< template # default = "{ row }" >{{ row . sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</ template ></ el-table-column >
2026-08-12 16:31:46 +08:00
< el-table-column label = "应收/应付" width = "110" >< template # default = "{ row }" >{{ directionLabel ( row . direction ) }}</ template ></ el-table-column >
2026-08-20 18:02:24 +08:00
< el-table-column label = "结算方式" width = "120" >< template # default = "{ row }" >{{ settlementLabel ( row as InboundFeeDetail ) }}</ template ></ el-table-column >
< el-table-column label = "已付摘要" min -width =" 290 " show -overflow-tooltip >< template # default = "{ row }" >{{ paidSummary ( row as InboundFeeDetail ) }}</ template ></ el-table-column >
2026-09-08 17:38:09 +08:00
< el-table-column label = "费用类型" min -width =" 190 " show -overflow-tooltip >< template # default = "{ row }" >< span >{{ row . feeTypeNameSnapshot }}</ span >< el-tag v-if = "row.feeTypeNameSnapshot === '私人仓尾程派送费'" class="private-delivery-tag" size="small" type="warning" effect="light" > 仅私人仓 </ el -tag ></ template ></ el-table-column >
2026-08-19 09:14:35 +08:00
< el-table-column label = "供应商/客户" min -width =" 170 " >< template # default = "{ row }" >{{ row . direction === 'PAYABLE' ? row . supplierName : row . customerName || '—' }}</ template ></ el-table-column >
2026-08-12 16:31:46 +08:00
< 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 prop = "sourceFeeVersion" label = "版本" width = "85" />
2026-08-26 15:32:22 +08:00
< el-table-column label = "发布状态" min -width =" 110 " >< template # default = "{ row }" >< StatusBadge :status = "publicationLabel(row.publicationStatus)" /></ template ></ el-table-column >
2026-08-12 16:31:46 +08:00
< 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 >
2026-10-20 14:55:02 +08:00
< AppDialog class = "admin-density-dialog" v-model = "editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(1000px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save" >
2026-08-12 16:31:46 +08:00
< el-alert v-if = "editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show -icon />
2026-10-20 15:48:23 +08:00
2026-08-12 16:40:09 +08:00
< el-alert v-if = "!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show -icon />
2026-08-19 09:14:35 +08:00
< el-alert v-if = "!canViewSuppliers" title="当前账号缺少供应商查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show -icon />
2026-08-12 16:40:09 +08:00
< el-alert v-if = "feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></ el-alert >
2026-08-19 09:14:35 +08:00
< el-alert v-if = "suppliersError" type="error" :closable="true" @close="suppliersError = null"><template #title>{{ suppliersError.message }}</template><ApiErrorMeta :details="suppliersError" /></ 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 >
2026-10-20 10:11:31 +08:00
< section class = "density-dialog-section" data -tone =" work " >
< h3 class = "density-dialog-section__title" > 费用资料 </ h3 >
< el-form ref = "formRef" :model = "form" :rules = "rules" label -position =" top " class = "fee-form density-form" >
< el-form-item label = "应收/应付" prop = "direction" >< el-segmented v-model = "form.direction" :options="directionOptions" /></ el-form-item >
2026-10-20 15:48:23 +08:00
< el-form-item label = "费用项目" prop = "feeItemId" >< template # label >< span > 费用项目 < InlineHelp title = "费用项目说明" >< p > 私人仓尾程派送费仅适用于目的仓类型为 “ 私人仓 ” 的订单 , 最终资格由服务端校验 。 </ p ></ InlineHelp ></ span ></ template >< el-select v-model = "form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems || feeItemsLoading" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :value="item.id" /></ el-select ></ el-form-item >
2026-10-20 10:11:31 +08:00
< el-form-item v-if = "form.direction === 'PAYABLE'" label="供应商" required><el-select v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></ el-select ></ el-form-item >
< el-form-item v-if = "form.direction === 'PAYABLE'" label="结算方式" required><el-radio-group v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID" > 已付成本 </ el -radio-button ></ el-radio-group ></ el-form-item >
< template v-if = "form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'" >
< el-form-item label = "已付时间" required >< el-date-picker v-model = "form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际付款时间" /></ el-form-item >
< el-form-item label = "付款方式" required >< el-select v-model = "form.paymentMethod" placeholder="请选择付款方式"><el-option label="线上支付" value="ONLINE" /><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 = "付款流水号" >< el-input v-model = "form.paymentReferenceNo" maxlength="128" placeholder="流水号与付款凭证至少填写一项" /></ el-form-item >
< el-form-item label = "付款凭证" class = "voucher-field" >
2026-10-20 14:52:06 +08:00
< div class = "voucher-control" >< label class = "voucher-picker" : class = "{ 'is-disabled': voucherUploading }" >< FileUp :size = "15" />< span >{{ voucherUploading ? '上传并校验中' : '选择凭证' }}</ span >< input type = "file" accept = ".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled = "voucherUploading" @change ="selectVoucher" /></ label >< span class = "voucher-hint" > 支持 PDF 、 JPG 、 PNG , 最多 20 个 </ span ></ div >
2026-10-20 10:11:31 +08:00
< el-alert v-if = "voucherError" type="error" :closable="true" show-icon @close="voucherError = null"><template #title>{{ voucherError.message }}</template><ApiErrorMeta :details="voucherError" /></ el-alert >
< div v-if = "voucherFiles.length" class="voucher-list"><span v-for="file in voucherFiles" :key="file.fileId"><span :title="file.fileName">{{ file.fileName }}</span><button type="button" :aria-label="`移除${file.fileName}`" @click="removeVoucher(file.fileId)"><X :size="14" /></ button ></ span ></ div >
</ el-form-item >
</ template >
< 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" placeholder="请输入单价" /></ el-form-item >
< el-form-item label = "金额" prop = "amount" >< el-input-number v-model = "form.amount" :min="0" :precision="2" placeholder="请输入金额" /></ el-form-item >
2026-10-20 14:52:06 +08:00
< el-form-item label = "备注" >< el-input v-model = "form.remark" type="textarea" :rows="3" maxlength="500" show -word-limit /></ el-form-item >
2026-10-20 10:11:31 +08:00
</ el-form >
</ section >
2026-08-12 16:31:46 +08:00
</ AppDialog >
2026-10-20 14:55:02 +08:00
< AppDialog class = "admin-density-dialog" v-model = "rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" width="min(880px, calc(100vw - 28px))" @confirm="reject" >
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 >
2026-09-15 09:58:15 +08:00
< el-form class = "density-form" label -position =" top " >< el-form-item label = "驳回原因" required >< el-input v-model = "rejectReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></ el-form-item ></ el-form >
2026-08-12 16:31:46 +08:00
</ AppDialog >
</ section >
</ template >
< style scoped >
2026-09-15 09:58:15 +08:00
. fee - detail - manager { display : grid ; min - width : 0 ; max - width : 100 % ; gap : var ( -- admin - space - 5 ); }
. fee - toolbar { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); color : var ( -- el - text - color - secondary ); font - size : var ( -- admin - font - body ); }
. fee - toolbar > div { display : flex ; flex - wrap : wrap ; gap : var ( -- admin - space - 3 ); }
2026-08-12 16:31:46 +08:00
. fee - toolbar : deep (. el - button + . el - button ) { margin - left : 0 ; }
2026-09-15 18:16:09 +08:00
. fee - refresh - button . is - spinning { animation : fee - refresh - spin 0.8 s linear infinite ; }
2026-10-20 10:11:31 +08:00
. fee - form { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 0 var ( -- admin - space - 6 ); }
2026-08-12 16:31:46 +08:00
. 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 ; }
2026-09-15 09:58:15 +08:00
. voucher - field { grid - column : 1 / - 1 ; }. voucher - control { display : flex ; align - items : center ; flex - wrap : wrap ; gap : var ( -- admin - space - 4 ); width : 100 % ; }. voucher - picker { display : inline - flex ; align - items : center ; gap : var ( -- admin - space - 2 ); padding : var ( -- admin - space - 2 ) var ( -- admin - space - 5 ); border : 1 px solid var ( -- cc - border ); border - radius : var ( -- admin - radius ); color : var ( -- el - color - primary ); cursor : pointer ; }. voucher - picker . is - disabled { cursor : not - allowed ; opacity : .6 ; }. voucher - picker input { position : absolute ; width : 1 px ; height : 1 px ; opacity : 0 ; }. voucher - hint { color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); }. voucher - list { display : grid ; width : 100 % ; gap : var ( -- admin - space - 2 ); margin - top : var ( -- admin - space - 3 ); }. voucher - list > span { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 3 ); padding : var ( -- admin - space - 2 ) var ( -- admin - space - 3 ); border - radius : var ( -- admin - radius ); background : var ( -- el - fill - color - light ); }. voucher - list > span > span { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }. voucher - list button { display : inline - flex ; padding : 2 px ; border : 0 ; background : transparent ; color : var ( -- el - color - danger ); cursor : pointer ; }
2026-08-12 16:31:46 +08:00
. fee - table - shell { min - width : 0 ; max - width : 100 % ; overflow : hidden ; }
. fee - table - shell : deep (. el - table ) { width : 100 % ; max - width : 100 % ; }
2026-09-15 09:58:15 +08:00
. private - delivery - tag { margin - left : var ( -- admin - space - 2 ); vertical - align : middle ; }
2026-08-17 18:32:41 +08:00
@ keyframes fee - refresh - spin { to { transform : rotate ( 360 deg ); } }
2026-08-12 16:31:46 +08:00
@ 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 >