feat(P1-3): 相关成本决策模块 — 官方25%权重'商业决策分析'核心

新增 /predict/relevant-decision API + 前端Tab(3场景):
1. 自制vs外购: 相关成本比较+无差别点(增量成本视角)
2. 特殊订单: 剩余产能下价格>变动成本即接受(增量利润)
3. 产品组合(约束理论): 单位约束资源边际贡献排序

验证: 自制850k vs 外购900k→自制; 特殊订单单位贡献25→接受;
      产品组合B产品单位约束贡献50最高→优先
This commit is contained in:
Hermes CI Fix
2026-08-26 22:38:37 +08:00
parent 35974514da
commit e330545d59
3 changed files with 227 additions and 0 deletions
+91
View File
@@ -161,6 +161,97 @@ def api_cvp_detailed(data: dict):
raise HTTPException(400, f"CVP详细分析失败: {str(e)}") raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
# ── 相关成本决策(CMA P2商业决策分析25%权重核心) ──────────────────
@router.post("/relevant-decision")
def api_relevant_decision(data: dict):
"""相关成本决策分析(CMA P2 商业决策分析核心内容)
场景: make-or-buy自制外购 / special-order特殊订单 / product-mix产品组合
"""
try:
decision_type = data.get("type", "make_or_buy")
if decision_type == "make_or_buy":
# 自制vs外购决策
# 相关成本 = 增量成本(只有随决策变化的成本才是相关的)
make_var_cost = float(data.get("make_variable_cost", 0)) # 自制单位变动成本
make_fixed = float(data.get("make_fixed_cost", 0)) # 自制新增固定成本
buy_price = float(data.get("buy_price", 0)) # 外购单价
demand = float(data.get("demand", 0)) # 需求量
existing_fixed = float(data.get("existing_fixed_cost", 0)) # 现有固定成本(无关成本,自制不增加则忽略)
make_total = make_var_cost * demand + make_fixed
buy_total = buy_price * demand
diff = buy_total - make_total # >0自制省钱
return {
"type": "自制vs外购",
"make_total_cost": round(make_total, 2),
"buy_total_cost": round(buy_total, 2),
"difference": round(diff, 2),
"recommendation": "自制" if diff > 0 else "外购",
"reason": f"自制总成本{make_total:.2f} vs 外购总成本{buy_total:.2f}{'自制节省' + str(round(diff,2)) if diff > 0 else '外购节省' + str(round(-diff,2))}",
"unit_make_cost": round(make_var_cost + (make_fixed / demand if demand else 0), 2),
"unit_buy_price": buy_price,
"indifferent_point": round(make_fixed / (buy_price - make_var_cost), 2) if buy_price > make_var_cost else None,
"notes": "仅考虑相关成本(增量成本);现有固定成本若不受决策影响则无关",
}
elif decision_type == "special_order":
# 特殊订单决策(有剩余产能时,只要价格>单位变动成本即接受)
normal_price = float(data.get("normal_price", 0))
special_price = float(data.get("special_price", 0))
var_cost = float(data.get("variable_cost", 0))
order_qty = float(data.get("order_qty", 0))
capacity_used = float(data.get("capacity_used", 0)) # 特殊订单占用产能%
extra_fixed = float(data.get("extra_fixed_cost", 0)) # 一次性额外固定成本
contribution_per_unit = special_price - var_cost
total_contribution = contribution_per_unit * order_qty - extra_fixed
accept = total_contribution > 0 and capacity_used <= 100
return {
"type": "特殊订单",
"unit_contribution": round(contribution_per_unit, 2),
"total_contribution": round(total_contribution, 2),
"extra_fixed_cost": extra_fixed,
"capacity_used_pct": capacity_used,
"recommendation": "接受" if accept else "拒绝",
"reason": f"单价{special_price} - 变动成本{var_cost} = 单位贡献{contribution_per_unit:.2f}" +
(f",共{total_contribution:.2f} > 0 且产能{capacity_used}%够用 → 接受(增量利润)" if accept else
f",总贡献{total_contribution:.2f} ≤ 0 或产能不足 → 拒绝"),
"notes": "有剩余产能时,只要价格>变动成本且不冲击正常市场即可接受;固定成本无关",
}
elif decision_type == "product_mix":
# 产品组合决策(约束理论:单位约束资源的边际贡献最大者优先)
products = data.get("products", []) # [{name, price, var_cost, constraint_usage, demand}]
results = []
for p in products:
cm_per_unit = float(p.get("price", 0)) - float(p.get("var_cost", 0))
cm_per_constraint = cm_per_unit / float(p.get("constraint_usage", 1))
results.append({
"name": p.get("name", ""),
"unit_contribution": round(cm_per_unit, 2),
"constraint_usage": float(p.get("constraint_usage", 1)),
"contribution_per_constraint": round(cm_per_constraint, 2),
"demand": float(p.get("demand", 0)),
})
# 按单位约束资源贡献排序(约束理论优先)
results.sort(key=lambda x: x["contribution_per_constraint"], reverse=True)
return {
"type": "产品组合(约束理论)",
"ranking": results,
"recommendation": f"优先生产「{results[0]['name']}」(单位约束贡献{results[0]['contribution_per_constraint']}最高)",
"notes": "瓶颈资源下,按单位约束资源的边际贡献排序,而非单位边际贡献",
}
raise HTTPException(400, "未知决策类型: " + str(decision_type))
except Exception as e:
raise HTTPException(400, f"相关成本决策失败: {str(e)}")
# ── 现金流预测(AI事前预警) ──────────────────────────────────── # ── 现金流预测(AI事前预警) ────────────────────────────────────
+1
View File
@@ -218,6 +218,7 @@ export const predictApi = {
investment: (data: any) => api.post('/predict/investment', data), investment: (data: any) => api.post('/predict/investment', data),
sensitivity: (data: any) => api.post('/predict/sensitivity', data), sensitivity: (data: any) => api.post('/predict/sensitivity', data),
scenario: (data: any) => api.post('/predict/scenario', data), scenario: (data: any) => api.post('/predict/scenario', data),
relevantDecision: (data: any) => api.post('/predict/relevant-decision', data),
growthQuality: (data: any) => api.post('/predict/growth-quality', data), growthQuality: (data: any) => api.post('/predict/growth-quality', data),
// AI事前预警 // AI事前预警
cashForecast: (data: any) => api.post('/predict/cash-forecast', data), cashForecast: (data: any) => api.post('/predict/cash-forecast', data),
+135
View File
@@ -217,6 +217,112 @@
</el-row> </el-row>
</el-tab-pane> </el-tab-pane>
<!-- Tab: 相关成本决策CMA P2商业决策分析 -->
<el-tab-pane label="相关成本决策" name="relevantDecision">
<el-row :gutter="16">
<el-col :span="24">
<el-radio-group v-model="relDecisionType" style="margin-bottom:16px;">
<el-radio-button value="make_or_buy">自制 vs 外购</el-radio-button>
<el-radio-button value="special_order">特殊订单</el-radio-button>
<el-radio-button value="product_mix">产品组合(约束理论)</el-radio-button>
</el-radio-group>
</el-col>
</el-row>
<!-- 自制vs外购 -->
<el-row v-if="relDecisionType === 'make_or_buy'" :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入参数</template>
<el-form label-width="140px" size="small">
<el-form-item label="需求量(件)"><el-input-number v-model="makeBuy.demand" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="自制单位变动成本"><el-input-number v-model="makeBuy.make_variable_cost" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="自制新增固定成本"><el-input-number v-model="makeBuy.make_fixed_cost" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="外购单价"><el-input-number v-model="makeBuy.buy_price" :min="0" style="width:200px;" /></el-form-item>
<el-button type="primary" size="small" @click="runRelevantDecision">计算决策</el-button>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="relResult">
<template #header>决策结果</template>
<el-alert :title="'建议:' + relResult.recommendation" :type="relResult.recommendation === '自制' ? 'primary' : 'warning'" :description="relResult.reason" show-icon :closable="false" style="margin-bottom:12px;" />
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="自制总成本">{{ formatRelNum(relResult.make_total_cost) }}</el-descriptions-item>
<el-descriptions-item label="外购总成本">{{ formatRelNum(relResult.buy_total_cost) }}</el-descriptions-item>
<el-descriptions-item label="单位自制成本">{{ formatRelNum(relResult.unit_make_cost) }}</el-descriptions-item>
<el-descriptions-item label="外购单价">{{ formatRelNum(relResult.unit_buy_price) }}</el-descriptions-item>
<el-descriptions-item label="无差别点" :span="2">{{ relResult.indifferent_point != null ? formatRelNum(relResult.indifferent_point) + '件' : '—' }}</el-descriptions-item>
</el-descriptions>
<el-text size="small" type="info" style="margin-top:8px;display:block;">{{ relResult.notes }}</el-text>
</el-card>
</el-col>
</el-row>
<!-- 特殊订单 -->
<el-row v-if="relDecisionType === 'special_order'" :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入参数</template>
<el-form label-width="140px" size="small">
<el-form-item label="正常售价"><el-input-number v-model="specialOrder.normal_price" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="特殊订单价"><el-input-number v-model="specialOrder.special_price" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="单位变动成本"><el-input-number v-model="specialOrder.variable_cost" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="订单数量"><el-input-number v-model="specialOrder.order_qty" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="产能占用%"><el-input-number v-model="specialOrder.capacity_used" :min="0" :max="100" style="width:200px;" /></el-form-item>
<el-form-item label="额外固定成本"><el-input-number v-model="specialOrder.extra_fixed_cost" :min="0" style="width:200px;" /></el-form-item>
<el-button type="primary" size="small" @click="runRelevantDecision">计算决策</el-button>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="relResult">
<template #header>决策结果</template>
<el-alert :title="'建议:' + relResult.recommendation" :type="relResult.recommendation === '接受' ? 'success' : 'danger'" :description="relResult.reason" show-icon :closable="false" style="margin-bottom:12px;" />
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="单位贡献">{{ formatRelNum(relResult.unit_contribution) }}</el-descriptions-item>
<el-descriptions-item label="总贡献">{{ formatRelNum(relResult.total_contribution) }}</el-descriptions-item>
<el-descriptions-item label="产能占用">{{ relResult.capacity_used_pct }}%</el-descriptions-item>
<el-descriptions-item label="额外固定成本">{{ formatRelNum(relResult.extra_fixed_cost) }}</el-descriptions-item>
</el-descriptions>
<el-text size="small" type="info" style="margin-top:8px;display:block;">{{ relResult.notes }}</el-text>
</el-card>
</el-col>
</el-row>
<!-- 产品组合 -->
<el-row v-if="relDecisionType === 'product_mix'" :gutter="16">
<el-col :span="12">
<el-card>
<template #header>产品参数价格/变动成本/约束资源消耗/需求</template>
<div v-for="(p, idx) in productMix.products" :key="idx" style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
<el-input v-model="p.name" placeholder="产品名" style="width:100px;" size="small" />
<el-input-number v-model="p.price" :min="0" placeholder="价格" style="width:110px;" size="small" />
<el-input-number v-model="p.var_cost" :min="0" placeholder="变动成本" style="width:110px;" size="small" />
<el-input-number v-model="p.constraint_usage" :min="0.1" placeholder="约束消耗" style="width:100px;" size="small" />
<el-input-number v-model="p.demand" :min="0" placeholder="需求" style="width:90px;" size="small" />
</div>
<el-button size="small" @click="productMix.products.push({ name: '新产品', price: 100, var_cost: 60, constraint_usage: 1, demand: 100 })">+ 添加产品</el-button>
<el-button size="small" type="primary" @click="runRelevantDecision" style="margin-left:8px;">计算决策</el-button>
</el-card>
</el-col>
<el-col :span="12">
<el-card v-if="relResult">
<template #header>决策结果</template>
<el-alert :title="relResult.recommendation" type="success" :closable="false" style="margin-bottom:12px;" />
<el-table :data="relResult.ranking" border stripe size="small">
<el-table-column prop="name" label="产品" />
<el-table-column prop="unit_contribution" label="单位贡献" />
<el-table-column prop="constraint_usage" label="约束消耗" />
<el-table-column prop="contribution_per_constraint" label="单位约束贡献" />
<el-table-column prop="demand" label="需求" />
</el-table>
<el-text size="small" type="info" style="margin-top:8px;display:block;">{{ relResult.notes }}</el-text>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 5: 现金流预测AI事前预警 --> <!-- Tab 5: 现金流预测AI事前预警 -->
<el-tab-pane label="现金流预测" name="cashFlow"> <el-tab-pane label="现金流预测" name="cashFlow">
<el-row :gutter="16"> <el-row :gutter="16">
@@ -455,6 +561,35 @@ const scenarioForm = ref({
}) })
const scenarioResult = ref<any>({}) const scenarioResult = ref<any>({})
// ── 相关成本决策(CMA P2商业决策分析) ──
const relDecisionType = ref('make_or_buy')
const relResult = ref<any>(null)
const makeBuy = ref({ demand: 10000, make_variable_cost: 80, make_fixed_cost: 50000, buy_price: 90 })
const specialOrder = ref({ normal_price: 150, special_price: 120, variable_cost: 95, order_qty: 1000, capacity_used: 60, extra_fixed_cost: 0 })
const productMix = ref({ products: [
{ name: 'A产品', price: 100, var_cost: 60, constraint_usage: 2, demand: 1000 },
{ name: 'B产品', price: 150, var_cost: 100, constraint_usage: 1, demand: 800 },
{ name: 'C产品', price: 200, var_cost: 150, constraint_usage: 3, demand: 500 },
]})
function formatRelNum(v: any) {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
async function runRelevantDecision() {
try {
let payload: any = { type: relDecisionType.value }
if (relDecisionType.value === 'make_or_buy') payload = { ...payload, ...makeBuy.value }
else if (relDecisionType.value === 'special_order') payload = { ...payload, ...specialOrder.value }
else payload = { ...payload, products: productMix.value.products }
relResult.value = await predictApi.relevantDecision(payload) as any
} catch (e: any) {
relResult.value = null
ElMessage.error(e?.response?.data?.detail || e?.message || '决策计算失败')
}
}
function fillScenarioExample() { function fillScenarioExample() {
scenarioForm.value = { scenarioForm.value = {
optimistic: { revenue: 130, cost: 90 }, optimistic: { revenue: 130, cost: 90 },