完善柜级费用分摊与财务来源展示
This commit is contained in:
@@ -10,13 +10,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
planId: string
|
||||
containerBookingId: string
|
||||
customers: Array<{ accountId: string; customerName: string | null }>
|
||||
businessReferences: Array<{ orderId: string; orderNo: string; inboundId: string; inboundCode: string | null }>
|
||||
businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }>
|
||||
}>()
|
||||
const access = useAccessPreviewStore()
|
||||
const accountId = ref('')
|
||||
@@ -37,6 +37,8 @@ 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>({
|
||||
@@ -61,6 +63,18 @@ const customerOptions = computed(() => {
|
||||
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'
|
||||
@@ -73,6 +87,7 @@ const columns: DataTableColumn[] = [
|
||||
{ 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' },
|
||||
@@ -90,6 +105,8 @@ const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费
|
||||
|
||||
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}` }))
|
||||
@@ -102,6 +119,7 @@ function clearPaidFields() {
|
||||
}
|
||||
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} 个凭证` : '凭证缺失')
|
||||
@@ -203,7 +221,23 @@ async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResour
|
||||
}
|
||||
|
||||
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
|
||||
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
|
||||
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 }
|
||||
@@ -212,11 +246,23 @@ async function save() {
|
||||
if (target.value) {
|
||||
saving.value = true
|
||||
try {
|
||||
await adjustContainerFee(props.planId, accountId.value, target.value.id, {
|
||||
expectedSourceVersion: target.value.sourceVersion,
|
||||
businessDate: form.businessDate,
|
||||
amount: form.amount,
|
||||
})
|
||||
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, '费用调整失败')
|
||||
@@ -242,24 +288,46 @@ async function save() {
|
||||
paymentVoucherFileIds: directPaid && voucherFiles.value.length ? voucherFiles.value.map((item) => item.fileId) : null }
|
||||
saving.value = true
|
||||
try {
|
||||
await createContainerFee(props.planId, accountId.value, payload)
|
||||
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 approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
|
||||
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 { await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim()); rejectOpen.value = false; ElMessage.success('费用已驳回'); await load() }
|
||||
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}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' })
|
||||
await voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion).then(() => ElMessage.success('费用已作废')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message))
|
||||
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 : '' }
|
||||
@@ -304,6 +372,7 @@ onMounted(() => void load())
|
||||
<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>
|
||||
|
||||
@@ -321,6 +390,7 @@ onMounted(() => void load())
|
||||
<el-descriptions-item label="已付摘要">{{ paidSummary(target) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用归属">{{ target.allocationOrderNo || '—' }} / {{ target.allocationInboundCode || '无入仓码' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<template v-else>
|
||||
@@ -343,6 +413,16 @@ onMounted(() => void load())
|
||||
</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="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||||
<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>
|
||||
<el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" />
|
||||
</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>
|
||||
<template v-if="!target">
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
</template>
|
||||
@@ -365,5 +445,8 @@ onMounted(() => void load())
|
||||
.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; }
|
||||
.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; }
|
||||
.adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; }
|
||||
.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); }
|
||||
@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>
|
||||
|
||||
Reference in New Issue
Block a user