514 lines
17 KiB
Python
514 lines
17 KiB
Python
"""利润模拟服务
|
|
|
|
实现利润模拟测算核心业务逻辑,包含:
|
|
- 利润模拟计算
|
|
- 敏感性分析
|
|
- 盈亏平衡分析
|
|
- AI 利润预测分析
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.models import (
|
|
Project,
|
|
PricingPlan,
|
|
ProfitSimulation,
|
|
SensitivityAnalysis,
|
|
FixedCost,
|
|
)
|
|
from app.schemas.profit import (
|
|
PeriodType,
|
|
SimulationInput,
|
|
SimulationResult,
|
|
BreakevenAnalysis,
|
|
SimulateProfitResponse,
|
|
SensitivityResultItem,
|
|
SensitivityInsights,
|
|
SensitivityAnalysisResponse,
|
|
BreakevenResponse,
|
|
)
|
|
from app.services.ai_service_wrapper import AIServiceWrapper
|
|
|
|
# 导入提示词
|
|
import sys
|
|
import os
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../prompts'))
|
|
from prompts.profit_forecast_prompts import SYSTEM_PROMPT, USER_PROMPT, PROMPT_META
|
|
|
|
|
|
class ProfitService:
|
|
"""利润模拟服务"""
|
|
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
|
|
async def get_pricing_plan(self, plan_id: int) -> PricingPlan:
|
|
"""获取定价方案"""
|
|
result = await self.db.execute(
|
|
select(PricingPlan).options(
|
|
selectinload(PricingPlan.project)
|
|
).where(PricingPlan.id == plan_id)
|
|
)
|
|
plan = result.scalar_one_or_none()
|
|
|
|
if not plan:
|
|
raise ValueError(f"定价方案不存在: {plan_id}")
|
|
|
|
return plan
|
|
|
|
async def get_monthly_fixed_cost(self, year_month: Optional[str] = None) -> Decimal:
|
|
"""获取月度固定成本总额"""
|
|
if not year_month:
|
|
year_month = datetime.now().strftime("%Y-%m")
|
|
|
|
result = await self.db.execute(
|
|
select(func.sum(FixedCost.monthly_amount)).where(
|
|
FixedCost.year_month == year_month,
|
|
FixedCost.is_active == True
|
|
)
|
|
)
|
|
return result.scalar() or Decimal("0")
|
|
|
|
def calculate_profit(
|
|
self,
|
|
price: float,
|
|
cost_per_unit: float,
|
|
volume: int,
|
|
) -> tuple[float, float, float, float]:
|
|
"""计算利润
|
|
|
|
Returns:
|
|
(收入, 成本, 利润, 利润率)
|
|
"""
|
|
revenue = price * volume
|
|
total_cost = cost_per_unit * volume
|
|
profit = revenue - total_cost
|
|
margin = (profit / revenue * 100) if revenue > 0 else 0
|
|
|
|
return revenue, total_cost, profit, margin
|
|
|
|
def calculate_breakeven(
|
|
self,
|
|
price: float,
|
|
variable_cost: float,
|
|
fixed_cost: float = 0,
|
|
) -> int:
|
|
"""计算盈亏平衡点
|
|
|
|
盈亏平衡客量 = 固定成本 / (单价 - 单位变动成本)
|
|
|
|
Args:
|
|
price: 单价
|
|
variable_cost: 单位变动成本
|
|
fixed_cost: 固定成本(可选)
|
|
|
|
Returns:
|
|
盈亏平衡客量
|
|
"""
|
|
contribution_margin = price - variable_cost
|
|
|
|
if contribution_margin <= 0:
|
|
# 边际贡献为负,无法盈利
|
|
return 999999
|
|
|
|
if fixed_cost > 0:
|
|
breakeven = int(fixed_cost / contribution_margin) + 1
|
|
else:
|
|
# 无固定成本时,只要有销量就盈利
|
|
breakeven = 1
|
|
|
|
return breakeven
|
|
|
|
async def simulate_profit(
|
|
self,
|
|
pricing_plan_id: int,
|
|
price: float,
|
|
estimated_volume: int,
|
|
period_type: PeriodType,
|
|
created_by: Optional[int] = None,
|
|
) -> SimulateProfitResponse:
|
|
"""执行利润模拟
|
|
|
|
Args:
|
|
pricing_plan_id: 定价方案ID
|
|
price: 模拟价格
|
|
estimated_volume: 预估客量
|
|
period_type: 周期类型
|
|
created_by: 创建人ID
|
|
|
|
Returns:
|
|
利润模拟结果
|
|
"""
|
|
# 获取定价方案
|
|
plan = await self.get_pricing_plan(pricing_plan_id)
|
|
cost_per_unit = float(plan.base_cost)
|
|
|
|
# 计算利润
|
|
revenue, total_cost, profit, margin = self.calculate_profit(
|
|
price=price,
|
|
cost_per_unit=cost_per_unit,
|
|
volume=estimated_volume,
|
|
)
|
|
|
|
# 计算盈亏平衡点
|
|
breakeven_volume = self.calculate_breakeven(
|
|
price=price,
|
|
variable_cost=cost_per_unit,
|
|
)
|
|
|
|
# 计算安全边际
|
|
safety_margin = estimated_volume - breakeven_volume
|
|
safety_margin_pct = (safety_margin / estimated_volume * 100) if estimated_volume > 0 else 0
|
|
|
|
# 限制利润率范围以避免数据库溢出 (DECIMAL(5,2) 范围 -999.99 ~ 999.99)
|
|
clamped_margin = max(-999.99, min(999.99, margin))
|
|
|
|
# 创建模拟记录
|
|
simulation = ProfitSimulation(
|
|
pricing_plan_id=pricing_plan_id,
|
|
simulation_name=f"{plan.project.project_name}-{period_type.value}模拟",
|
|
price=Decimal(str(price)),
|
|
estimated_volume=estimated_volume,
|
|
period_type=period_type.value,
|
|
estimated_revenue=Decimal(str(revenue)),
|
|
estimated_cost=Decimal(str(total_cost)),
|
|
estimated_profit=Decimal(str(profit)),
|
|
profit_margin=Decimal(str(round(clamped_margin, 2))),
|
|
breakeven_volume=breakeven_volume,
|
|
created_by=created_by,
|
|
)
|
|
|
|
self.db.add(simulation)
|
|
await self.db.flush()
|
|
await self.db.refresh(simulation)
|
|
|
|
return SimulateProfitResponse(
|
|
simulation_id=simulation.id,
|
|
pricing_plan_id=pricing_plan_id,
|
|
project_name=plan.project.project_name,
|
|
input=SimulationInput(
|
|
price=price,
|
|
cost_per_unit=cost_per_unit,
|
|
estimated_volume=estimated_volume,
|
|
period_type=period_type.value,
|
|
),
|
|
result=SimulationResult(
|
|
estimated_revenue=round(revenue, 2),
|
|
estimated_cost=round(total_cost, 2),
|
|
estimated_profit=round(profit, 2),
|
|
profit_margin=round(margin, 2),
|
|
profit_per_unit=round(price - cost_per_unit, 2),
|
|
),
|
|
breakeven_analysis=BreakevenAnalysis(
|
|
breakeven_volume=breakeven_volume,
|
|
current_volume=estimated_volume,
|
|
safety_margin=safety_margin,
|
|
safety_margin_percentage=round(safety_margin_pct, 1),
|
|
),
|
|
created_at=simulation.created_at,
|
|
)
|
|
|
|
async def sensitivity_analysis(
|
|
self,
|
|
simulation_id: int,
|
|
price_change_rates: List[float],
|
|
) -> SensitivityAnalysisResponse:
|
|
"""执行敏感性分析
|
|
|
|
分析价格变动对利润的影响
|
|
|
|
Args:
|
|
simulation_id: 模拟ID
|
|
price_change_rates: 价格变动率列表
|
|
|
|
Returns:
|
|
敏感性分析结果
|
|
"""
|
|
# 获取模拟记录
|
|
result = await self.db.execute(
|
|
select(ProfitSimulation).options(
|
|
selectinload(ProfitSimulation.pricing_plan)
|
|
).where(ProfitSimulation.id == simulation_id)
|
|
)
|
|
simulation = result.scalar_one_or_none()
|
|
|
|
if not simulation:
|
|
raise ValueError(f"模拟记录不存在: {simulation_id}")
|
|
|
|
base_price = float(simulation.price)
|
|
base_profit = float(simulation.estimated_profit)
|
|
cost_per_unit = float(simulation.pricing_plan.base_cost)
|
|
volume = simulation.estimated_volume
|
|
|
|
sensitivity_results = []
|
|
|
|
for rate in sorted(price_change_rates):
|
|
# 计算调整后价格
|
|
adjusted_price = base_price * (1 + rate / 100)
|
|
|
|
# 计算调整后利润
|
|
_, _, adjusted_profit, _ = self.calculate_profit(
|
|
price=adjusted_price,
|
|
cost_per_unit=cost_per_unit,
|
|
volume=volume,
|
|
)
|
|
|
|
# 计算利润变动率
|
|
profit_change_rate = 0
|
|
if base_profit != 0:
|
|
profit_change_rate = (adjusted_profit - base_profit) / abs(base_profit) * 100
|
|
|
|
# 限制变动率范围以避免数据库溢出
|
|
clamped_profit_change_rate = max(-999.99, min(999.99, profit_change_rate))
|
|
|
|
item = SensitivityResultItem(
|
|
price_change_rate=rate,
|
|
adjusted_price=round(adjusted_price, 2),
|
|
adjusted_profit=round(adjusted_profit, 2),
|
|
profit_change_rate=round(clamped_profit_change_rate, 2),
|
|
)
|
|
sensitivity_results.append(item)
|
|
|
|
# 保存到数据库
|
|
analysis = SensitivityAnalysis(
|
|
simulation_id=simulation_id,
|
|
price_change_rate=Decimal(str(rate)),
|
|
adjusted_price=Decimal(str(adjusted_price)),
|
|
adjusted_profit=Decimal(str(adjusted_profit)),
|
|
profit_change_rate=Decimal(str(round(clamped_profit_change_rate, 2))),
|
|
)
|
|
self.db.add(analysis)
|
|
|
|
await self.db.flush()
|
|
|
|
# 生成洞察
|
|
insights = self._generate_sensitivity_insights(
|
|
base_price=base_price,
|
|
base_profit=base_profit,
|
|
results=sensitivity_results,
|
|
)
|
|
|
|
return SensitivityAnalysisResponse(
|
|
simulation_id=simulation_id,
|
|
base_price=base_price,
|
|
base_profit=base_profit,
|
|
sensitivity_results=sensitivity_results,
|
|
insights=insights,
|
|
)
|
|
|
|
def _generate_sensitivity_insights(
|
|
self,
|
|
base_price: float,
|
|
base_profit: float,
|
|
results: List[SensitivityResultItem],
|
|
) -> SensitivityInsights:
|
|
"""生成敏感性分析洞察"""
|
|
# 计算价格弹性(使用 ±10% 的数据点)
|
|
elasticity = 0
|
|
for r in results:
|
|
if r.price_change_rate == 10:
|
|
elasticity = r.profit_change_rate / 10
|
|
break
|
|
|
|
# 判断风险等级
|
|
risk_level = "低"
|
|
min_profit = min(r.adjusted_profit for r in results)
|
|
|
|
if min_profit < 0:
|
|
risk_level = "高"
|
|
elif min_profit < base_profit * 0.5:
|
|
risk_level = "中等"
|
|
|
|
# 生成建议
|
|
recommendation = "价格调整空间较大,经营风险可控。"
|
|
if risk_level == "高":
|
|
recommendation = f"价格下降超过某阈值会导致亏损,建议密切关注市场动态。"
|
|
elif risk_level == "中等":
|
|
recommendation = f"价格变动对利润影响较大,建议谨慎调价。"
|
|
|
|
return SensitivityInsights(
|
|
price_elasticity=f"价格每变动1%,利润变动约{abs(elasticity):.2f}%",
|
|
risk_level=risk_level,
|
|
recommendation=recommendation,
|
|
)
|
|
|
|
async def breakeven_analysis(
|
|
self,
|
|
pricing_plan_id: int,
|
|
target_profit: Optional[float] = None,
|
|
) -> BreakevenResponse:
|
|
"""盈亏平衡分析
|
|
|
|
Args:
|
|
pricing_plan_id: 定价方案ID
|
|
target_profit: 目标利润(可选)
|
|
|
|
Returns:
|
|
盈亏平衡分析结果
|
|
"""
|
|
plan = await self.get_pricing_plan(pricing_plan_id)
|
|
|
|
price = float(plan.final_price or plan.suggested_price)
|
|
unit_cost = float(plan.base_cost)
|
|
|
|
# 获取月度固定成本
|
|
monthly_fixed_cost = float(await self.get_monthly_fixed_cost())
|
|
|
|
# 计算盈亏平衡点
|
|
contribution_margin = price - unit_cost
|
|
breakeven_volume = self.calculate_breakeven(
|
|
price=price,
|
|
variable_cost=unit_cost,
|
|
fixed_cost=monthly_fixed_cost,
|
|
)
|
|
|
|
# 计算达到目标利润的客量
|
|
target_volume = None
|
|
if target_profit is not None and contribution_margin > 0:
|
|
target_volume = int((monthly_fixed_cost + target_profit) / contribution_margin) + 1
|
|
|
|
return BreakevenResponse(
|
|
pricing_plan_id=pricing_plan_id,
|
|
project_name=plan.project.project_name,
|
|
price=price,
|
|
unit_cost=unit_cost,
|
|
fixed_cost_monthly=monthly_fixed_cost,
|
|
breakeven_volume=breakeven_volume,
|
|
current_margin=round(contribution_margin, 2),
|
|
target_profit_volume=target_volume,
|
|
)
|
|
|
|
async def generate_profit_forecast(
|
|
self,
|
|
simulation_id: int,
|
|
) -> str:
|
|
"""AI 生成利润预测分析
|
|
|
|
遵循瑞小美 AI 接入规范
|
|
|
|
Args:
|
|
simulation_id: 模拟ID
|
|
|
|
Returns:
|
|
AI 分析内容
|
|
"""
|
|
# 获取模拟数据
|
|
result = await self.db.execute(
|
|
select(ProfitSimulation).options(
|
|
selectinload(ProfitSimulation.pricing_plan).selectinload(PricingPlan.project),
|
|
selectinload(ProfitSimulation.sensitivity_analyses),
|
|
).where(ProfitSimulation.id == simulation_id)
|
|
)
|
|
simulation = result.scalar_one_or_none()
|
|
|
|
if not simulation:
|
|
raise ValueError(f"模拟记录不存在: {simulation_id}")
|
|
|
|
# 格式化数据
|
|
pricing_data = f"""- 定价方案:{simulation.pricing_plan.plan_name}
|
|
- 策略类型:{simulation.pricing_plan.strategy_type}
|
|
- 基础成本:{float(simulation.pricing_plan.base_cost):.2f} 元
|
|
- 建议价格:{float(simulation.pricing_plan.suggested_price):.2f} 元
|
|
- 目标毛利率:{float(simulation.pricing_plan.target_margin):.1f}%"""
|
|
|
|
simulation_data = f"""- 模拟价格:{float(simulation.price):.2f} 元
|
|
- 预估客量:{simulation.estimated_volume} ({simulation.period_type})
|
|
- 预估收入:{float(simulation.estimated_revenue):.2f} 元
|
|
- 预估成本:{float(simulation.estimated_cost):.2f} 元
|
|
- 预估利润:{float(simulation.estimated_profit):.2f} 元
|
|
- 利润率:{float(simulation.profit_margin):.1f}%
|
|
- 盈亏平衡客量:{simulation.breakeven_volume}"""
|
|
|
|
# 格式化敏感性数据
|
|
sensitivity_data = "暂无敏感性分析数据"
|
|
if simulation.sensitivity_analyses:
|
|
lines = []
|
|
for sa in simulation.sensitivity_analyses:
|
|
lines.append(
|
|
f" - 价格{float(sa.price_change_rate):+.0f}%: "
|
|
f"价格{float(sa.adjusted_price):.0f}元, "
|
|
f"利润{float(sa.adjusted_profit):.0f}元 "
|
|
f"({float(sa.profit_change_rate):+.1f}%)"
|
|
)
|
|
sensitivity_data = "\n".join(lines)
|
|
|
|
# 调用 AI
|
|
ai_service = AIServiceWrapper(db_session=self.db)
|
|
|
|
messages = [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": USER_PROMPT.format(
|
|
project_name=simulation.pricing_plan.project.project_name,
|
|
pricing_data=pricing_data,
|
|
simulation_data=simulation_data,
|
|
sensitivity_data=sensitivity_data,
|
|
)},
|
|
]
|
|
|
|
try:
|
|
response = await ai_service.chat(
|
|
messages=messages,
|
|
prompt_name=PROMPT_META["name"], # 必填!
|
|
)
|
|
return response.content
|
|
except Exception as e:
|
|
return f"AI 分析暂不可用: {str(e)}"
|
|
|
|
async def get_simulation_list(
|
|
self,
|
|
pricing_plan_id: Optional[int] = None,
|
|
period_type: Optional[PeriodType] = None,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
) -> tuple[List[ProfitSimulation], int]:
|
|
"""获取模拟列表
|
|
|
|
Returns:
|
|
(模拟列表, 总数)
|
|
"""
|
|
query = select(ProfitSimulation).options(
|
|
selectinload(ProfitSimulation.pricing_plan).selectinload(PricingPlan.project)
|
|
)
|
|
|
|
if pricing_plan_id:
|
|
query = query.where(ProfitSimulation.pricing_plan_id == pricing_plan_id)
|
|
|
|
if period_type:
|
|
query = query.where(ProfitSimulation.period_type == period_type.value)
|
|
|
|
# 计算总数
|
|
count_query = select(func.count()).select_from(
|
|
query.subquery()
|
|
)
|
|
total_result = await self.db.execute(count_query)
|
|
total = total_result.scalar() or 0
|
|
|
|
# 分页
|
|
query = query.order_by(ProfitSimulation.created_at.desc())
|
|
query = query.offset((page - 1) * page_size).limit(page_size)
|
|
|
|
result = await self.db.execute(query)
|
|
simulations = result.scalars().all()
|
|
|
|
return simulations, total
|
|
|
|
async def delete_simulation(self, simulation_id: int) -> bool:
|
|
"""删除模拟记录"""
|
|
result = await self.db.execute(
|
|
select(ProfitSimulation).where(ProfitSimulation.id == simulation_id)
|
|
)
|
|
simulation = result.scalar_one_or_none()
|
|
|
|
if simulation:
|
|
await self.db.delete(simulation)
|
|
await self.db.flush()
|
|
return True
|
|
|
|
return False
|