Files
smart-project-pricing/后端服务/app/services/profit_service.py
2026-01-31 21:33:06 +08:00

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