This commit is contained in:
2026-08-18 17:40:40 +08:00
parent 6ff77e92b2
commit c188482c24
18 changed files with 423 additions and 97 deletions
@@ -25,6 +25,7 @@ const partnersError = ref<ApiErrorDetails | null>(null)
const feeItemsLoading = ref(false)
const partnersLoading = ref(false)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const rejectOpen = ref(false)
const saving = ref(false)
const target = ref<ContainerFeeResource | null>(null)
@@ -113,21 +114,38 @@ async function load() {
finally { if (sequence === loadSequence) loading.value = false }
}
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(item); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
async function save() {
if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return
if (form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return }
if (!canEdit.value || !accountId.value || (!target.value && !canViewFeeItems.value)) return
if (!target.value && form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return }
if (!await formRef.value?.validate().catch(() => false)) return
editorError.value = null
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,
})
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
}
const partner = partners.value.find((item) => item.id === String(form.partnerId))
const payload: ContainerFeeSaveRequest = { ...form, partnerId: form.direction === 'PAYABLE' ? form.partnerId : null, partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || null : null }
if (form.direction === 'PAYABLE' && !payload.partnerName) { ElMessage.warning('请选择有效的往来单位'); return }
saving.value = true
try {
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, payload)
else await createContainerFee(props.planId, accountId.value, payload)
editorOpen.value = false; ElMessage.success(target.value ? '费用调整已提交审核' : '费用已新增'); await load()
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) }
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)) }
@@ -162,7 +180,7 @@ onMounted(() => void load())
</script>
<template>
<section class="fees-panel surface-panel" aria-label="柜次费用维护">
<section class="fees-panel" aria-label="柜次费用维护">
<header class="fees-header">
<div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
<div class="fees-header__actions">
@@ -174,7 +192,7 @@ onMounted(() => void load())
<template v-else>
<el-alert v-if="customerOptions.length === 0" type="warning" :closable="false" show-icon title="装柜明细缺少客户归属,无法安全维护费用" />
<el-alert v-else-if="!accountId" type="info" :closable="false" show-icon title="该柜次包含多个客户,请先选择费用归属客户" />
<el-alert v-else-if="!canViewFeeItems" type="warning" :closable="false" show-icon title="当前账号缺少费用项目查看权限,无法新增或调整费用" />
<el-alert v-else-if="!canViewFeeItems" type="warning" :closable="false" show-icon title="当前账号缺少费用项目查看权限,无法新增费用;已审核费用仍可调整日期与金额" />
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
@@ -187,20 +205,52 @@ onMounted(() => void load())
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && 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 #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
</template>
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save"><el-alert v-if="target" type="info" :closable="false" show-icon title="调整后版本递增并重新进入待审核状态" /><el-alert v-if="!canViewPartners" type="warning" :closable="false" show-icon title="当前账号缺少往来单位查看权限,仅可维护应收费用" /><el-alert v-else-if="partnersError" type="warning" :closable="false" show-icon :title="partnersError.message" /><el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" /><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form"><el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewPartners || !!partnersError }]" /></el-form-item><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><el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners || !!partnersError" placeholder="请选择启用的往来单位"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item><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 label="参与结算"><el-switch v-model="form.settlementIncluded" /></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></AppDialog>
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
<div v-if="target" class="adjust-snapshot">
<p>调整仅允许修改业务日期和金额;如需变更方向、费用项目、往来单位或其他信息,请先作废后新增。</p>
<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>
<el-descriptions-item label="往来单位">{{ target.partnerName || '—' }}</el-descriptions-item>
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
<el-descriptions-item label="参与结算">{{ target.settlementIncluded ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
</el-descriptions>
</div>
<template v-else>
<el-alert v-if="!canViewPartners" type="warning" :closable="false" show-icon title="当前账号缺少往来单位查看权限,仅可维护应收费用" />
<el-alert v-else-if="partnersError" type="warning" :closable="false" show-icon :title="partnersError.message" />
<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">
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewPartners || !!partnersError }]" /></el-form-item>
<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>
<el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners || !!partnersError" placeholder="请选择启用的往来单位"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></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="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item>
<template v-if="!target">
<el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></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>
</template>
</el-form>
<LoadErrorState v-if="editorError" compact title="费用保存失败" :message="editorError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
</AppDialog>
<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>
.fees-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 14px; }
.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; }
.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; }
.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; }
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { 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; }
.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; }
.adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; }
@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>