396 lines
32 KiB
Vue
396 lines
32 KiB
Vue
<script setup lang="ts">
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||
import { FileUp, Plus, RefreshCw, X } from '@lucide/vue'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import {
|
||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
|
||
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
|
||
reviewInboundFeeDetail, updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail,
|
||
} from '@/modules/unified-orders/api/inboundFeeDetails'
|
||
import type {
|
||
InboundFeeDetail, InboundFeeItemOption,
|
||
InboundFeeSupplierOption, SaveInboundFeeDetailRequest,
|
||
} from '@/modules/unified-orders/api/inboundFeeDetails'
|
||
|
||
const props = defineProps<{ accountId: string; inboundId: string; destinationType?: string | null }>()
|
||
interface OtherFeeSummary { currency: string; approvedReceivable: number; approvedPayable: number }
|
||
const emit = defineEmits<{ changed: []; summary: [value: OtherFeeSummary] }>()
|
||
const access = useAccessPreviewStore()
|
||
const rows = ref<InboundFeeDetail[]>([])
|
||
const feeItems = ref<InboundFeeItemOption[]>([])
|
||
const suppliers = ref<InboundFeeSupplierOption[]>([])
|
||
const feeItemsLoading = ref(false)
|
||
const suppliersLoading = ref(false)
|
||
const loadedSupplierCategory = ref<string | null | undefined>(undefined)
|
||
const feeItemsError = ref<ApiErrorDetails | null>(null)
|
||
const suppliersError = ref<ApiErrorDetails | null>(null)
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
const actionError = ref<ApiErrorDetails | null>(null)
|
||
const editorOpen = ref(false)
|
||
const editorMode = ref<'create' | 'edit' | 'adjust'>('create')
|
||
const target = ref<InboundFeeDetail | null>(null)
|
||
const formRef = ref<FormInstance>()
|
||
const rejectOpen = ref(false)
|
||
const rejectReason = ref('')
|
||
const voucherFiles = ref<Array<{ fileId: string; fileName: string }>>([])
|
||
const voucherUploading = ref(false)
|
||
const voucherError = ref<ApiErrorDetails | null>(null)
|
||
let supplierLoadSequence = 0
|
||
|
||
const emptyForm = (): SaveInboundFeeDetailRequest => ({
|
||
feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
|
||
unitPrice: 0, currency: 'CNY', amount: 0,
|
||
supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null,
|
||
paymentReferenceNo: null, paymentVoucherFileIds: null, remark: null,
|
||
})
|
||
const form = reactive<SaveInboundFeeDetailRequest>(emptyForm())
|
||
const settlementModeModel = computed<string>({
|
||
get: () => form.settlementMode ?? '',
|
||
set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
|
||
})
|
||
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'))
|
||
const normalizedInboundId = computed(() => {
|
||
const identifier = String(props.inboundId ?? '').trim()
|
||
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
|
||
})
|
||
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
|
||
const canViewSuppliers = computed(() => access.can('platform:supplier:list'))
|
||
const directionOptions = computed(() => [
|
||
{ label: '应收', value: 'RECEIVABLE' },
|
||
{ label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers.value },
|
||
])
|
||
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
|
||
}))
|
||
const selectedFeeItem = computed(() => feeItems.value.find((item) => String(item.id) === String(form.feeItemId)))
|
||
|
||
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' }
|
||
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)
|
||
}
|
||
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
|
||
function publicationLabel(value: string) {
|
||
return ({ DRAFT: '草稿', PUBLISHED: '已发布', SUPERSEDED: '已被替代', VOIDED: '已作废' } as Record<string, string>)[value] || value || '未知状态'
|
||
}
|
||
function directionLabel(value: string) { return value === 'RECEIVABLE' ? '应收' : '应付' }
|
||
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
|
||
}
|
||
|
||
async function load() {
|
||
if (!canView.value || !normalizedInboundId.value) {
|
||
rows.value = []
|
||
return
|
||
}
|
||
loading.value = true; loadError.value = null
|
||
try {
|
||
rows.value = await listInboundFeeDetails(props.accountId, normalizedInboundId.value)
|
||
const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED')
|
||
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),
|
||
})
|
||
}
|
||
catch (error) { loadError.value = getApiErrorDetails(error, '费用明细加载失败') }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
async function loadFeeItems() {
|
||
if (!canViewFeeItems.value || feeItemsLoading.value) return
|
||
// 刷新期间移除旧候选,加载失败时不能继续使用过期默认价格。
|
||
feeItems.value = []
|
||
feeItemsLoading.value = true; feeItemsError.value = null
|
||
try { feeItems.value = await listInboundFeeItemOptions() }
|
||
catch (error) { feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败') }
|
||
finally { feeItemsLoading.value = false }
|
||
}
|
||
|
||
async function loadSuppliers() {
|
||
const sequence = ++supplierLoadSequence
|
||
const item = selectedFeeItem.value
|
||
suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null
|
||
if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !item) return
|
||
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 }
|
||
}
|
||
|
||
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),
|
||
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,
|
||
} : emptyForm())
|
||
voucherFiles.value = (row?.paymentVoucherFileIds || []).map((fileId) => ({ fileId: String(fileId), fileName: `凭证 ${String(fileId)}` }))
|
||
voucherError.value = null
|
||
}
|
||
|
||
// 仅在用户主动选费时带价,编辑回显不覆盖已保存的单价和金额。
|
||
function applyFeeItemDefaultPrice() {
|
||
const price = selectedFeeItem.value?.defaultPrice ?? 0
|
||
form.unitPrice = price
|
||
form.amount = price
|
||
}
|
||
|
||
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
|
||
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
|
||
feeItemsError.value = null; suppliersError.value = null
|
||
editorOpen.value = true
|
||
// 每次进入录入窗口都读取当前项目配置,不修改已有费用的回显金额。
|
||
await loadFeeItems()
|
||
if (form.direction === 'PAYABLE') await loadSuppliers()
|
||
}
|
||
|
||
function normalizePayload(): SaveInboundFeeDetailRequest {
|
||
const directPaid = form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'
|
||
return { ...form, currency: 'CNY', 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,
|
||
remark: form.remark?.trim() || null }
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
async function finishAction(message: string) {
|
||
await load(); emit('changed'); ElMessage.success(message)
|
||
}
|
||
|
||
async function save() {
|
||
if (!await formRef.value?.validate()) return
|
||
if (!canViewFeeItems.value) { actionError.value = { message: '当前账号缺少费用项目查看权限,无法保存费用' }; return }
|
||
if (feeItemsLoading.value || feeItemsError.value) { actionError.value = { message: feeItemsLoading.value ? '费用项目正在加载,请稍候' : '费用项目加载失败,请重新打开费用窗口后重试' }; return }
|
||
const selectedItem = feeItems.value.find((item) => String(item.id) === String(form.feeItemId))
|
||
if (!selectedItem) { actionError.value = { message: '所选费用项目不可用,请重新选择' }; return }
|
||
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))
|
||
if (form.direction === 'PAYABLE' && (!selectedSupplier || (selectedItem?.supplierCategory && selectedSupplier.category !== selectedItem.supplierCategory))) { actionError.value = { message: '所选供应商与费用项目要求的供应商类别不匹配' }; return }
|
||
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
|
||
}
|
||
saving.value = true; actionError.value = null
|
||
try {
|
||
const payload = normalizePayload()
|
||
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)
|
||
editorOpen.value = false
|
||
await finishAction(editorMode.value === 'adjust' ? '费用调整已提交审核' : '费用明细已保存')
|
||
} catch (error) { actionError.value = getApiErrorDetails(error, '费用明细保存失败') }
|
||
finally { saving.value = false }
|
||
}
|
||
|
||
async function remove(row: InboundFeeDetail) {
|
||
try {
|
||
await ElMessageBox.confirm('删除后无法恢复,是否继续?', '删除费用明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '删除' })
|
||
await deleteInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id)); await finishAction('费用明细已删除')
|
||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用明细删除失败') }
|
||
}
|
||
|
||
async function approve(row: InboundFeeDetail) {
|
||
try { await reviewInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id), 'approve'); await finishAction('费用审核通过') }
|
||
catch (error) { actionError.value = getApiErrorDetails(error, '费用审核失败') }
|
||
}
|
||
|
||
function openReject(row: InboundFeeDetail) { target.value = row; rejectReason.value = ''; actionError.value = null; rejectOpen.value = true }
|
||
async function reject() {
|
||
if (!target.value || !rejectReason.value.trim()) { actionError.value = { message: '请填写驳回原因' }; return }
|
||
saving.value = true
|
||
try { await reviewInboundFeeDetail(props.accountId, normalizedInboundId.value, String(target.value.id), 'reject', rejectReason.value.trim()); rejectOpen.value = false; await finishAction('费用已驳回') }
|
||
catch (error) { actionError.value = getApiErrorDetails(error, '费用驳回失败') }
|
||
finally { saving.value = false }
|
||
}
|
||
|
||
async function voidRow(row: InboundFeeDetail) {
|
||
try {
|
||
await ElMessageBox.confirm('作废会同步撤销尚未结算的财务费用来源,是否继续?', '作废费用明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '作废' })
|
||
await voidInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id), row.sourceFeeVersion); await finishAction('费用明细已作废')
|
||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用作废失败') }
|
||
}
|
||
|
||
watch(() => [props.accountId, normalizedInboundId.value], () => void load())
|
||
watch(() => form.direction, (direction) => {
|
||
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="fee-detail-manager admin-density" aria-label="运单费用维护">
|
||
<div class="fee-toolbar">
|
||
<span>共 {{ rows.length }} 条应收应付费用明细</span>
|
||
<div>
|
||
<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>
|
||
<el-button v-if="access.can('firstleg:inbound:fee-detail:edit') && canViewFeeItems" type="primary" @click="openEditor('create')"><Plus :size="16" />新增费用</el-button>
|
||
</div>
|
||
</div>
|
||
<el-alert v-if="!canView" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
|
||
<LoadErrorState v-else-if="loadError" title="费用明细加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||
<template v-else>
|
||
<el-alert v-if="actionError && !editorOpen && !rejectOpen" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||
<div v-loading="loading" class="fee-table-shell">
|
||
<el-table :data="rows" border stripe empty-text="暂无费用数据" row-key="id">
|
||
<el-table-column label="操作" fixed="left" width="250">
|
||
<template #default="{ row }"><RowActions :actions="feeRowActions(row as InboundFeeDetail)" :more-label="`${(row as InboundFeeDetail).feeTypeNameSnapshot || '当前费用'}的更多操作`" @action="handleFeeRowAction($event, row as InboundFeeDetail)" /></template>
|
||
</el-table-column>
|
||
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
|
||
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
|
||
<el-table-column label="应收/应付" width="110"><template #default="{ row }">{{ directionLabel(row.direction) }}</template></el-table-column>
|
||
<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>
|
||
<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>
|
||
<el-table-column label="供应商/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName : row.customerName || '—' }}</template></el-table-column>
|
||
<el-table-column prop="businessDate" label="业务发生日" width="130" />
|
||
<el-table-column prop="currency" label="货币" width="90" />
|
||
<el-table-column label="金额" min-width="120"><template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template></el-table-column>
|
||
<el-table-column label="单价" min-width="110"><template #default="{ row }">{{ Number(row.unitPrice).toFixed(4) }}</template></el-table-column>
|
||
<el-table-column prop="sourceFeeVersion" label="版本" width="85" />
|
||
<el-table-column label="发布状态" min-width="110"><template #default="{ row }"><StatusBadge :status="publicationLabel(row.publicationStatus)" /></template></el-table-column>
|
||
<el-table-column prop="reviewReason" label="驳回原因" min-width="180" show-overflow-tooltip />
|
||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<AppDialog 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">
|
||
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
|
||
|
||
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
|
||
<el-alert v-if="!canViewSuppliers" title="当前账号缺少供应商查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
|
||
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
|
||
<el-alert v-if="suppliersError" type="error" :closable="true" @close="suppliersError = null"><template #title>{{ suppliersError.message }}</template><ApiErrorMeta :details="suppliersError" /></el-alert>
|
||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||
<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>
|
||
<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>
|
||
<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">
|
||
<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>
|
||
</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>
|
||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||
</el-form>
|
||
</section>
|
||
</AppDialog>
|
||
<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">
|
||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||
<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>
|
||
</AppDialog>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.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); }
|
||
.fee-toolbar :deep(.el-button + .el-button) { margin-left: 0; }
|
||
.fee-refresh-button .is-spinning { animation: fee-refresh-spin 0.8s linear infinite; }
|
||
.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
|
||
.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; }
|
||
.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: 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) 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: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
|
||
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
|
||
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
|
||
.private-delivery-tag { margin-left: var(--admin-space-2); vertical-align: middle; }
|
||
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
|
||
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||
</style>
|