feat: CMA P1+P2 Round1 — 成本法对比+杜邦分析+本量利
This commit is contained in:
@@ -207,6 +207,76 @@
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 6: 成本法对比(CMA P1) -->
|
||||
<el-tab-pane label="成本法对比" name="comparison">
|
||||
<div v-loading="compLoading">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
|
||||
<span style="font-weight:600;font-size:14px;">分析对象:</span>
|
||||
<el-radio-group v-model="compEntity" @change="loadComparison">
|
||||
<el-radio value="hanke">陕西酣客(白酒经销)</el-radio>
|
||||
<el-radio value="bohai">陕西博海科技(IT服务)</el-radio>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" size="small" @click="loadComparison" :loading="compLoading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="compData.traditional" class="comp-table-wrapper">
|
||||
<el-table :data="compRows" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="metric" label="指标" width="120" fixed>
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: row.bold ? 700 : 400 }">{{ row.metric }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="传统成本法" width="160" align="center">
|
||||
<template #header>
|
||||
<div style="text-align:center;">传统成本法</div>
|
||||
<div style="font-size:11px;color:#999;text-align:center;">(账面当前)</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.t_color || '#333', fontWeight: row.t_bold ? 700 : 400 }">{{ row.traditional }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动成本法" width="160" align="center">
|
||||
<template #header>
|
||||
<div style="text-align:center;">变动成本法</div>
|
||||
<div style="font-size:11px;color:#999;text-align:center;">(Model C)</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.v_color || '#333', fontWeight: row.v_bold ? 700 : 400 }">{{ row.variable }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="作业成本法" width="160" align="center">
|
||||
<template #header>
|
||||
<div style="text-align:center;">作业成本法</div>
|
||||
<div style="font-size:11px;color:#999;text-align:center;">(ABC)</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.a_color || '#333', fontWeight: row.a_bold ? 700 : 400 }">{{ row.abc }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 注释 -->
|
||||
<el-card style="margin-top:12px;background:#fafafa;" size="small" v-if="compNotes.length > 0">
|
||||
<div v-for="(note, idx) in compNotes" :key="idx" style="font-size:12px;color:#666;padding:2px 0;">
|
||||
{{ note }}
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 决策启示 -->
|
||||
<el-card style="margin-top:12px;border-left:4px solid #409eff;">
|
||||
<template #header><span style="font-weight:600;">📋 决策启示</span></template>
|
||||
<div v-for="insight in compInsights" :key="insight.method" class="insight-row">
|
||||
<div class="insight-method">{{ insight.method }}</div>
|
||||
<div class="insight-conclusion">{{ insight.conclusion }}</div>
|
||||
<div class="insight-decision">{{ insight.decision }}</div>
|
||||
<div class="insight-detail">{{ insight.detail }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-else-if="!compLoading" description="选择实体后点击「刷新」加载数据" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 标准成本表单弹窗 -->
|
||||
@@ -283,7 +353,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { costApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
@@ -506,6 +576,88 @@ async function doAllocate() {
|
||||
allocSaving.value = false
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 成本法对比(CMA P1)
|
||||
// ============================
|
||||
const compLoading = ref(false)
|
||||
const compEntity = ref('hanke')
|
||||
const compData = ref<any>({})
|
||||
const compRows = computed(() => {
|
||||
if (!compData.value.traditional) return []
|
||||
const d = compData.value
|
||||
const fmt = (v: any) => {
|
||||
if (v === null || v === undefined) return '--'
|
||||
const n = Number(v)
|
||||
if (n >= 10000) return (n / 10000).toFixed(1) + '亿'
|
||||
return n.toFixed(1) + '万'
|
||||
}
|
||||
const pct = (v: any) => v !== null && v !== undefined ? v.toFixed(2) + '%' : '--'
|
||||
return [
|
||||
{
|
||||
metric: '收入', bold: true,
|
||||
traditional: fmt(d.traditional.revenue),
|
||||
variable: fmt(d.variable.revenue),
|
||||
abc: fmt(d.abc.revenue),
|
||||
},
|
||||
{
|
||||
metric: '成本', bold: true,
|
||||
traditional: fmt(d.traditional.cost),
|
||||
variable: fmt(d.variable.cost),
|
||||
abc: fmt(d.abc.cost),
|
||||
},
|
||||
{
|
||||
metric: '毛利', bold: true,
|
||||
traditional: fmt(d.traditional.gross_profit),
|
||||
variable: fmt(d.variable.gross_profit),
|
||||
abc: fmt(d.abc.gross_profit),
|
||||
t_color: d.traditional.gross_profit < 0 ? '#f56c6c' : '#67c23a',
|
||||
v_color: d.variable.gross_profit > 0 ? '#67c23a' : '#f56c6c',
|
||||
a_color: d.abc.gross_profit > 0 ? '#67c23a' : '#f56c6c',
|
||||
t_bold: true, v_bold: true, a_bold: true,
|
||||
},
|
||||
{
|
||||
metric: '毛利率', bold: false,
|
||||
traditional: pct(d.traditional.gross_margin),
|
||||
variable: pct(d.variable.gross_margin),
|
||||
abc: pct(d.abc.gross_margin),
|
||||
t_color: d.traditional.gross_margin < 0 ? '#f56c6c' : '#67c23a',
|
||||
v_color: d.variable.gross_margin > 10 ? '#67c23a' : '#e6a23c',
|
||||
a_color: d.abc.gross_margin > 10 ? '#67c23a' : '#e6a23c',
|
||||
},
|
||||
{
|
||||
metric: '费用', bold: true,
|
||||
traditional: fmt(d.traditional.expenses),
|
||||
variable: fmt(d.variable.expenses),
|
||||
abc: fmt(d.abc.expenses),
|
||||
},
|
||||
{
|
||||
metric: '净利润', bold: true,
|
||||
traditional: fmt(d.traditional.net_profit),
|
||||
variable: fmt(d.variable.net_profit),
|
||||
abc: fmt(d.abc.net_profit),
|
||||
t_color: d.traditional.net_profit < 0 ? '#f56c6c' : '#67c23a',
|
||||
v_color: d.variable.net_profit < 0 ? '#f56c6c' : '#67c23a',
|
||||
a_color: d.abc.net_profit < 0 ? '#f56c6c' : '#67c23a',
|
||||
t_bold: true, v_bold: true, a_bold: true,
|
||||
},
|
||||
]
|
||||
})
|
||||
const compNotes = computed(() => {
|
||||
const notes = compData.value.notes
|
||||
if (!notes) return []
|
||||
return Object.values(notes)
|
||||
})
|
||||
const compInsights = computed(() => compData.value.insights || [])
|
||||
|
||||
async function loadComparison() {
|
||||
compLoading.value = true
|
||||
try {
|
||||
const r: any = await costApi.comparison({ entity: compEntity.value })
|
||||
compData.value = r.data || r
|
||||
} catch (e) { ElMessage.error('加载对比数据失败') }
|
||||
compLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview()
|
||||
loadStandardCosts()
|
||||
|
||||
@@ -1,129 +1,158 @@
|
||||
<template>
|
||||
<div class="dupont-analysis" v-loading="loading">
|
||||
<!-- 1. ROE大数字 -->
|
||||
<div class="roe-hero" v-if="data.roe !== null">
|
||||
<div class="roe-value">{{ data.roe }}<span class="roe-unit">%</span></div>
|
||||
<div class="roe-label">净资产收益率 (ROE)</div>
|
||||
<div class="roe-change" :class="data.history?.trend || 'stable'">
|
||||
<span v-if="data.history?.trend === 'up'">↑</span>
|
||||
<span v-else-if="data.history?.trend === 'down'">↓</span>
|
||||
<span v-else>→</span>
|
||||
{{ data.history?.change?.roe_label || '无对比' }}
|
||||
<span class="change-sub">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="杜邦分析数据不足以计算,请确认营收、总资产、净资产数据已同步" />
|
||||
|
||||
<!-- 2. 三因子卡片 -->
|
||||
<div class="factor-cards" v-if="data.factors && Object.keys(data.factors).length">
|
||||
<div class="factor-card" v-for="(f, key) in data.factors" :key="key">
|
||||
<div class="factor-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="factor-label">{{ f.label }}</div>
|
||||
<div class="factor-desc">{{ f.desc }}</div>
|
||||
</div>
|
||||
<!-- 顶部控制 -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
|
||||
<span style="font-weight:600;font-size:14px;">分析对象:</span>
|
||||
<el-radio-group v-model="entity" @change="fetchDupont">
|
||||
<el-radio value="bohai">陕西博海科技(IT服务)</el-radio>
|
||||
<el-radio value="hanke">陕西酣客(白酒经销)</el-radio>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" size="small" @click="fetchDupont" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 3. 杜邦拆解树 -->
|
||||
<div class="dupont-tree" v-if="data.roe !== null && data.raw_data">
|
||||
<div class="tree-title">杜邦拆解链路</div>
|
||||
<div class="tree-container">
|
||||
<!-- 第一层:ROE -->
|
||||
<div class="tree-node tree-root">
|
||||
<div class="node-value">{{ data.roe }}%</div>
|
||||
<div class="node-label">ROE</div>
|
||||
<template v-if="data.roe !== null && data.roe !== undefined">
|
||||
<!-- 1. ROE大数字 -->
|
||||
<div class="roe-hero">
|
||||
<div class="roe-value">{{ formatPct(data.roe) }}<span class="roe-unit">%</span></div>
|
||||
<div class="roe-label">净资产收益率 (ROE) — {{ data.entity_name }} {{ data.period }}</div>
|
||||
<div class="roe-change" :class="data.roe_trend || 'stable'">
|
||||
<span v-if="data.roe_trend === 'up'">↑</span>
|
||||
<span v-else-if="data.roe_trend === 'down'">↓</span>
|
||||
<span v-else>→</span>
|
||||
{{ data.roe_change != null ? (data.roe_change > 0 ? '+' : '') + data.roe_change.toFixed(2) + '%' : '无对比' }}
|
||||
<span class="change-sub">较上期 ({{ data.prev_roe != null ? data.prev_roe + '%' : '-' }})</span>
|
||||
</div>
|
||||
<!-- 连线 -->
|
||||
<div class="tree-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal">
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 三因子卡片 -->
|
||||
<div class="factor-cards" v-if="data.factors">
|
||||
<div class="factor-card" v-for="(f, key) in data.factors" :key="key">
|
||||
<div class="factor-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="factor-label">{{ f.label }}</div>
|
||||
<div class="factor-desc">{{ f.desc }}</div>
|
||||
<div class="factor-assessment">{{ f.status }} {{ f.assessment }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 杜邦拆解树 -->
|
||||
<div class="dupont-tree">
|
||||
<div class="tree-title">杜邦拆解链路</div>
|
||||
<div class="tree-container">
|
||||
<!-- 第一层:ROE -->
|
||||
<div class="tree-node tree-root">
|
||||
<div class="node-value">{{ formatPct(data.roe) }}%</div>
|
||||
<div class="node-label">ROE = 净利润率 × 资产周转率 × 财务杠杆</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二层:三因子 -->
|
||||
<div class="tree-row">
|
||||
<div class="tree-node tree-factor" v-for="(f, key) in data.factors" :key="'f-'+key">
|
||||
<div class="node-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="node-label">{{ f.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 连线 第二层到第三层 -->
|
||||
<div class="tree-lines-second">
|
||||
<div class="sub-lines">
|
||||
<!-- 连线 -->
|
||||
<div class="tree-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
<div class="line-horizontal">
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
<!-- 第二层:三因子 -->
|
||||
<div class="tree-row" v-if="data.factors">
|
||||
<div class="tree-node tree-factor" v-for="(f, key) in data.factors" :key="'f-'+key">
|
||||
<div class="node-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="node-label">{{ f.label }}</div>
|
||||
<div class="node-desc">{{ f.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
<!-- 连线 第二层到第三层 -->
|
||||
<div class="tree-lines-second">
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第三层:原始数据 -->
|
||||
<div class="tree-row" v-if="data.raw_data">
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.profit_net) }}</div>
|
||||
<div class="node-label sub">净利润</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.asset_total) }}</div>
|
||||
<div class="node-label sub">总资产</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.asset_total) }}</div>
|
||||
<div class="node-label">总资产</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.equity_total) }}</div>
|
||||
<div class="node-label sub">净资产</div>
|
||||
<!-- 第三层:原始数据 -->
|
||||
<div class="tree-row" v-if="data.raw_data">
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.net_profit) }}</div>
|
||||
<div class="node-label">净利润</div>
|
||||
<div class="node-hint">÷ {{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label sub">营收</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">÷ {{ formatMoney(data.raw_data.total_assets) }}</div>
|
||||
<div class="node-label sub">总资产</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.total_assets) }}</div>
|
||||
<div class="node-label">总资产</div>
|
||||
<div class="node-hint">÷ {{ formatMoney(data.raw_data.equity) }}</div>
|
||||
<div class="node-label sub">净资产</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 一句话AI解读 -->
|
||||
<div class="ai-insight" v-if="aiAnalysis">
|
||||
<div class="insight-label">💡 AI解读</div>
|
||||
<div class="insight-text">{{ aiAnalysis }}</div>
|
||||
</div>
|
||||
<!-- 4. 分析解读 -->
|
||||
<div class="ai-insight" v-if="data.insight">
|
||||
<div class="insight-label">💡 分析解读</div>
|
||||
<div class="insight-text">{{ data.insight.detail }}</div>
|
||||
<div class="insight-improvement">改善方向:{{ data.insight.improvement }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!loading" description="杜邦分析数据不足以计算,请确认营收、总资产、净资产数据已同步" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardApi } from '@/api'
|
||||
import axios from 'axios'
|
||||
|
||||
const loading = ref(false)
|
||||
const entity = ref('bohai')
|
||||
const data = ref<any>({})
|
||||
const aiAnalysis = ref('')
|
||||
|
||||
const api = axios.create({ baseURL: '/api/cma', timeout: 15000 })
|
||||
api.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
api.interceptors.response.use((r: any) => r.data, (e: any) => Promise.reject(e))
|
||||
|
||||
function formatPct(val: number): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
return val.toFixed(2)
|
||||
}
|
||||
|
||||
function formatFactor(val: number, key: string): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
if (key === 'equity_multiplier') return val.toFixed(2) + 'x'
|
||||
if (key === 'financial_leverage') return val.toFixed(2) + 'x'
|
||||
if (key === 'asset_turnover') return val.toFixed(2) + 'x'
|
||||
return (val * 100).toFixed(2) + '%'
|
||||
return val.toFixed(2) + '%'
|
||||
}
|
||||
|
||||
function formatMoney(val: number): string {
|
||||
if (!val) return '-'
|
||||
if (val >= 100000000) return (val / 100000000).toFixed(2) + '亿'
|
||||
if (val >= 10000) return (val / 10000).toFixed(0) + '万'
|
||||
if (val >= 10000) return (val / 10000).toFixed(1) + '亿'
|
||||
if (val >= 1) return val.toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
|
||||
async function fetchDupont() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await dashboardApi.dupont?.() || await (await fetch('/api/cma/dashboard/dupont')).json()
|
||||
data.value = res
|
||||
const r: any = await api.get('/reports/dupont', { params: { entity: entity.value } })
|
||||
data.value = r.data || r
|
||||
} catch (e) {
|
||||
console.error('杜邦分析加载失败:', e)
|
||||
data.value = {}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -135,7 +164,7 @@ onMounted(fetchDupont)
|
||||
<style scoped>
|
||||
.dupont-analysis {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -187,6 +216,7 @@ onMounted(fetchDupont)
|
||||
.factor-value { font-size: 28px; font-weight: 700; color: #2c3e50; }
|
||||
.factor-label { font-size: 14px; color: #7f8c8d; margin-top: 4px; }
|
||||
.factor-desc { font-size: 11px; color: #b0b0b0; margin-top: 8px; }
|
||||
.factor-assessment { font-size: 12px; color: #666; margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0; }
|
||||
|
||||
/* 杜邦拆解树 */
|
||||
.dupont-tree {
|
||||
@@ -206,7 +236,7 @@ onMounted(fetchDupont)
|
||||
text-align: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
min-width: 120px;
|
||||
min-width: 130px;
|
||||
}
|
||||
.tree-root {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
@@ -214,7 +244,7 @@ onMounted(fetchDupont)
|
||||
font-size: 20px;
|
||||
}
|
||||
.tree-root .node-value { font-size: 32px; font-weight: 700; }
|
||||
.tree-root .node-label { font-size: 13px; opacity: 0.85; }
|
||||
.tree-root .node-label { font-size: 12px; opacity: 0.85; margin-top: 4px; }
|
||||
.tree-factor {
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #d0d8f0;
|
||||
@@ -222,6 +252,7 @@ onMounted(fetchDupont)
|
||||
}
|
||||
.tree-factor .node-value { font-size: 22px; font-weight: 700; }
|
||||
.tree-factor .node-label { font-size: 13px; color: #7f8c8d; }
|
||||
.tree-factor .node-desc { font-size: 11px; color: #b0b0b0; margin-top: 4px; }
|
||||
.tree-leaf {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
@@ -269,7 +300,7 @@ onMounted(fetchDupont)
|
||||
}
|
||||
.sub-lines .line-horizontal { gap: 50px; }
|
||||
|
||||
/* AI解读 */
|
||||
/* 分析解读 */
|
||||
.ai-insight {
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
@@ -278,4 +309,5 @@ onMounted(fetchDupont)
|
||||
}
|
||||
.insight-label { font-size: 14px; font-weight: 600; color: #d48806; margin-bottom: 8px; }
|
||||
.insight-text { font-size: 14px; color: #5a4e2b; line-height: 1.7; }
|
||||
.insight-improvement { font-size: 14px; color: #2c3e50; font-weight: 600; margin-top: 8px; padding-top: 8px; border-top: 1px solid #ffe58f; }
|
||||
</style>
|
||||
|
||||
@@ -3,46 +3,73 @@
|
||||
<h3>预测模拟</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<!-- Tab 1: CVP本量利分析 -->
|
||||
<!-- Tab 1: CVP本量利分析(增强版) -->
|
||||
<el-tab-pane label="本量利分析" name="cvp">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="cvpForm" label-width="140px">
|
||||
<el-form-item label="产品单价"><el-input-number v-model="cvpForm.unit_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="单位变动成本"><el-input-number v-model="cvpForm.unit_variable_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="固定成本"><el-input-number v-model="cvpForm.fixed_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="实际销量(可选)"><el-input-number v-model="cvpForm.actual_volume" :min="0" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="目标利润(可选)"><el-input-number v-model="cvpForm.target_profit" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>参数设置 — 酣客白酒</span>
|
||||
<el-button size="small" @click="fillActualData">📋 一键填入当前实际数据</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="固定成本(万元)">
|
||||
<el-slider v-model="cvpForm.fixed_cost" :min="100" :max="1000" :step="10" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item label="变动成本率(%)">
|
||||
<el-slider v-model="cvpForm.variable_cost_rate" :min="10" :max="90" :step="0.5" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item label="销售单价(元/瓶)">
|
||||
<el-input-number v-model="cvpForm.unit_price" :min="50" :max="999" style="width:100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="当前销量(瓶)">
|
||||
<el-input-number v-model="cvpForm.current_volume" :min="100" :max="100000" :step="100" style="width:100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doCvp" :loading="cvpLoading">计算</el-button>
|
||||
<el-button @click="cvpForm = { unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 }">填充示例</el-button>
|
||||
<el-button type="primary" @click="doCvpDetailed" :loading="cvpLoading" style="width:100%;">分析</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="cvpResult.bep_units">
|
||||
<el-col :span="16">
|
||||
<el-card v-if="cvpResult.breakeven_revenue !== undefined">
|
||||
<template #header>分析结果</template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本销量</div><div class="r-value">{{ cvpResult.bep_units }} 件</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本销售额</div><div class="r-value">{{ formatMoney(cvpResult.bep_revenue) }}</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">边际贡献率</div><div class="r-value blue">{{ cvpResult.contribution_ratio }}%</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本点(销售额)</div><div class="r-value red">{{ formatMoney(cvpResult.breakeven_revenue, 'wan') }} 万</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本点(销售量)</div><div class="r-value red">{{ cvpResult.breakeven_units?.toLocaleString() }} 瓶</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">边际贡献率</div><div class="r-value blue">{{ cvpResult.contribution_margin_rate }}%</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;">
|
||||
<el-col :span="8" v-if="cvpResult.safety_margin_units !== undefined"><div class="result-card"><div class="r-label">安全边际(量)</div><div class="r-value green">{{ cvpResult.safety_margin_units }} 件</div></div></el-col>
|
||||
<el-col :span="8" v-if="cvpResult.safety_margin_ratio !== undefined"><div class="result-card"><div class="r-label">安全边际率</div><div class="r-value" :class="safetyColor(cvpResult.safety_margin_ratio)">{{ cvpResult.safety_margin_ratio }}%</div></div></el-col>
|
||||
<el-col :span="8" v-if="cvpResult.actual_profit !== undefined"><div class="result-card"><div class="r-label">当前利润</div><div class="r-value orange">{{ formatMoney(cvpResult.actual_profit) }}</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;" v-if="cvpResult.target_units">
|
||||
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需销量</div><div class="r-value purple">{{ cvpResult.target_units }} 件</div></div></el-col>
|
||||
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需收入</div><div class="r-value purple">{{ formatMoney(cvpResult.target_revenue) }}</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">当前销售收入</div><div class="r-value green">{{ formatMoney(cvpResult.current_revenue, 'wan') }} 万</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">当前利润</div><div class="r-value" :class="cvpResult.current_profit >= 0 ? 'green' : 'red'">{{ formatMoney(cvpResult.current_profit, 'wan') }} 万</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">安全边际率</div><div class="r-value" :class="cvpResult.safety_margin > 25 ? 'green' : cvpResult.safety_margin > 10 ? 'orange' : 'red'">{{ cvpResult.safety_margin }}%</div></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 改善方案 -->
|
||||
<el-card style="margin-top:12px;background:#f9f9f9;" size="small" v-if="cvpResult.scenarios?.length">
|
||||
<template #header>💡 改善方案推演</template>
|
||||
<el-table :data="cvpResult.scenarios" border stripe size="small">
|
||||
<el-table-column prop="name" label="方案" min-width="160" />
|
||||
<el-table-column prop="breakeven_revenue" label="保本点(万)" width="120" align="right">
|
||||
<template #default="{ row }">{{ row.breakeven_revenue?.toFixed(1) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="breakeven_units" label="保本销量(瓶)" width="120" align="right">
|
||||
<template #default="{ row }">{{ row.breakeven_units?.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!cvpLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
<el-empty v-else-if="!cvpLoading" description="调整参数后点击「分析」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 保本图 -->
|
||||
<el-card style="margin-top:16px;" v-if="cvpResult.chart_data?.length">
|
||||
<template #header>📈 保本点分析图(销售额 vs 总成本)</template>
|
||||
<div ref="cvpChartRef" style="height:360px;width:100%;"></div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 投资决策 -->
|
||||
@@ -194,31 +221,90 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { predictApi } from '../api/index'
|
||||
|
||||
const activeTab = ref('cvp')
|
||||
|
||||
function formatMoney(v: any) {
|
||||
function formatMoney(v: any, mode?: string) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
if (mode === 'wan') return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
function safetyColor(r: number) { return r > 25 ? 'green' : r > 10 ? 'orange' : 'red' }
|
||||
|
||||
// ── CVP ──
|
||||
// ── CVP本量利分析(增强版) ──
|
||||
const cvpLoading = ref(false)
|
||||
const cvpForm = ref({ unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 })
|
||||
const cvpForm = ref({ fixed_cost: 617, variable_cost_rate: 48.62, unit_price: 228, current_volume: 5300 })
|
||||
const cvpResult = ref<any>({})
|
||||
const cvpChartRef = ref<HTMLElement | null>(null)
|
||||
let cvpChart: any = null
|
||||
|
||||
async function doCvp() {
|
||||
function fillActualData() {
|
||||
cvpForm.value = { fixed_cost: 617, variable_cost_rate: 48.62, unit_price: 228, current_volume: 5300 }
|
||||
doCvpDetailed()
|
||||
}
|
||||
|
||||
async function doCvpDetailed() {
|
||||
cvpLoading.value = true
|
||||
try {
|
||||
cvpResult.value = await predictApi.cvp(cvpForm.value) as any
|
||||
} catch { ElMessage.error('CVP计算失败') }
|
||||
const payload = {
|
||||
fixed_cost: cvpForm.value.fixed_cost,
|
||||
variable_cost_rate: cvpForm.value.variable_cost_rate / 100,
|
||||
unit_price: cvpForm.value.unit_price,
|
||||
current_volume: cvpForm.value.current_volume,
|
||||
}
|
||||
cvpResult.value = await predictApi.cvpDetailed(payload) as any
|
||||
// 渲染保本图
|
||||
nextTick(() => renderCvpChart())
|
||||
} catch { ElMessage.error('CVP分析失败') }
|
||||
cvpLoading.value = false
|
||||
}
|
||||
|
||||
function renderCvpChart() {
|
||||
if (!cvpChartRef.value || !cvpResult.value.chart_data) return
|
||||
import('echarts').then(echarts => {
|
||||
if (cvpChart) cvpChart.dispose()
|
||||
cvpChart = echarts.init(cvpChartRef.value!)
|
||||
const chartData = cvpResult.value.chart_data
|
||||
const volumes = chartData.map((d: any) => d.volume)
|
||||
const revenues = chartData.map((d: any) => d.revenue)
|
||||
const costs = chartData.map((d: any) => d.total_cost)
|
||||
const bepVol = cvpResult.value.breakeven_units || 0
|
||||
const bepRev = cvpResult.value.breakeven_revenue || 0
|
||||
|
||||
cvpChart.setOption({
|
||||
grid: { left: 60, right: 30, top: 20, bottom: 40 },
|
||||
xAxis: { type: 'category', name: '销量(瓶)', data: volumes, axisLabel: { rotate: 45, fontSize: 10 } },
|
||||
yAxis: { type: 'value', name: '金额(万元)' },
|
||||
series: [
|
||||
{
|
||||
name: '总收入', type: 'line', data: revenues,
|
||||
smooth: true, lineStyle: { width: 2, color: '#409eff' },
|
||||
areaStyle: { color: 'rgba(64,158,255,0.05)' },
|
||||
symbol: 'none',
|
||||
},
|
||||
{
|
||||
name: '总成本', type: 'line', data: costs,
|
||||
smooth: true, lineStyle: { width: 2, color: '#f56c6c' },
|
||||
areaStyle: { color: 'rgba(245,108,108,0.05)' },
|
||||
symbol: 'none',
|
||||
},
|
||||
...(bepVol > 0 ? [{
|
||||
name: '保本点', type: 'scatter',
|
||||
data: [[bepVol, bepRev]],
|
||||
symbolSize: 14,
|
||||
itemStyle: { color: '#e6a23c', borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: `保本点\n${bepRev.toFixed(1)}万`, position: 'right', fontSize: 11, fontWeight: 600, color: '#e6a23c' },
|
||||
}] : []),
|
||||
],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── 投资决策 ──
|
||||
const invLoading = ref(false)
|
||||
const invForm = ref({ initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] })
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
@change="onPeriodChange"
|
||||
/>
|
||||
<el-button text size="small" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
<el-button type="primary" size="small" @click="openDupont">📊 杜邦分析</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -326,6 +327,10 @@ function loadAll() {
|
||||
Promise.all([loadProfit(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false })
|
||||
}
|
||||
|
||||
function openDupont() {
|
||||
window.open('/dupont-analysis', '_blank')
|
||||
}
|
||||
|
||||
// ── ECharts 渲染 ──
|
||||
function renderTrendCharts() {
|
||||
import('echarts').then(echarts => {
|
||||
|
||||
Reference in New Issue
Block a user