2026-08-12 16:31:46 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
2026-08-20 18:02:24 +08:00
|
|
|
|
import { Check, CircleX, FileUp, Pencil, Plus, Trash2, X } from '@lucide/vue'
|
2026-08-12 16:31:46 +08:00
|
|
|
|
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'
|
2026-08-30 17:43:03 +08:00
|
|
|
|
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
|
2026-09-02 17:07:03 +08:00
|
|
|
|
import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
2026-08-12 16:31:46 +08:00
|
|
|
|
|
2026-08-30 17:43:03 +08:00
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
planId: string
|
|
|
|
|
|
containerBookingId: string
|
|
|
|
|
|
customers: Array<{ accountId: string; customerName: string | null }>
|
2026-09-02 17:07:03 +08:00
|
|
|
|
businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }>
|
2026-08-30 17:43:03 +08:00
|
|
|
|
}>()
|
2026-08-12 16:31:46 +08:00
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
|
const accountId = ref('')
|
|
|
|
|
|
const fees = ref<ContainerFeeResource[]>([])
|
2026-08-27 21:48:15 +08:00
|
|
|
|
const settledFeeIds = ref<Set<string>>(new Set())
|
2026-08-12 16:31:46 +08:00
|
|
|
|
const feeItems = ref<ApplicableFeeItem[]>([])
|
2026-08-19 09:14:35 +08:00
|
|
|
|
const suppliers = ref<ContainerFeeSupplierOption[]>([])
|
2026-08-12 16:31:46 +08:00
|
|
|
|
const costs = ref<ContainerCostDetail[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
2026-09-08 20:55:30 +08:00
|
|
|
|
const financeLoading = ref(false)
|
|
|
|
|
|
const financeLoaded = ref(false)
|
2026-08-12 16:31:46 +08:00
|
|
|
|
const error = ref<ApiErrorDetails | null>(null)
|
2026-09-08 20:55:30 +08:00
|
|
|
|
const financeError = ref<ApiErrorDetails | null>(null)
|
2026-08-12 16:58:52 +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:58:52 +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:31:46 +08:00
|
|
|
|
const editorOpen = ref(false)
|
2026-08-18 17:40:40 +08:00
|
|
|
|
const editorError = ref<ApiErrorDetails | null>(null)
|
2026-08-12 16:31:46 +08:00
|
|
|
|
const rejectOpen = ref(false)
|
|
|
|
|
|
const saving = ref(false)
|
|
|
|
|
|
const target = ref<ContainerFeeResource | null>(null)
|
2026-09-02 17:07:03 +08:00
|
|
|
|
const targetGroupRows = ref<ContainerFeeResource[]>([])
|
|
|
|
|
|
const allocationDraft = ref<ContainerFeeAllocation[]>([])
|
2026-08-12 16:31:46 +08:00
|
|
|
|
const formRef = ref<FormInstance>()
|
2026-08-20 18:02:24 +08:00
|
|
|
|
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 })
|
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-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-12 16:31:46 +08:00
|
|
|
|
const rejection = ref('')
|
2026-08-12 16:40:09 +08:00
|
|
|
|
let loadSequence = 0
|
2026-08-19 09:14:35 +08:00
|
|
|
|
let supplierLoadSequence = 0
|
2026-08-12 16:31:46 +08:00
|
|
|
|
|
|
|
|
|
|
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
|
|
|
|
|
const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit'))
|
|
|
|
|
|
const canReview = computed(() => access.can('firstleg:inbound:fee-detail:review'))
|
2026-08-12 16:58:52 +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:31:46 +08:00
|
|
|
|
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))
|
2026-09-02 17:07:03 +08:00
|
|
|
|
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))
|
2026-08-30 17:43:03 +08:00
|
|
|
|
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])))
|
2026-08-12 16:31:46 +08:00
|
|
|
|
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
|
|
|
|
|
|
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
|
2026-08-19 09:14:35 +08:00
|
|
|
|
const selectedFeeItem = computed(() => feeItems.value.find((item) => item.id === form.feeItemId))
|
2026-08-12 16:31:46 +08:00
|
|
|
|
const columns: DataTableColumn[] = [
|
2026-08-18 16:39:44 +08:00
|
|
|
|
{ key: 'actions', label: '操作', fixed: 'left', width: 270, slotName: 'actions', hideable: false, movable: false },
|
2026-08-12 16:31:46 +08:00
|
|
|
|
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
|
|
|
|
|
|
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
|
2026-08-20 18:02:24 +08:00
|
|
|
|
{ key: 'settlementMode', label: '结算方式', width: 120, slotName: 'settlementMode' },
|
|
|
|
|
|
{ key: 'paidSummary', label: '已付摘要', minWidth: 290, slotName: 'paidSummary' },
|
2026-08-19 09:14:35 +08:00
|
|
|
|
{ key: 'supplier', label: '供应商/客户', minWidth: 170, slotName: 'supplier' },
|
2026-09-02 17:07:03 +08:00
|
|
|
|
{ key: 'businessReference', label: '费用归属', minWidth: 220, slotName: 'businessReference' },
|
2026-08-12 16:31:46 +08:00
|
|
|
|
{ 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[] = [
|
2026-08-30 17:43:03 +08:00
|
|
|
|
{ key: 'orderNo', label: '订单编码', fixed: 'left', minWidth: 170, slotName: 'orderNo' },
|
|
|
|
|
|
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
|
2026-08-12 16:31:46 +08:00
|
|
|
|
{ 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
|
2026-09-02 17:07:03 +08:00
|
|
|
|
targetGroupRows.value = []
|
|
|
|
|
|
allocationDraft.value = item?.allocationGroupId ? [] : allocationCandidates.value.map((value) => ({ ...value }))
|
2026-08-20 18:02:24 +08:00
|
|
|
|
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 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 || ''] || '—' }
|
2026-09-02 17:07:03 +08:00
|
|
|
|
function customerLabel(customerId: string) { return customerOptions.value.find((item) => item.value === customerId)?.label || '客户信息缺失' }
|
2026-08-20 18:02:24 +08:00
|
|
|
|
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}`
|
|
|
|
|
|
}
|
2026-08-27 21:48:15 +08:00
|
|
|
|
function isSettled(item: ContainerFeeResource) {
|
|
|
|
|
|
return item.settlementMode === 'DIRECT_PAID' || settledFeeIds.value.has(item.id)
|
|
|
|
|
|
}
|
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 || !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
|
2026-08-12 16:58:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadFeeItems() {
|
|
|
|
|
|
feeItems.value = []; feeItemsError.value = null
|
|
|
|
|
|
if (!canViewFeeItems.value) return
|
|
|
|
|
|
feeItemsLoading.value = true
|
|
|
|
|
|
try { feeItems.value = await listApplicableFeeItems() }
|
|
|
|
|
|
catch (cause) { feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
|
|
|
|
|
|
finally { feeItemsLoading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 09:14:35 +08:00
|
|
|
|
async function loadSuppliers() {
|
|
|
|
|
|
const sequence = ++supplierLoadSequence
|
2026-08-19 09:42:12 +08:00
|
|
|
|
suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null
|
|
|
|
|
|
const item = selectedFeeItem.value
|
|
|
|
|
|
if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !item) return
|
|
|
|
|
|
const category = item.supplierCategory
|
2026-08-19 09:14:35 +08:00
|
|
|
|
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 }
|
2026-08-12 16:31:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
if (!accountId.value || !canView.value) return
|
2026-08-12 16:40:09 +08:00
|
|
|
|
const sequence = ++loadSequence
|
|
|
|
|
|
const requestedAccountId = accountId.value
|
2026-08-12 16:31:46 +08:00
|
|
|
|
loading.value = true; error.value = null
|
|
|
|
|
|
try {
|
2026-08-12 16:58:52 +08:00
|
|
|
|
const feeRows = await listContainerFees(props.planId, requestedAccountId)
|
2026-08-12 16:40:09 +08:00
|
|
|
|
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
2026-08-12 16:58:52 +08:00
|
|
|
|
fees.value = feeRows
|
2026-09-08 20:55:30 +08:00
|
|
|
|
settledFeeIds.value = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
|
2026-08-12 16:31:46 +08:00
|
|
|
|
costs.value = []
|
2026-09-08 20:55:30 +08:00
|
|
|
|
financeLoaded.value = false
|
|
|
|
|
|
financeError.value = null
|
2026-08-12 16:40:09 +08:00
|
|
|
|
} catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
|
|
|
|
|
|
finally { if (sequence === loadSequence) loading.value = false }
|
2026-08-12 16:31:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-08 20:55:30 +08:00
|
|
|
|
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('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
|
|
|
|
|
|
await loadSettledFeeIds(requestedAccountId, feeRows, sequence)
|
|
|
|
|
|
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
|
|
|
|
|
costs.value = props.containerBookingId ? await listContainerCostDetails(requestedAccountId, props.containerBookingId) : []
|
|
|
|
|
|
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 21:48:15 +08:00
|
|
|
|
async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResource[], sequence: number) {
|
|
|
|
|
|
const settled = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
|
|
|
|
|
|
settledFeeIds.value = settled
|
|
|
|
|
|
const listRequests: Array<Promise<Awaited<ReturnType<typeof listFinanceReceivableSettlements>> | Awaited<ReturnType<typeof listFinancePayableSettlements>>>> = []
|
|
|
|
|
|
if (access.can('platform:finance:settlement-ar:list')) listRequests.push(listFinanceReceivableSettlements(customerId))
|
|
|
|
|
|
if (access.can('platform:finance:settlement-ap:list')) listRequests.push(listFinancePayableSettlements(customerId))
|
|
|
|
|
|
if (!listRequests.length) return
|
|
|
|
|
|
|
|
|
|
|
|
const settlementResults = await Promise.allSettled(listRequests)
|
|
|
|
|
|
const paidSettlements = settlementResults.flatMap((result) => result.status === 'fulfilled'
|
|
|
|
|
|
? result.value.filter((item) => item.status === 'PAID' || item.paymentStatus === 'PAID')
|
|
|
|
|
|
: [])
|
|
|
|
|
|
const detailResults = await Promise.allSettled(paidSettlements.map((item) => getFinanceSettlement(item.id)))
|
|
|
|
|
|
for (const result of detailResults) {
|
|
|
|
|
|
if (result.status !== 'fulfilled') continue
|
|
|
|
|
|
for (const line of result.value.lines) {
|
|
|
|
|
|
if (line.sourceType === 'CONTAINER_FEE' && line.sourceFeeId) settled.add(line.sourceFeeId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sequence === loadSequence && customerId === accountId.value) settledFeeIds.value = settled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 09:14:35 +08:00
|
|
|
|
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
|
2026-09-02 17:07:03 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-12 16:31:46 +08:00
|
|
|
|
async function save() {
|
2026-08-18 17:40:40 +08:00
|
|
|
|
if (!canEdit.value || !accountId.value || (!target.value && !canViewFeeItems.value)) return
|
2026-08-19 09:14:35 +08:00
|
|
|
|
if (!target.value && form.direction === 'PAYABLE' && !canViewSuppliers.value) { ElMessage.warning('当前账号缺少供应商查看权限,无法保存应付费用'); return }
|
2026-08-12 16:58:52 +08:00
|
|
|
|
if (!await formRef.value?.validate().catch(() => false)) return
|
2026-08-18 17:40:40 +08:00
|
|
|
|
editorError.value = null
|
|
|
|
|
|
if (target.value) {
|
|
|
|
|
|
saving.value = true
|
|
|
|
|
|
try {
|
2026-09-02 17:07:03 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-19 09:14:35 +08:00
|
|
|
|
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))
|
2026-08-19 09:42:12 +08:00
|
|
|
|
if (form.direction === 'PAYABLE' && (!supplier || (selectedFeeItem.value?.supplierCategory && supplier.category !== selectedFeeItem.value.supplierCategory))) { ElMessage.warning('所选供应商与费用项目要求的供应商类别不匹配'); return }
|
2026-08-20 18:02:24 +08:00
|
|
|
|
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 }
|
2026-08-12 16:31:46 +08:00
|
|
|
|
saving.value = true
|
|
|
|
|
|
try {
|
2026-09-02 17:07:03 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
editorOpen.value = false; ElMessage.success('费用已新增'); await load()
|
|
|
|
|
|
} catch (cause) { editorError.value = getApiErrorDetails(cause, '费用保存失败') }
|
2026-08-12 16:31:46 +08:00
|
|
|
|
finally { saving.value = false }
|
|
|
|
|
|
}
|
2026-09-02 17:07:03 +08:00
|
|
|
|
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)) }
|
2026-08-12 16:40:09 +08:00
|
|
|
|
function openReject(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; target.value = item; rejection.value = ''; rejectOpen.value = true }
|
2026-08-12 16:31:46 +08:00
|
|
|
|
async function reject() {
|
2026-08-12 16:40:09 +08:00
|
|
|
|
if (!canReview.value || !accountId.value || !target.value) return
|
|
|
|
|
|
if (!rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
|
2026-08-12 16:31:46 +08:00
|
|
|
|
saving.value = true
|
2026-09-02 17:07:03 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
2026-08-12 16:31:46 +08:00
|
|
|
|
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
async function voidFee(item: ContainerFeeResource) {
|
2026-08-12 16:40:09 +08:00
|
|
|
|
if (!canReview.value || !accountId.value) return
|
2026-08-12 16:31:46 +08:00
|
|
|
|
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' })
|
2026-09-02 17:07:03 +08:00
|
|
|
|
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))
|
2026-08-12 16:31:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 : '' }
|
2026-08-12 16:40:09 +08:00
|
|
|
|
function clearCustomerContext() {
|
|
|
|
|
|
loadSequence += 1
|
2026-08-19 09:14:35 +08:00
|
|
|
|
supplierLoadSequence += 1
|
2026-09-08 20:55:30 +08:00
|
|
|
|
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
|
2026-08-12 16:40:09 +08:00
|
|
|
|
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
|
|
|
|
|
|
resetForm()
|
|
|
|
|
|
}
|
2026-08-12 16:31:46 +08:00
|
|
|
|
watch(customerOptions, initializeCustomer, { immediate: true })
|
2026-08-12 16:40:09 +08:00
|
|
|
|
watch(accountId, () => { clearCustomerContext(); void load() })
|
2026-08-12 16:58:52 +08:00
|
|
|
|
watch(() => form.direction, (direction) => {
|
|
|
|
|
|
if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = ''
|
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:58:52 +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-08-18 17:40:40 +08:00
|
|
|
|
<section class="fees-panel" aria-label="柜次费用维护">
|
2026-08-18 16:39:44 +08:00
|
|
|
|
<header class="fees-header">
|
|
|
|
|
|
<div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></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>
|
2026-08-12 16:31:46 +08:00
|
|
|
|
<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="该柜次包含多个客户,请先选择费用归属客户" />
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<el-alert v-else-if="!canViewFeeItems" type="warning" :closable="false" show-icon title="当前账号缺少费用项目查看权限,无法新增费用;已审核费用仍可调整日期与金额" />
|
2026-08-12 16:31:46 +08:00
|
|
|
|
<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="当前客户暂无柜次费用">
|
2026-08-18 16:39:44 +08:00
|
|
|
|
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
|
2026-09-08 20:55:30 +08:00
|
|
|
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
2026-08-12 16:31:46 +08:00
|
|
|
|
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
2026-08-20 18:02:24 +08:00
|
|
|
|
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
|
|
|
|
|
|
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
|
2026-08-19 09:14:35 +08:00
|
|
|
|
<template #supplier="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template>
|
2026-09-02 17:07:03 +08:00
|
|
|
|
<template #businessReference="{ row }"><div class="business-reference"><span>{{ row.allocationOrderNo || '订单号待补充' }}</span><small>{{ row.allocationInboundCode || '无入仓码' }}<template v-if="row.allocationGroupId"> · 分摊组</template></small></div></template>
|
2026-08-19 09:14:35 +08:00
|
|
|
|
<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>
|
2026-08-12 16:31:46 +08:00
|
|
|
|
</DataTable>
|
|
|
|
|
|
|
2026-09-08 20:55:30 +08:00
|
|
|
|
<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>
|
2026-08-12 16:31:46 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
|
|
|
|
|
|
<div v-if="target" class="adjust-snapshot">
|
2026-08-19 09:14:35 +08:00
|
|
|
|
<p>调整仅允许修改业务日期和金额;如需变更方向、费用项目、供应商或其他信息,请先作废后新增。</p>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<el-descriptions :column="3" border size="small">
|
|
|
|
|
|
<el-descriptions-item label="方向">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="费用项目">{{ target.feeItemName }}</el-descriptions-item>
|
2026-08-19 09:14:35 +08:00
|
|
|
|
<el-descriptions-item label="供应商">{{ target.supplierName || '—' }}</el-descriptions-item>
|
2026-08-20 18:02:24 +08:00
|
|
|
|
<el-descriptions-item label="结算方式">{{ settlementLabel(target) }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="已付摘要">{{ paidSummary(target) }}</el-descriptions-item>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
|
2026-09-02 17:07:03 +08:00
|
|
|
|
<el-descriptions-item label="费用归属">{{ target.allocationOrderNo || '—' }} / {{ target.allocationInboundCode || '无入仓码' }}</el-descriptions-item>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
</el-descriptions>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template v-else>
|
2026-08-19 09:14:35 +08:00
|
|
|
|
<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" />
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form">
|
|
|
|
|
|
<template v-if="!target">
|
2026-08-19 09:14:35 +08:00
|
|
|
|
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
2026-08-19 09:42:12 +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>
|
2026-08-20 18:05:27 +08:00
|
|
|
|
<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>
|
2026-08-20 18:02:24 +08:00
|
|
|
|
<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"><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">FINANCE_VOUCHER · 最多 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>
|
|
|
|
|
|
</template>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
2026-09-03 19:29:38 +08:00
|
|
|
|
<el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入金额" /></el-form-item>
|
2026-09-02 17:07:03 +08:00
|
|
|
|
<el-form-item v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
|
|
|
|
|
|
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
|
|
|
|
|
|
<div class="allocation-editor__rows">
|
|
|
|
|
|
<div v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`" class="allocation-editor__row">
|
|
|
|
|
|
<span><strong>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</strong><small>{{ row.orderNo }} · {{ row.inboundCode || '无入仓码' }}</small></span>
|
2026-09-03 19:29:38 +08:00
|
|
|
|
<el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入分摊金额" />
|
2026-09-02 17:07:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</p>
|
|
|
|
|
|
</el-form-item>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<template v-if="!target">
|
2026-09-03 19:29:38 +08:00
|
|
|
|
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-form>
|
|
|
|
|
|
<LoadErrorState v-if="editorError" compact title="费用保存失败" :message="editorError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
|
|
|
|
|
</AppDialog>
|
2026-08-12 16:31:46 +08:00
|
|
|
|
<AppDialog v-model="rejectOpen" title="驳回柜次费用" 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>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
.fees-panel { min-width: 0; overflow: hidden; padding-top: 16px; border-top: 1px solid var(--cc-border); }
|
|
|
|
|
|
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 0 12px; }
|
2026-08-12 16:31:46 +08:00
|
|
|
|
.fees-header h2, .allocation-section h3 { margin: 0; font-size: 16px; }.fees-header p, .allocation-section p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
2026-08-18 16:39:44 +08:00
|
|
|
|
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: 10px; }
|
|
|
|
|
|
.customer-context > span { color: var(--cc-muted); font-size: 13px; white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
|
|
|
|
|
|
.fees-table-title { display: flex; align-items: baseline; gap: 8px; }.fees-table-title strong { font-size: 14px; }.fees-table-title span { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.fees-panel :deep(.data-table__toolbar) { min-height: 48px; padding: 6px 12px; }
|
|
|
|
|
|
.fees-panel :deep(.el-table th.el-table__cell) { padding: 7px 0; }.fees-panel :deep(.el-table td.el-table__cell) { padding: 6px 0; }
|
2026-08-18 17:40:40 +08:00
|
|
|
|
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { padding-top: 16px; border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 14px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
2026-08-20 18:02:24 +08:00
|
|
|
|
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; 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: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 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; }
|
2026-08-18 17:40:40 +08:00
|
|
|
|
.adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; }
|
2026-09-02 17:07:03 +08:00
|
|
|
|
.business-reference { display: grid; gap: 2px; }.business-reference small { color: var(--cc-muted); }
|
|
|
|
|
|
.allocation-editor { grid-column: 1 / -1; }.allocation-editor :deep(.el-form-item__content) { display: block; }
|
|
|
|
|
|
.allocation-editor__rows { display: grid; gap: 8px; margin-top: 10px; }.allocation-editor__row { display: grid; grid-template-columns: minmax(0, 1fr) 160px; align-items: center; gap: 12px; padding: 8px 10px; border: 1px solid var(--cc-border); border-radius: 6px; }.allocation-editor__row > span { display: grid; min-width: 0; }.allocation-editor__row small { overflow: hidden; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }.allocation-editor__sum { margin: 8px 0 0; text-align: right; color: var(--el-color-success); }.allocation-editor__sum.is-error { color: var(--el-color-danger); }
|
2026-08-12 16:31:46 +08:00
|
|
|
|
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } }
|
|
|
|
|
|
</style>
|