Files
admin-web/src/modules/loading-lists/components/ContainerFeesPanel.vue
T
2026-10-20 14:52:06 +08:00

452 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { FileUp, Plus, X } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
const props = withDefaults(defineProps<{
title?: string
planId: string
containerBookingId: string
cabinetNo?: string | null
containerNo?: string | null
customers: Array<{ accountId: string; customerName: string | null }>
businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }>
readonly?: boolean
}>(), { title: '柜次费用', readonly: false })
function feeDialogTitle(action: string) {
const code = props.cabinetNo?.trim() || props.containerNo?.trim()
return code ? `${action} · ${code}` : action
}
const access = useAccessPreviewStore()
const accountId = ref('')
const fees = ref<ContainerFeeResource[]>([])
const settledFeeIds = ref<Set<string>>(new Set())
const feeItems = ref<ApplicableFeeItem[]>([])
const suppliers = ref<ContainerFeeSupplierOption[]>([])
const costs = ref<ContainerCostDetail[]>([])
const loading = ref(false)
const financeLoading = ref(false)
const financeLoaded = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const financeError = ref<ApiErrorDetails | null>(null)
const feeItemsError = ref<ApiErrorDetails | null>(null)
const suppliersError = ref<ApiErrorDetails | null>(null)
const feeItemsLoading = ref(false)
const suppliersLoading = ref(false)
const loadedSupplierCategory = ref<string | null | undefined>(undefined)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const rejectOpen = ref(false)
const saving = ref(false)
const target = ref<ContainerFeeResource | null>(null)
const targetGroupRows = ref<ContainerFeeResource[]>([])
const allocationDraft = ref<ContainerFeeAllocation[]>([])
const formRef = ref<FormInstance>()
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
const settlementModeModel = computed<string>({
get: () => form.settlementMode ?? '',
set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
})
const voucherFiles = ref<Array<{ fileId: string; fileName: string }>>([])
const voucherUploading = ref(false)
const voucherError = ref<ApiErrorDetails | null>(null)
const rejection = ref('')
let loadSequence = 0
let feeItemLoadSequence = 0
let supplierLoadSequence = 0
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const canEdit = computed(() => !props.readonly && access.can('firstleg:inbound:fee-detail:edit'))
const canReview = computed(() => !props.readonly && access.can('firstleg:inbound:fee-detail:review'))
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
const canViewSuppliers = computed(() => access.can('platform:supplier:list'))
const customerOptions = computed(() => {
const unique = new Map<string, string>()
for (const item of props.customers) if (item.accountId) unique.set(item.accountId, item.customerName || '客户信息缺失')
return [...unique].map(([value, label]) => ({ value, label }))
})
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
const allocationCandidates = computed(() => {
const unique = new Map<string, ContainerFeeAllocation>()
for (const item of props.businessReferences) {
if (!item.accountId || !item.orderId || !item.orderNo) continue
const key = `${item.accountId}|${item.orderId}|${item.inboundId || ''}|${item.inboundCode || ''}`
unique.set(key, { customerId: item.accountId, orderId: item.orderId, orderNo: item.orderNo,
inboundId: item.inboundId || null, inboundCode: item.inboundCode?.trim() || null, amount: 0 })
}
return [...unique.values()]
})
const allocationMode = computed(() => allocationCandidates.value.length > 1 || Boolean(target.value?.allocationGroupId))
const allocationSum = computed(() => allocationDraft.value.reduce((sum, item) => sum + Number(item.amount || 0), 0))
const orderNumbers = computed(() => new Map(props.businessReferences.map((item) => [item.orderId, item.orderNo])))
const inboundCodes = computed(() => new Map(props.businessReferences.map((item) => [item.inboundId, item.inboundCode])))
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
const selectedFeeItem = computed(() => feeItems.value.find((item) => item.id === form.feeItemId))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 270, slotName: 'actions', hideable: false, movable: false },
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
{ key: 'settlementMode', label: '结算方式', width: 120, slotName: 'settlementMode' },
{ key: 'paidSummary', label: '已付摘要', minWidth: 290, slotName: 'paidSummary' },
{ key: 'supplier', label: '供应商/客户', minWidth: 170, slotName: 'supplier' },
{ key: 'businessReference', label: '费用归属', minWidth: 220, slotName: 'businessReference' },
{ key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' },
{ key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 },
{ key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' },
{ key: 'sourceStatus', label: '来源状态', width: 100, slotName: 'sourceStatus' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
]
const allocationColumns: DataTableColumn[] = [
{ key: 'orderNo', label: '订单编码', fixed: 'left', minWidth: 170, slotName: 'orderNo' },
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
{ key: 'volumeCbm', label: '订单体积(m³)', width: 125, align: 'right', slotName: 'volume' },
{ key: 'allocationRate', label: '分摊比例', width: 110, align: 'right', slotName: 'rate' },
{ key: 'allocatedAmount', label: '分摊金额(CNY)', width: 135, align: 'right', slotName: 'allocated' },
]
const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费用项目', trigger: 'change' }], businessDate: [{ required: true, message: '请选择业务日期', trigger: 'change' }], amount: [{ type: 'number', min: 0, message: '金额不能小于 0', trigger: 'change' }] }
function resetForm(item?: ContainerFeeResource) {
target.value = item || null
targetGroupRows.value = []
allocationDraft.value = item?.allocationGroupId ? [] : allocationCandidates.value.map((value) => ({ ...value }))
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, supplierId: item.supplierId, settlementMode: item.settlementMode ?? null, paidAt: item.paidAt ?? null, paymentMethod: item.paymentMethod ?? null, paymentReferenceNo: item.paymentReferenceNo ?? null, paymentVoucherFileIds: item.paymentVoucherFileIds ?? null, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', remark: item.remark }
: { feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
voucherFiles.value = (item?.paymentVoucherFileIds || []).map((fileId) => ({ fileId, fileName: `凭证 ${fileId}` }))
voucherError.value = null
}
function clearPaidFields() {
form.paidAt = null; form.paymentMethod = null; form.paymentReferenceNo = null; form.paymentVoucherFileIds = null
voucherFiles.value = []; voucherError.value = null
}
// 仅在用户主动选费时带价,保留已有费用回显金额与人工修改。
function applyFeeItemDefaultPrice() {
form.amount = selectedFeeItem.value?.defaultPrice ?? 0
}
function settlementLabel(item: ContainerFeeResource) { return item.direction !== 'PAYABLE' ? '—' : item.settlementMode === 'DIRECT_PAID' ? '已付成本' : item.settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' }
function paymentMethodLabel(value: string | null | undefined) { return ({ ONLINE: '线上支付', BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[value || ''] || '—' }
function customerLabel(customerId: string) { return customerOptions.value.find((item) => item.value === customerId)?.label || '客户信息缺失' }
function paidSummary(item: ContainerFeeResource) {
if (item.settlementMode !== 'DIRECT_PAID') return '—'
const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
return `${item.paidAt?.replace('T', ' ').slice(0, 16) || '时间缺失'} · ${paymentMethodLabel(item.paymentMethod)} · ${evidence}`
}
function isSettled(item: ContainerFeeResource) {
return item.settlementMode === 'DIRECT_PAID' || settledFeeIds.value.has(item.id)
}
async function selectVoucher(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || voucherUploading.value || !accountId.value) return
if (voucherFiles.value.length >= 20) { voucherError.value = { message: '付款凭证最多上传 20 个' }; return }
voucherUploading.value = true; voucherError.value = null
try {
const uploaded = await uploadContainerFeePaymentVoucher(accountId.value, file)
if (!voucherFiles.value.some((item) => item.fileId === uploaded.fileId)) voucherFiles.value.push(uploaded)
form.paymentVoucherFileIds = voucherFiles.value.map((item) => item.fileId)
} catch (cause) { voucherError.value = getApiErrorDetails(cause, '付款凭证上传或校验失败') }
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
}
async function loadFeeItems() {
const sequence = ++feeItemLoadSequence
feeItems.value = []; feeItemsError.value = null
if (!canViewFeeItems.value) return
feeItemsLoading.value = true
try {
const items = await listApplicableFeeItems()
// 只接收当前窗口最新请求,旧响应不得覆盖价格或清除新请求的加载状态。
if (sequence === feeItemLoadSequence) feeItems.value = items
}
catch (cause) { if (sequence === feeItemLoadSequence) feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
finally { if (sequence === feeItemLoadSequence) feeItemsLoading.value = false }
}
async function loadSuppliers() {
const sequence = ++supplierLoadSequence
suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null
const item = selectedFeeItem.value
if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !item) return
const category = item.supplierCategory
suppliersLoading.value = true
try {
const result = await listContainerFeeSupplierOptions(category)
if (sequence === supplierLoadSequence && form.direction === 'PAYABLE' && selectedFeeItem.value?.supplierCategory === category) {
suppliers.value = result
loadedSupplierCategory.value = category
}
}
catch (cause) { if (sequence === supplierLoadSequence) suppliersError.value = getApiErrorDetails(cause, '供应商加载失败') }
finally { if (sequence === supplierLoadSequence) suppliersLoading.value = false }
}
async function load() {
if (!accountId.value || !canView.value) return
const sequence = ++loadSequence
const requestedAccountId = accountId.value
loading.value = true; error.value = null
try {
const feeRows = await listContainerFees(props.planId, requestedAccountId)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
fees.value = feeRows
settledFeeIds.value = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
costs.value = []
financeLoaded.value = false
financeError.value = null
} catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
finally { if (sequence === loadSequence) loading.value = false }
}
async function loadFinanceDetails() {
if (!accountId.value || !canView.value || !fees.value.length || financeLoading.value) return
const sequence = loadSequence
const requestedAccountId = accountId.value
const feeRows = [...fees.value]
financeLoading.value = true; financeError.value = null
try {
const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))]
if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
const aggregate = await getContainerSettlementDetails(requestedAccountId, props.containerBookingId)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
settledFeeIds.value = new Set([
...feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id),
...aggregate.settledSourceFeeIds,
])
costs.value = aggregate.costs
financeLoaded.value = true
} catch (cause) {
if (sequence === loadSequence && requestedAccountId === accountId.value) financeError.value = getApiErrorDetails(cause, '财务状态加载失败')
} finally {
if (sequence === loadSequence && requestedAccountId === accountId.value) financeLoading.value = false
}
}
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
async function openAdjust(item: ContainerFeeResource) {
if (!canEdit.value || !accountId.value) return
resetForm(item); editorError.value = null
if (item.allocationGroupId) {
try {
const all = await Promise.all(customerOptions.value.map((customer) => listContainerFees(props.planId, customer.value)))
targetGroupRows.value = all.flat().filter((row, index, rows) => row.allocationGroupId === item.allocationGroupId
&& rows.findIndex((candidate) => candidate.id === row.id) === index)
allocationDraft.value = targetGroupRows.value.map((row) => ({ customerId: '', orderId: row.allocationOrderId || '',
orderNo: row.allocationOrderNo || '', inboundId: row.allocationInboundId ?? null,
inboundCode: row.allocationInboundCode ?? null, amount: row.amount }))
if (!targetGroupRows.value.length) throw new Error('未读取到完整分摊组')
form.amount = item.allocationGroupAmount ?? targetGroupRows.value.reduce((sum, row) => sum + row.amount, 0)
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '完整分摊组加载失败').message); return }
}
editorOpen.value = true
}
async function save() {
if (!canEdit.value || !accountId.value || (!target.value && !canViewFeeItems.value)) return
if (!target.value && form.direction === 'PAYABLE' && !canViewSuppliers.value) { ElMessage.warning('当前账号缺少供应商查看权限,无法保存应付费用'); return }
if (!await formRef.value?.validate().catch(() => false)) return
editorError.value = null
if (target.value) {
saving.value = true
try {
if (target.value.allocationGroupId) {
if (Math.abs(allocationSum.value - form.amount) > 0.001 || allocationDraft.value.some((row) => row.amount <= 0)) {
ElMessage.warning('每个业务归属的分摊金额必须大于 0,且合计等于费用总额'); return
}
await adjustContainerFeeAllocationGroup(props.planId, target.value.allocationGroupId, {
expectedSourceVersion: target.value.sourceVersion, businessDate: form.businessDate,
totalAmount: form.amount, allocations: targetGroupRows.value.map((row, index) => ({
feeId: row.id, amount: allocationDraft.value[index]?.amount || 0,
})),
})
} else {
await adjustContainerFee(props.planId, accountId.value, target.value.id, {
expectedSourceVersion: target.value.sourceVersion,
businessDate: form.businessDate,
amount: form.amount,
})
}
editorOpen.value = false; ElMessage.success('费用调整已提交审核'); await load()
} catch (cause) {
const details = getApiErrorDetails(cause, '费用调整失败')
editorError.value = details.code === 'A0409'
? { ...details, message: `${details.message};当前输入已保留,请刷新费用明细后重试。` }
: details
} finally { saving.value = false }
return
}
if (form.direction === 'PAYABLE' && suppliersError.value) { ElMessage.warning('供应商列表加载失败,无法校验应付费用供应商'); return }
if (form.direction === 'PAYABLE' && loadedSupplierCategory.value !== selectedFeeItem.value?.supplierCategory) { ElMessage.warning('供应商候选尚未按费用项目类别加载完成,无法保存'); return }
if (form.direction === 'PAYABLE' && !form.supplierId) { ElMessage.warning('应付费用必须选择供应商'); return }
const supplier = suppliers.value.find((item) => item.id === String(form.supplierId))
if (form.direction === 'PAYABLE' && (!supplier || (selectedFeeItem.value?.supplierCategory && supplier.category !== selectedFeeItem.value.supplierCategory))) { ElMessage.warning('所选供应商与费用项目要求的供应商类别不匹配'); return }
if (form.direction === 'PAYABLE' && !form.settlementMode) { ElMessage.warning('应付费用必须选择结算方式'); return }
if (voucherUploading.value || voucherError.value) { ElMessage.warning(voucherUploading.value ? '付款凭证正在上传,请稍候' : '付款凭证上传或校验失败,请处理后再保存'); return }
if (form.settlementMode === 'DIRECT_PAID' && (!form.paidAt || !form.paymentMethod || (!form.paymentReferenceNo?.trim() && !voucherFiles.value.length))) { ElMessage.warning('直接已付必须填写已付时间、付款方式,并提供流水号或付款凭证'); return }
const directPaid = form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'
const payload: ContainerFeeSaveRequest = { ...form, supplierId: form.direction === 'PAYABLE' ? form.supplierId : null,
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 }
saving.value = true
try {
if (allocationMode.value) {
const allocations = allocationDraft.value.filter((row) => row.amount > 0)
if (!allocations.length || Math.abs(allocationSum.value - form.amount) > 0.001) {
ElMessage.warning('请填写分摊金额,分摊合计必须等于费用总额'); return
}
if (payload.settlementMode === 'DIRECT_PAID' && payload.paymentVoucherFileIds?.length
&& new Set(allocations.map((row) => row.customerId)).size > 1) {
ElMessage.warning('跨客户直接已付费用不能共用客户凭证,请改用付款流水号或分别录入'); return
}
await createContainerFeeAllocationGroup(props.planId, { fee: payload, allocations })
} else {
await createContainerFee(props.planId, accountId.value, payload)
}
editorOpen.value = false; ElMessage.success('费用已新增'); await load()
} catch (cause) { editorError.value = getApiErrorDetails(cause, '费用保存失败') }
finally { saving.value = false }
}
async function approve(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; await (item.allocationGroupId
? approveContainerFeeAllocationGroup(props.planId, item.allocationGroupId)
: approveContainerFee(props.planId, accountId.value, item.id)).then(() => ElMessage.success(item.allocationGroupId ? '分摊组已整体审核通过' : '费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
function openReject(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; target.value = item; rejection.value = ''; rejectOpen.value = true }
async function reject() {
if (!canReview.value || !accountId.value || !target.value) return
if (!rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
saving.value = true
try {
if (target.value.allocationGroupId) await rejectContainerFeeAllocationGroup(props.planId, target.value.allocationGroupId, rejection.value.trim())
else await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim())
rejectOpen.value = false; ElMessage.success(target.value.allocationGroupId ? '分摊组已整体驳回' : '费用已驳回'); await load()
}
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false }
}
async function voidFee(item: ContainerFeeResource) {
if (!canReview.value || !accountId.value) return
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, feeDialogTitle('作废费用'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废' })
await (item.allocationGroupId
? voidContainerFeeAllocationGroup(props.planId, item.allocationGroupId, item.sourceVersion)
: voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion))
.then(() => ElMessage.success(item.allocationGroupId ? '分摊组已整体作废' : '费用已作废')).then(load)
.catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message))
}
function money(value: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' }
function clearCustomerContext() {
loadSequence += 1
supplierLoadSequence += 1
fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; financeError.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; financeLoading.value = false; financeLoaded.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
resetForm()
}
watch(customerOptions, initializeCustomer, { immediate: true })
watch(accountId, () => { clearCustomerContext(); void load() })
watch(editorOpen, (open) => {
if (open) return
// 同步失效旧窗口请求,覆盖关闭后立即重开及切换客户的情况。
feeItemLoadSequence += 1
feeItems.value = []; feeItemsError.value = null; feeItemsLoading.value = false
}, { flush: 'sync' })
watch(() => form.direction, (direction) => {
if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = ''
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; form.settlementMode = null; clearPaidFields(); suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; suppliersLoading.value = false }
else void loadSuppliers()
})
watch(() => form.settlementMode, (mode) => { if (mode !== 'DIRECT_PAID') clearPaidFields() })
watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() })
onMounted(() => void load())
</script>
<template>
<section class="fees-panel admin-density" aria-label="柜次费用维护">
<header class="fees-header">
<div><h2>{{ title }}</h2></div>
<div class="fees-header__actions">
<label v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label>
<el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button>
</div>
</header>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
<template v-else>
<el-alert v-if="customerOptions.length === 0" type="warning" :closable="false" show-icon title="装柜明细缺少客户归属,无法安全维护费用" />
<el-alert v-else-if="!accountId" type="info" :closable="false" show-icon title="该柜次包含多个客户,请先选择费用归属客户" />
<el-alert v-else-if="!canViewFeeItems" type="warning" :closable="false" show-icon title="当前账号缺少费用项目查看权限,无法新增费用;已审核费用仍可调整日期与金额" />
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="warning" class="density-action" v-if="canReview && row.reviewStatus === 'PENDING'" type="success" @click="approve(row as ContainerFeeResource)">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.reviewStatus === 'PENDING'" type="danger" @click="openReject(row as ContainerFeeResource)">驳回</el-button><el-button class="density-action" v-if="canEdit && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" type="primary" @click="openAdjust(row as ContainerFeeResource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" type="danger" @click="voidFee(row as ContainerFeeResource)">作废</el-button></div></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
<template #supplier="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template>
<template #businessReference="{ row }"><div class="business-reference"><span>{{ row.allocationOrderNo || '订单号待补充' }}</span><small>{{ row.allocationInboundCode || '无入仓码' }}<template v-if="row.allocationGroupId"> · 分摊组</template></small></div></template>
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
</DataTable>
<div v-if="accountId && fees.length" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div><el-button :loading="financeLoading" @click="loadFinanceDetails">{{ financeLoaded ? '刷新财务状态' : '加载财务状态' }}</el-button></header><LoadErrorState v-if="financeError" compact title="财务状态加载失败" :message="financeError.message" @retry="loadFinanceDetails"><template #details><ApiErrorMeta :details="financeError" /></template></LoadErrorState><el-empty v-else-if="!financeLoaded" description="点击加载财务状态后查看结算与成本分摊" :image-size="64" /><el-empty v-else-if="!financeLoading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
</template>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="feeDialogTitle(target ? '调整已审核费用' : '新增柜次费用')" width="min(1080px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="saving" @confirm="save">
<p v-if="target" class="density-dialog-note">调整仅允许修改业务日期和金额;如需变更方向、费用项目、供应商或其他信息,请先作废后新增。</p>
<template v-if="!target">
<el-alert v-if="!canViewSuppliers" type="warning" :closable="false" show-icon title="当前账号缺少供应商查看权限,仅可维护应收费用" />
<el-alert v-else-if="suppliersError" type="warning" :closable="false" show-icon :title="suppliersError.message" />
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
</template>
<section class="density-dialog-section" data-tone="work">
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" class="density-form density-form--flush-end">
<table class="density-table density-table--editable fee-editor-table" aria-label="柜次费用填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="110px" label="方向" :prop="target ? undefined : 'direction'"><span v-if="target">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</span><el-segmented v-else v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" :prop="target ? undefined : 'feeItemId'"><span v-if="target">{{ target.feeItemName }}</span><el-select v-else v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></td></tr>
<tr v-if="target || form.direction === 'PAYABLE'"><td><el-form-item label-position="left" label-width="110px" label="供应商" :required="!target"><span v-if="target">{{ target.supplierName || '—' }}</span><el-select v-else 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></td><td><el-form-item label-position="left" label-width="110px" label="结算方式" :required="!target"><span v-if="target">{{ settlementLabel(target) }}</span><el-radio-group v-else 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></td></tr>
<tr v-if="target"><td><el-form-item label="已付摘要" label-position="left" label-width="110px"><span>{{ paidSummary(target) }}</span></el-form-item></td><td><el-form-item label="币种" label-position="left" label-width="110px"><span>{{ target.currency }}</span></el-form-item></td></tr>
<tr v-if="!target && form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td><el-form-item label-position="left" label-width="110px" label="已付时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际付款时间" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" 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></td></tr>
<tr v-if="!target && form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="流水号与付款凭证至少填写一项" /></el-form-item></td></tr>
<tr v-if="!target && form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="付款凭证" class="voucher-field"><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><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></td></tr>
<tr><td><el-form-item label-position="left" label-width="110px" label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入金额" /></el-form-item></td></tr>
<tr v-if="target"><td><el-form-item label="备注" label-position="left" label-width="110px"><span>{{ target.remark || '—' }}</span></el-form-item></td><td><el-form-item label="费用归属" label-position="left" label-width="110px"><span>{{ (target.allocationOrderNo || '—') + ' / ' + (target.allocationInboundCode || '无入仓码') }}</span></el-form-item></td></tr>
<tr v-if="!target"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></td></tr>
</tbody></table>
<el-form-item label-position="top" v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
<table class="density-table density-table--editable allocation-editor-table" aria-label="业务归属分摊"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th class="allocation-amount">分摊金额(CNY)</th></tr></thead><tbody>
<tr v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`"><td>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</td><td>{{ row.orderNo }}</td><td>{{ row.inboundCode || '无入仓码' }}</td><td><el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入分摊金额" /></td></tr>
</tbody><tfoot><tr><td colspan="4"><span class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</span></td></tr></tfoot></table>
</el-form-item>
</el-form>
</section>
<LoadErrorState v-if="editorError" compact title="费用保存失败" :message="editorError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="rejectOpen" :title="feeDialogTitle('驳回柜次费用')" confirm-text="确认驳回" :confirm-loading="saving" @confirm="reject"><el-input v-model="rejection" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></AppDialog>
</section>
</template>
<style scoped>
.fees-panel { min-width: 0; overflow: hidden; padding-top: 0; border-top: 0; }
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: 0 0 var(--admin-space-5); }
.fees-header h2, .allocation-section h3 { margin: 0; font-size: var(--admin-font-section); }.allocation-section p { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: var(--admin-space-4); }
.customer-context > span { color: var(--cc-muted); font-size: var(--admin-font-body); white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
.fees-table-title { display: flex; align-items: baseline; gap: var(--admin-space-3); }.fees-table-title strong { font-size: var(--admin-font-section); }.fees-table-title span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.row-actions { flex-wrap: wrap; }.allocation-section { padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-4) var(--admin-space-6); padding: var(--admin-space-4) var(--admin-space-5); background: #f8fafc; font-size: var(--admin-font-body); }.cost-summary span { color: var(--cc-muted); }.fee-editor-table { table-layout: fixed; }
.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: 7px var(--admin-space-5); border: 1px 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: 1px; height: 1px; 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) 9px; border-radius: 5px; 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: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
.business-reference { display: grid; gap: 2px; }.business-reference small { color: var(--cc-muted); }
.allocation-editor { margin-top: var(--admin-space-5); }.allocation-editor :deep(.el-form-item__content) { display: block; }.allocation-editor-table { margin-top: var(--admin-space-3); }.allocation-amount { width: 180px; }.allocation-editor__sum { color: var(--el-color-success); }.allocation-editor__sum.is-error { color: var(--el-color-danger); }
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-editor-table, .fee-editor-table tbody, .fee-editor-table tr, .fee-editor-table td { display: block; width: 100%; }.customer-context { align-items: stretch; flex-direction: column; } }
</style>