fix(security): 多租户隔离全量修复 security-fix multi-tenant (OpenCode审查P0)
- bot_bridge 18数据端点全部 entity_id 隔离(Depends(get_entity_id)/body),/ping /risk-levels 豁免 - alert_rules 11端点 entity_id 隔离 + KPIAlert/DynamicThresholdCache 写入 entity_id - reports 17端点隔离 + generate_report 写 ReportHistory.entity_id + history 按 entity 过滤 - ai_analysis 移除硬编码默认key,改 _require_deepseek_key() 强制 env 缺失 503 - budget auto-decompose 硬编码 entity_id==1 改请求 entity - kpis update_kpi 加 UPDATE_KPI_WHITELIST 白名单(status/important_flag 不可越权改) - data_quality 收敛:删 MySQL JSON 版 _run_rule_checks,check-governance 复用 _run_governance_checks(SQLite 兼容) - _eval_threshold invert 参数修复(>=↔< 等取反),red 分支不传 invert 保持行为 - 新增 test_security_multitenant.py 13条(bot_bridge/alert_rules/reports 隔离 + invert + SQLite governance) - models 6表加 entity_id 列;生产库已 ALTER + 按真实归属回填(kpi_alerts 472行中216行属entity≠1)
This commit is contained in:
@@ -232,9 +232,17 @@ def _unapplied_suggestions(db: Session, entity_id: int, limit: int = 20) -> list
|
||||
return [_sug_dict(s) for s in items]
|
||||
|
||||
|
||||
def _require_deepseek_key() -> str:
|
||||
"""强制从环境变量读取 DeepSeek Key,禁止硬编码默认值(安全修复 2026-08-31)"""
|
||||
api_key = os.getenv("DEEPSEEK_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(503, "DEEPSEEK_API_KEY 未配置(禁止硬编码默认key,安全修复 2026-08-31)")
|
||||
return api_key
|
||||
|
||||
|
||||
async def _call_deepseek(prompt: str) -> str:
|
||||
"""调用DeepSeek API"""
|
||||
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
|
||||
api_key = _require_deepseek_key()
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
@@ -383,7 +391,7 @@ async def _stream_analysis(prompt: str):
|
||||
"POST",
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY', 'sk-8e24e6eb87f2475e96ea0980002dc2e8')}",
|
||||
"Authorization": f"Bearer {_require_deepseek_key()}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
|
||||
@@ -41,6 +41,7 @@ class DynamicThresholdCache(Base):
|
||||
"""动态阈值缓存 — 存储近3个月历史统计"""
|
||||
__tablename__ = "dynamic_threshold_cache"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
|
||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||
period = Column(String(20), nullable=False, comment="计算期间 2026-07")
|
||||
mean_value = Column(Float, nullable=True, comment="近3月均值")
|
||||
@@ -91,15 +92,16 @@ def list_alert_rules(
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}")
|
||||
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单个KPI的所有预警规则"""
|
||||
rules = db.query(AlertRule).filter(AlertRule.kpi_id == kpi_id).order_by(AlertRule.id).all()
|
||||
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取单个KPI的所有预警规则(账套隔离: 按token企业)"""
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id).order_by(AlertRule.id).all()
|
||||
return {"data": [{c.name: getattr(r, c.name) for c in AlertRule.__table__.columns} for r in rules]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(require_role("ceo", "finance", "it"))):
|
||||
"""创建预警规则"""
|
||||
def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(require_role("ceo", "finance", "it")), entity_id: int = Depends(get_entity_id)):
|
||||
"""创建预警规则(账套隔离: 写入token企业, 2026-08-31 安全修复)"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
rule_type = data.get("rule_type", "static")
|
||||
trigger_on = data.get("trigger_on", "actual")
|
||||
@@ -107,10 +109,13 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if rule_type not in ("static", "dynamic", "trend_up", "trend_down", "forecast_deviation"): # 升级2b: 预测偏差
|
||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
||||
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
trigger_on=trigger_on,
|
||||
@@ -132,9 +137,9 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
|
||||
|
||||
@router.put("/{rule_id}")
|
||||
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""更新预警规则"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""更新预警规则(账套隔离: 禁止跨企业修改)"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id, AlertRule.entity_id == entity_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "预警规则不存在")
|
||||
|
||||
@@ -147,9 +152,9 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
|
||||
"""删除预警规则"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""删除预警规则(账套隔离: 禁止跨企业删除)"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id, AlertRule.entity_id == entity_id).first()
|
||||
if rule:
|
||||
db.delete(rule)
|
||||
db.commit()
|
||||
@@ -157,8 +162,8 @@ def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
||||
"""批量创建预警规则
|
||||
def batch_create_rules(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""批量创建预警规则(账套隔离: 全部写入token企业)
|
||||
data.rules: [{"kpi_id": id, "rule_type": "static", "params": {...}}, ...]
|
||||
"""
|
||||
rules_data = data.get("rules", [])
|
||||
@@ -166,14 +171,19 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
||||
for rule_data in rules_data:
|
||||
kpi_id = rule_data.get("kpi_id")
|
||||
rule_type = rule_data.get("rule_type", "static")
|
||||
# 检查是否已存在相同类型的规则
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
continue
|
||||
# 检查是否已存在相同类型的规则(同企业内)
|
||||
existing = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi_id,
|
||||
AlertRule.rule_type == rule_type,
|
||||
AlertRule.entity_id == entity_id,
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
enabled=rule_data.get("enabled", 1),
|
||||
@@ -186,15 +196,17 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/generate-defaults")
|
||||
def generate_default_rules(db: Session = Depends(get_db)):
|
||||
"""为所有尚未配置预警规则的KPI生成默认规则"""
|
||||
# 找到所有active KPI
|
||||
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""为当前企业尚未配置预警规则的KPI生成默认规则(账套隔离 2026-08-31)"""
|
||||
# 找到当前企业所有active KPI
|
||||
all_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||
|
||||
created = 0
|
||||
for kpi in all_kpis:
|
||||
# 检查是否已有任何规则
|
||||
existing = db.query(AlertRule).filter(AlertRule.kpi_id == kpi.id).first()
|
||||
# 检查是否已有任何规则(同企业内)
|
||||
existing = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi.id, AlertRule.entity_id == entity_id).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
@@ -203,6 +215,7 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
||||
# 1. 静态阈值规则(基于kpi_definitions的阈值)
|
||||
if kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red:
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type="static",
|
||||
enabled=1,
|
||||
@@ -217,6 +230,7 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
||||
|
||||
# 2. 动态趋势规则(所有KPI默认加 trend_down)
|
||||
rule2 = AlertRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type="trend_down",
|
||||
enabled=1,
|
||||
@@ -230,9 +244,10 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/check-all")
|
||||
def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
"""执行所有KPI的预警检查 — 生成新的预警记录"""
|
||||
rules = db.query(AlertRule).filter(AlertRule.enabled == 1).all()
|
||||
def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)"""
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
|
||||
kpi_cache = {}
|
||||
value_cache = {}
|
||||
|
||||
@@ -246,7 +261,7 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
if kpi:
|
||||
kpi_cache[rule.kpi_id] = kpi
|
||||
|
||||
if not kpi:
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
continue
|
||||
|
||||
# 获取最新值
|
||||
@@ -288,6 +303,7 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
).first()
|
||||
if not existing_alert:
|
||||
alert = KPIAlert(
|
||||
entity_id=entity_id,
|
||||
kpi_id=rule.kpi_id,
|
||||
kpi_value_id=latest_value.id,
|
||||
alert_level=alert_level,
|
||||
@@ -306,9 +322,9 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/dynamic-thresholds")
|
||||
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
"""获取动态阈值缓存"""
|
||||
query = db.query(DynamicThresholdCache)
|
||||
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取动态阈值缓存(账套隔离 2026-08-31)"""
|
||||
query = db.query(DynamicThresholdCache).filter(DynamicThresholdCache.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
|
||||
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
|
||||
@@ -316,9 +332,10 @@ def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(g
|
||||
|
||||
|
||||
@router.post("/calculate-dynamic")
|
||||
def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
||||
"""计算所有KPI的动态阈值(基于近3个月历史均值±标准差)"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
def calculate_dynamic_thresholds(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""计算当前企业所有KPI的动态阈值(账套隔离 2026-08-31)"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
computed = 0
|
||||
@@ -351,8 +368,9 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
||||
dynamic_yellow = f">={mean_val:.2f}"
|
||||
dynamic_red = f"<{mean_val:.2f}"
|
||||
|
||||
# 检查是否已有缓存
|
||||
# 检查是否已有缓存(同企业内)
|
||||
existing = db.query(DynamicThresholdCache).filter(
|
||||
DynamicThresholdCache.entity_id == entity_id,
|
||||
DynamicThresholdCache.kpi_id == kpi.id,
|
||||
DynamicThresholdCache.period == current_period,
|
||||
).first()
|
||||
@@ -365,6 +383,7 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
||||
existing.dynamic_red = dynamic_red
|
||||
else:
|
||||
cache = DynamicThresholdCache(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi.id,
|
||||
period=current_period,
|
||||
mean_value=mean_val,
|
||||
@@ -400,7 +419,8 @@ def _check_static(value: float, params: dict, kpi) -> tuple:
|
||||
return ("green", f"[静态] {kpi.kpi_name}={value}, 绿灯{green}")
|
||||
elif _eval_threshold(value, yellow):
|
||||
return ("yellow", f"[静态] {kpi.kpi_name}={value}, 黄灯{yellow}")
|
||||
elif red and _eval_threshold(value, red, invert=True):
|
||||
elif red and _eval_threshold(value, red):
|
||||
# red 阈值字面即命中条件(如 "<600" = 低于600触发红灯;">25" = 高于25触发红灯)
|
||||
return ("red", f"[静态] {kpi.kpi_name}={value}, 红灯{red}")
|
||||
|
||||
return (None, None)
|
||||
@@ -469,16 +489,17 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
|
||||
try:
|
||||
if threshold_str.startswith(">="):
|
||||
limit = float(threshold_str[2:])
|
||||
return value >= limit if not invert else value >= limit
|
||||
# invert=True 时取反:命中 = 值低于阈值(低于下限触发红灯等场景)
|
||||
return value < limit if invert else value >= limit
|
||||
elif threshold_str.startswith("<="):
|
||||
limit = float(threshold_str[2:])
|
||||
return value <= limit if not invert else value <= limit
|
||||
return value > limit if invert else value <= limit
|
||||
elif threshold_str.startswith(">"):
|
||||
limit = float(threshold_str[1:])
|
||||
return value > limit if not invert else value > limit
|
||||
return value <= limit if invert else value > limit
|
||||
elif threshold_str.startswith("<"):
|
||||
limit = float(threshold_str[1:])
|
||||
return value < limit if not invert else value < limit
|
||||
return value >= limit if invert else value < limit
|
||||
else:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
@@ -489,13 +510,14 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
|
||||
# 预测值检查 + 情景建议
|
||||
# ============================================================
|
||||
|
||||
def _check_forecast_alerts(db: Session) -> int:
|
||||
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast'和'both'的规则"""
|
||||
def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
|
||||
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast'和'both'的规则(账套隔离 2026-08-31)"""
|
||||
from app.utils.cash_forecast_engine import forecast_cash_flow, generate_scenario_suggestion
|
||||
from app.models import CashForecast
|
||||
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.enabled == 1,
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.trigger_on.in_(["forecast", "both"]),
|
||||
).all()
|
||||
|
||||
@@ -512,11 +534,10 @@ def _check_forecast_alerts(db: Session) -> int:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
if kpi:
|
||||
rule_kpi_cache[rule.kpi_id] = kpi
|
||||
if not kpi:
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
continue
|
||||
|
||||
entity_id = kpi.entity_id or 1
|
||||
# 获取最新的预测
|
||||
# 获取最新的预测(按规则所属企业)
|
||||
latest_forecasts = db.query(CashForecast).filter(
|
||||
CashForecast.entity_id == entity_id,
|
||||
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
|
||||
@@ -562,6 +583,7 @@ def _check_forecast_alerts(db: Session) -> int:
|
||||
).first()
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
entity_id=entity_id,
|
||||
kpi_id=rule.kpi_id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_message,
|
||||
@@ -580,18 +602,19 @@ def _check_forecast_alerts(db: Session) -> int:
|
||||
|
||||
|
||||
@router.post("/check-forecast")
|
||||
def run_forecast_alert_check(db: Session = Depends(get_db)):
|
||||
"""执行预测值预警检查 — 检查未来7天预测值是否超限"""
|
||||
generated = _check_forecast_alerts(db)
|
||||
def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)"""
|
||||
generated = _check_forecast_alerts(db, entity_id=entity_id)
|
||||
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
|
||||
|
||||
|
||||
@router.post("/generate-suggestions")
|
||||
def generate_alert_suggestions(db: Session = Depends(get_db)):
|
||||
"""为所有未处理的预警生成情景建议"""
|
||||
def generate_alert_suggestions(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""为当前企业所有未处理的预警生成情景建议(账套隔离 2026-08-31)"""
|
||||
from app.utils.cash_forecast_engine import generate_scenario_suggestion
|
||||
|
||||
pending = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.suggestion.is_(None),
|
||||
).all()
|
||||
@@ -681,6 +704,7 @@ def run_forecast_deviation_check(
|
||||
existing.alert_level = alert_level
|
||||
else:
|
||||
db.add(KPIAlert(
|
||||
entity_id=entity_id,
|
||||
kpi_id=rule.kpi_id,
|
||||
kpi_value_id=actual.id,
|
||||
alert_level=alert_level,
|
||||
|
||||
@@ -9,8 +9,9 @@ from sqlalchemy import func, desc
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.models import (
|
||||
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
||||
NotificationLog, RolePermission, ActionPlan, OrgNode,
|
||||
StrategicMapVersion, MapObjective, Objective, KR,
|
||||
@@ -92,20 +93,22 @@ def ping():
|
||||
def bot_overview(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""系统总览 — BOT首选入口"""
|
||||
"""系统总览 — BOT首选入口(账套隔离 2026-08-31: 仅统计当前企业)"""
|
||||
return {
|
||||
"bot": bot,
|
||||
"entity_id": entity_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"stats": {
|
||||
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
|
||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
|
||||
"maps_total": db.query(func.count(StrategicMap.id)).scalar() or 0,
|
||||
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
|
||||
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
|
||||
"data_sources": db.query(func.count(DataSourceConfig.id)).scalar() or 0,
|
||||
"users": db.query(func.count(User.id)).scalar() or 0,
|
||||
"org_nodes": db.query(func.count(OrgNode.id)).scalar() or 0,
|
||||
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar() or 0,
|
||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id).scalar() or 0,
|
||||
"maps_total": db.query(func.count(StrategicMap.id)).filter(StrategicMap.entity_id == entity_id).scalar() or 0,
|
||||
"budget_plans": db.query(func.count(BudgetPlan.id)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
|
||||
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.entity_id == entity_id, ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
|
||||
"data_sources": db.query(func.count(DataSourceConfig.id)).filter(DataSourceConfig.entity_id == entity_id).scalar() or 0,
|
||||
"users": db.query(func.count(User.id)).join(UserEntity, UserEntity.user_id == User.id).filter(UserEntity.entity_id == entity_id).scalar() or 0,
|
||||
"org_nodes": db.query(func.count(OrgNode.id)).filter(OrgNode.entity_id == entity_id).scalar() or 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +123,9 @@ def bot_kpis(
|
||||
limit: int = Query(200, le=1000),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == status)
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == status, KPIDefinition.entity_id == entity_id)
|
||||
if dimension:
|
||||
query = query.filter(KPIDefinition.dimension == dimension)
|
||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
|
||||
@@ -154,9 +158,10 @@ def bot_kpi_history(
|
||||
kpi_id: int, limit: int = Query(12, le=60),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
|
||||
.order_by(KPIValue.period.desc()).limit(limit).all()
|
||||
@@ -180,8 +185,9 @@ def bot_kpi_history(
|
||||
def bot_maps(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
maps = db.query(StrategicMap).order_by(StrategicMap.id.desc()).all()
|
||||
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.id.desc()).all()
|
||||
result = []
|
||||
for m in maps:
|
||||
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
|
||||
@@ -211,8 +217,9 @@ def bot_alerts(
|
||||
limit: int = Query(50, le=200),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
query = db.query(KPIAlert)
|
||||
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
|
||||
query = query.filter(KPIAlert.status == status)
|
||||
if level:
|
||||
query = query.filter(KPIAlert.alert_level == level)
|
||||
@@ -240,8 +247,9 @@ def bot_budget_plans(
|
||||
year: Optional[int] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
query = db.query(BudgetPlan)
|
||||
query = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id)
|
||||
if year:
|
||||
query = query.filter(BudgetPlan.budget_year == year)
|
||||
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
|
||||
@@ -267,8 +275,9 @@ def bot_budget_plans(
|
||||
def bot_standard_costs(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
costs = db.query(StandardCost).filter(StandardCost.status == "active").limit(200).all()
|
||||
costs = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id).limit(200).all()
|
||||
return {
|
||||
"total": len(costs),
|
||||
"items": [
|
||||
@@ -292,8 +301,9 @@ def bot_actual_costs(
|
||||
period: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
query = db.query(ActualCost)
|
||||
query = db.query(ActualCost).filter(ActualCost.entity_id == entity_id)
|
||||
if period:
|
||||
query = query.filter(ActualCost.period == period)
|
||||
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
|
||||
@@ -321,8 +331,9 @@ def bot_actions(
|
||||
status: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
query = db.query(ActionPlan)
|
||||
query = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(ActionPlan.status == status)
|
||||
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
|
||||
@@ -348,8 +359,9 @@ def bot_actions(
|
||||
def bot_org(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all()
|
||||
nodes = db.query(OrgNode).filter(OrgNode.entity_id == entity_id).order_by(OrgNode.level, OrgNode.sort_order).all()
|
||||
return {
|
||||
"total": len(nodes),
|
||||
"items": [
|
||||
@@ -370,8 +382,9 @@ def bot_org(
|
||||
def bot_data_sources(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
sources = db.query(DataSourceConfig).all()
|
||||
sources = db.query(DataSourceConfig).filter(DataSourceConfig.entity_id == entity_id).all()
|
||||
return {
|
||||
"total": len(sources),
|
||||
"items": [
|
||||
@@ -394,8 +407,11 @@ def bot_data_sources(
|
||||
def bot_users(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
users = db.query(User).all()
|
||||
# 用户是全局实体,通过 user_entities 授权表按企业过滤(安全修复 2026-08-31)
|
||||
users = db.query(User).join(UserEntity, UserEntity.user_id == User.id)\
|
||||
.filter(UserEntity.entity_id == entity_id).all()
|
||||
return {
|
||||
"total": len(users),
|
||||
"items": [
|
||||
@@ -414,20 +430,21 @@ def bot_query(
|
||||
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""统一查询入口 — BOT用这个一次拿完需要的数据"""
|
||||
result = {"bot": bot["name"], "role": bot["role"], "timestamp": datetime.now().isoformat()}
|
||||
"""统一查询入口 — BOT用这个一次拿完需要的数据(账套隔离 2026-08-31)"""
|
||||
result = {"bot": bot["name"], "role": bot["role"], "entity_id": entity_id, "timestamp": datetime.now().isoformat()}
|
||||
|
||||
if q in ("overview", "all"):
|
||||
result["overview"] = {
|
||||
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
|
||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
|
||||
"maps": db.query(func.count(StrategicMap.id)).scalar() or 0,
|
||||
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
|
||||
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar() or 0,
|
||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id).scalar() or 0,
|
||||
"maps": db.query(func.count(StrategicMap.id)).filter(StrategicMap.entity_id == entity_id).scalar() or 0,
|
||||
"budget_plans": db.query(func.count(BudgetPlan.id)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
|
||||
}
|
||||
|
||||
if q in ("kpis", "all"):
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").limit(100).all()
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).limit(100).all()
|
||||
result["kpis"] = [
|
||||
{"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
||||
"dimension": k.dimension, "target": _float(k.target_value), "unit": k.unit}
|
||||
@@ -435,7 +452,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
if q in ("alerts", "all"):
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending")\
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id)\
|
||||
.order_by(KPIAlert.created_at.desc()).limit(20).all()
|
||||
result["alerts"] = [
|
||||
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
|
||||
@@ -444,7 +461,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
if q in ("maps", "all"):
|
||||
maps = db.query(StrategicMap).limit(10).all()
|
||||
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).limit(10).all()
|
||||
result["maps"] = [
|
||||
{"id": m.id, "title": m.title, "status": m.status,
|
||||
"version": m.version, "created_at": _safe_iso(m.created_at)}
|
||||
@@ -452,7 +469,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
if q in ("budget", "all"):
|
||||
plans = db.query(BudgetPlan).limit(50).all()
|
||||
plans = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id).limit(50).all()
|
||||
result["budget"] = [
|
||||
{"id": p.id, "period": p.period, "budget_value": _float(p.budget_value),
|
||||
"year": p.budget_year, "month": p.budget_month, "status": p.status,
|
||||
@@ -461,7 +478,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
if q in ("cost", "all"):
|
||||
sc = db.query(StandardCost).limit(50).all()
|
||||
sc = db.query(StandardCost).filter(StandardCost.entity_id == entity_id).limit(50).all()
|
||||
result["costs"] = [
|
||||
{"id": c.id, "product": c.product_name, "type": c.cost_type,
|
||||
"standard": _float(c.standard_cost), "unit": c.unit}
|
||||
@@ -469,7 +486,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
if q in ("okr", "all"):
|
||||
objs = db.query(Objective).filter(Objective.status == "active").all()
|
||||
objs = db.query(Objective).filter(Objective.status == "active", Objective.entity_id == entity_id).all()
|
||||
result["okr"] = []
|
||||
for o in objs:
|
||||
# KR完整修复(2026-08-27): 从krs表读取
|
||||
@@ -486,7 +503,7 @@ def bot_query(
|
||||
})
|
||||
|
||||
if q in ("actions", "all"):
|
||||
acts = db.query(ActionPlan).limit(30).all()
|
||||
acts = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).limit(30).all()
|
||||
result["actions"] = [
|
||||
{"id": a.id, "title": a.title, "status": a.status,
|
||||
"progress": a.progress, "assignee": a.assignee}
|
||||
@@ -502,8 +519,9 @@ def bot_import_excel(
|
||||
file: UploadFile = File(...),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""Bot上传Excel导入KPI数据到CMA"""
|
||||
"""Bot上传Excel导入KPI数据到CMA(账套隔离 2026-08-31: 仅导入当前企业KPI)"""
|
||||
import pandas as pd, io, hashlib
|
||||
from app.models import KPIValue
|
||||
try:
|
||||
@@ -547,6 +565,9 @@ def bot_import_excel(
|
||||
if not kpi:
|
||||
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
|
||||
continue
|
||||
if kpi.entity_id != entity_id:
|
||||
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不属于当前企业(entity={kpi.entity_id}),跳过")
|
||||
continue
|
||||
|
||||
kv = KPIValue(kpi_id=kpi.id, entity_id=kpi.entity_id, period=period, actual_value=val,
|
||||
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
||||
@@ -569,10 +590,11 @@ def bot_okr_create(
|
||||
dimension: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""Bot创建OKR目标"""
|
||||
"""Bot创建OKR目标(账套隔离 2026-08-31: 写入token企业)"""
|
||||
from app.models import Objective
|
||||
obj = Objective(title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
|
||||
obj = Objective(entity_id=entity_id, title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
@@ -585,10 +607,11 @@ def bot_okr_list(
|
||||
quarter: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""Bot列出OKR(含KR进度)"""
|
||||
"""Bot列出OKR(含KR进度)(账套隔离 2026-08-31)"""
|
||||
from app.models import Objective
|
||||
q = db.query(Objective)
|
||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
objs = q.order_by(Objective.quarter.desc()).all()
|
||||
@@ -607,6 +630,7 @@ def bot_nlp(
|
||||
intent: str = Query("overview"),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""
|
||||
自然语言意图映射:
|
||||
@@ -623,7 +647,7 @@ def bot_nlp(
|
||||
"okr": "okr", "目标": "okr", "季度目标": "okr",
|
||||
}
|
||||
resolved = m.get(intent, intent)
|
||||
return bot_query(q=resolved, bot=bot, db=db)
|
||||
return bot_query(q=resolved, bot=bot, db=db, entity_id=entity_id)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -183,6 +183,7 @@ def auto_decompose_budget(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""自动分解年度预算到月度(均分或按历史权重)
|
||||
支持两种模式:
|
||||
@@ -199,7 +200,7 @@ def auto_decompose_budget(
|
||||
if not kpi_id:
|
||||
# 只取年度行(period=YYYY-00)作为年度总额,避免把月度行也加进来导致滚雪球(非幂等bug修复)
|
||||
year_budget_rows = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == 1,
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.budget_year == year,
|
||||
BudgetPlan.period == f"{year}-00",
|
||||
BudgetPlan.status == "active",
|
||||
@@ -259,7 +260,7 @@ def auto_decompose_budget(
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
db.add(BudgetPlan(
|
||||
entity_id=1,
|
||||
entity_id=entity_id,
|
||||
kpi_id=kid,
|
||||
period=period,
|
||||
budget_value=monthly_value,
|
||||
|
||||
@@ -341,151 +341,6 @@ RULES_META = {
|
||||
DETAIL_LIMIT = 10 # 每条规则detail最多列出的条数(避免响应过大)
|
||||
|
||||
|
||||
def _run_rule_checks(db: Session, entity_id: int = 0):
|
||||
"""执行7条DAMA治理规则,返回 issues 列表。entity_id=0 表示全部实体。"""
|
||||
entity_filter = " AND cp.entity_id = :eid" if entity_id else ""
|
||||
|
||||
issues = []
|
||||
|
||||
# ── 规则1 单位校验 ──
|
||||
rows = db.execute(text(
|
||||
"SELECT cp.id, cp.entity_id, cp.amount, cp.source, cp.description "
|
||||
"FROM cash_plans cp WHERE cp.amount > 10000" + entity_filter + " ORDER BY cp.amount DESC"
|
||||
), {"eid": entity_id}).fetchall()
|
||||
issues.append({
|
||||
"rule": "unit_check", "level": "error",
|
||||
"count": len(rows),
|
||||
"detail": [f"plan#{r.id} 金额{r.amount}(疑似元)" for r in rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d条" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则2 重复预警(同plan_id多条pending应收预警)──
|
||||
if entity_id:
|
||||
dup_sql = text(
|
||||
"SELECT JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid, COUNT(*) c, MAX(p.entity_id) eid "
|
||||
"FROM kpi_alerts a JOIN cash_plans p ON p.id = JSON_EXTRACT(a.suggestion, '$.plan_id') "
|
||||
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
|
||||
"AND a.suggestion LIKE '%plan_id%' AND p.entity_id = :eid "
|
||||
"GROUP BY pid HAVING c > 1 ORDER BY c DESC"
|
||||
)
|
||||
else:
|
||||
dup_sql = text(
|
||||
"SELECT JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid, COUNT(*) c, MAX(p.entity_id) eid "
|
||||
"FROM kpi_alerts a JOIN cash_plans p ON p.id = JSON_EXTRACT(a.suggestion, '$.plan_id') "
|
||||
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
|
||||
"AND a.suggestion LIKE '%plan_id%' "
|
||||
"GROUP BY pid HAVING c > 1 ORDER BY c DESC"
|
||||
)
|
||||
dup_rows = db.execute(dup_sql, {"eid": entity_id}).fetchall()
|
||||
issues.append({
|
||||
"rule": "dup_alert", "level": "error",
|
||||
"count": len(dup_rows),
|
||||
"detail": [f"plan#{r.pid} 重复预警×{r.c}" for r in dup_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d个plan" % len(dup_rows)] if len(dup_rows) > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则3 孤儿预警(plan_id指向不存在的cash_plans)──
|
||||
orphan_sql = text(
|
||||
"SELECT a.id, a.kpi_id, JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid "
|
||||
"FROM kpi_alerts a "
|
||||
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
|
||||
"AND a.suggestion LIKE '%plan_id%' "
|
||||
"AND NOT EXISTS (SELECT 1 FROM cash_plans p WHERE p.id = JSON_EXTRACT(a.suggestion, '$.plan_id')) "
|
||||
"ORDER BY a.id LIMIT 200"
|
||||
)
|
||||
orphan_rows = db.execute(orphan_sql).fetchall()
|
||||
issues.append({
|
||||
"rule": "orphan_check", "level": "error",
|
||||
"count": len(orphan_rows),
|
||||
"detail": [f"预警#{r.id}(kpi#{r.kpi_id}) → plan#{r.pid} 不存在" for r in orphan_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d条" % len(orphan_rows)] if len(orphan_rows) > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则4 虚拟污染(source含test/虚拟标识)──
|
||||
rows = db.execute(text(
|
||||
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
|
||||
"WHERE cp.source LIKE '%test%' OR cp.source LIKE '%虚拟%' OR cp.source LIKE '%demo%'"
|
||||
+ entity_filter + " ORDER BY cp.id LIMIT 200"
|
||||
), {"eid": entity_id}).fetchall()
|
||||
issues.append({
|
||||
"rule": "virtual_pollution", "level": "error",
|
||||
"count": len(rows),
|
||||
"detail": [f"plan#{r.id} source={r.source}" for r in rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d条" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则5 实体归属(kpi_values.entity_id != kpi_definitions.entity_id)──
|
||||
if entity_id:
|
||||
ent_sql = text(
|
||||
"SELECT v.id, v.kpi_id, d.kpi_code, v.entity_id AS v_eid, d.entity_id AS d_eid "
|
||||
"FROM kpi_values v JOIN kpi_definitions d ON v.kpi_id = d.id "
|
||||
"WHERE v.entity_id != d.entity_id AND v.entity_id = :eid ORDER BY v.id LIMIT 200"
|
||||
)
|
||||
else:
|
||||
ent_sql = text(
|
||||
"SELECT v.id, v.kpi_id, d.kpi_code, v.entity_id AS v_eid, d.entity_id AS d_eid "
|
||||
"FROM kpi_values v JOIN kpi_definitions d ON v.kpi_id = d.id "
|
||||
"WHERE v.entity_id != d.entity_id ORDER BY v.id LIMIT 200"
|
||||
)
|
||||
ent_rows = db.execute(ent_sql, {"eid": entity_id}).fetchall()
|
||||
issues.append({
|
||||
"rule": "entity_check", "level": "error",
|
||||
"count": len(ent_rows),
|
||||
"detail": [f"值#{r.id} {r.kpi_code} 实体{r.v_eid}≠定义实体{r.d_eid}" for r in ent_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d条" % len(ent_rows)] if len(ent_rows) > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则6 KPI完整性(active KPI无任何值)──
|
||||
if entity_id:
|
||||
comp_sql = text(
|
||||
"SELECT d.id, d.kpi_code, d.kpi_name FROM kpi_definitions d "
|
||||
"WHERE d.status='active' AND d.entity_id = :eid "
|
||||
"AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id) ORDER BY d.id LIMIT 300"
|
||||
)
|
||||
else:
|
||||
comp_sql = text(
|
||||
"SELECT d.id, d.kpi_code, d.kpi_name FROM kpi_definitions d "
|
||||
"WHERE d.status='active' "
|
||||
"AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id) ORDER BY d.id LIMIT 300"
|
||||
)
|
||||
comp_rows = db.execute(comp_sql, {"eid": entity_id}).fetchall()
|
||||
issues.append({
|
||||
"rule": "kpi_completeness", "level": "warning",
|
||||
"count": len(comp_rows),
|
||||
"detail": [f"{r.kpi_code} {r.kpi_name}(无值)" for r in comp_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d个KPI" % len(comp_rows)] if len(comp_rows) > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则7 勾稽验证(预算月度合计 vs 年度目标差异>20%)──
|
||||
if entity_id:
|
||||
recon_sql = text(
|
||||
"SELECT d.kpi_code, d.kpi_name, d.target_yearly, "
|
||||
"SUM(b.budget_value) AS monthly_sum, "
|
||||
"ROUND((SUM(b.budget_value) - d.target_yearly) / d.target_yearly * 100, 1) AS diff_pct "
|
||||
"FROM kpi_definitions d JOIN budget_plans b ON b.kpi_id = d.id "
|
||||
"WHERE d.status='active' AND d.target_yearly > 0 AND d.entity_id = :eid "
|
||||
"GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
|
||||
)
|
||||
else:
|
||||
recon_sql = text(
|
||||
"SELECT d.kpi_code, d.kpi_name, d.target_yearly, "
|
||||
"SUM(b.budget_value) AS monthly_sum, "
|
||||
"ROUND((SUM(b.budget_value) - d.target_yearly) / d.target_yearly * 100, 1) AS diff_pct "
|
||||
"FROM kpi_definitions d JOIN budget_plans b ON b.kpi_id = d.id "
|
||||
"WHERE d.status='active' AND d.target_yearly > 0 "
|
||||
"GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
|
||||
)
|
||||
recon_rows = db.execute(recon_sql, {"eid": entity_id}).fetchall()
|
||||
issues.append({
|
||||
"rule": "reconciliation", "level": "warning",
|
||||
"count": len(recon_rows),
|
||||
"detail": [f"{r.kpi_code} 预算合计{round(r.monthly_sum, 1)} vs 年度目标{r.target_yearly} 差异{r.diff_pct}%" for r in recon_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d个KPI" % len(recon_rows)] if len(recon_rows) > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@router.get("/check-governance")
|
||||
def check_governance(
|
||||
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
|
||||
@@ -495,10 +350,16 @@ def check_governance(
|
||||
|
||||
评分规则: 满分100,error级规则每条扣10分,warning级规则每条扣5分,
|
||||
每条规则最多扣一次分(按规则是否命中,不按count累扣),最低0分。
|
||||
"""
|
||||
issues = _run_rule_checks(db, entity_id or 0)
|
||||
|
||||
# 计算评分
|
||||
收敛说明(2026-08-31 安全修复 P1-2):规则执行统一复用
|
||||
_run_governance_checks(Python 解析 suggestion JSON,SQLite 兼容),
|
||||
删除原 _run_rule_checks(MySQL JSON_EXTRACT/JSON_VALID 版,SQLite 不兼容)。
|
||||
本端点保留原评分口径与响应结构(governance-check 端点使用新的扣分口径)。
|
||||
"""
|
||||
gov = _run_governance_checks(db, entity_id or 0)
|
||||
issues = gov["issues"]
|
||||
|
||||
# 计算评分(check-governance 原口径:error 扣10 / warning 扣5,每规则最多扣一次)
|
||||
score = 100
|
||||
for item in issues:
|
||||
if item["count"] > 0:
|
||||
|
||||
@@ -735,6 +735,19 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
|
||||
return kpi_to_dict(kpi)
|
||||
|
||||
|
||||
# 可更新字段白名单(安全修复 2026-08-31):update_kpi 只允许更新业务属性。
|
||||
# status / important_flag 等敏感字段及主键/归属字段(id/kpi_code/entity_id/map_id/created_*)一律忽略,
|
||||
# 防越权修改(如借 update 篡改重要标记/上下架状态)。
|
||||
UPDATE_KPI_WHITELIST = {
|
||||
"kpi_name", "dimension", "objective", "formula", "formula_desc",
|
||||
"data_source_type", "data_source_config", "data_source", "data_owner",
|
||||
"frequency", "unit", "target_value", "target_monthly", "target_quarterly",
|
||||
"target_yearly", "target_calc_type", "threshold_green", "threshold_yellow",
|
||||
"threshold_red", "category", "data_level", "data_category",
|
||||
"responsible_dept", "responsible_user", "kpi_level", "bot_source", "epic",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{kpi_id}")
|
||||
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
@@ -749,6 +762,10 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
data.pop("entity_id", None) # 禁止通过update改企业归属
|
||||
data = apply_calc_type_inference(data, infer_missing=False)
|
||||
# 白名单过滤(安全修复 2026-08-31):白名单外字段(status/important_flag/主键等)忽略不修改
|
||||
for k in list(data.keys()):
|
||||
if k not in UPDATE_KPI_WHITELIST:
|
||||
data.pop(k, None)
|
||||
for k, v in data.items():
|
||||
if hasattr(kpi, k) and v is not None:
|
||||
setattr(kpi, k, v)
|
||||
|
||||
@@ -328,7 +328,11 @@ def _sync_map_objectives(m, db):
|
||||
|
||||
|
||||
def _merge_map_objectives(m, db):
|
||||
"""读取时:将map_objectives表的数据合并进dimensions JSON"""
|
||||
"""读取时:将map_objectives表的数据合并进dimensions JSON
|
||||
|
||||
注意(安全审查 2026-08-31):本函数是【只读合并】——只读 map_objectives 并合并到
|
||||
dimensions JSON,不写库、无事务提交需求,缺 commit 不影响。勿误判为写操作。
|
||||
"""
|
||||
objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all()
|
||||
if not objs:
|
||||
return
|
||||
|
||||
+140
-108
@@ -37,6 +37,7 @@ router = APIRouter(prefix="/api/cma/reports", tags=["管理报表"],
|
||||
def get_profit_summary(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润"""
|
||||
if period is None:
|
||||
@@ -44,7 +45,8 @@ def get_profit_summary(
|
||||
|
||||
# 从KPI数据中获取各利润要素
|
||||
def get_val(code: str):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||
if not kpi:
|
||||
return None
|
||||
v = db.query(KPIValue).filter(
|
||||
@@ -79,7 +81,8 @@ def get_profit_summary(
|
||||
prev_period = f"{py}-{pm:02d}"
|
||||
|
||||
def get_prev_val(code: str):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||
if not kpi: return None
|
||||
v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id, KPIValue.period == prev_period
|
||||
@@ -159,12 +162,13 @@ def get_budget_execution(
|
||||
dimension: Optional[str] = Query(None),
|
||||
alert_level: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""预算执行报告 — 各KPI预算vs实际vs差异率"""
|
||||
"""预算执行报告 — 各KPI预算vs实际vs差异率(账套隔离 2026-08-31)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id)
|
||||
if dimension:
|
||||
query = query.filter(KPIDefinition.dimension == dimension)
|
||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).all()
|
||||
@@ -223,9 +227,10 @@ def get_kpi_trends(
|
||||
dimension: Optional[str] = Query(None),
|
||||
months: int = Query(12, ge=3, le=36),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""KPI趋势报告 — 选定KPI的历史趋势线"""
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
"""KPI趋势报告 — 选定KPI的历史趋势线(账套隔离 2026-08-31)"""
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(KPIDefinition.id == kpi_id)
|
||||
if dimension:
|
||||
@@ -289,20 +294,21 @@ def get_bsc_scorecard(
|
||||
map_id: Optional[int] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""四维度绩效评分卡 — BSC健康度"""
|
||||
"""四维度绩效评分卡 — BSC健康度(账套隔离 2026-08-31)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
# 取最新的已发布地图
|
||||
map_query = db.query(StrategicMap).filter(StrategicMap.status == "published")
|
||||
# 取最新的已发布地图(当前企业)
|
||||
map_query = db.query(StrategicMap).filter(StrategicMap.status == "published", StrategicMap.entity_id == entity_id)
|
||||
if map_id:
|
||||
map_query = map_query.filter(StrategicMap.id == map_id)
|
||||
sm = map_query.order_by(StrategicMap.updated_at.desc()).first()
|
||||
|
||||
if not sm:
|
||||
# 没有已发布地图,按维度聚合KPI
|
||||
return _build_scorecard_from_kpis(db, period)
|
||||
return _build_scorecard_from_kpis(db, period, entity_id)
|
||||
|
||||
# 从战略地图维度数据构建评分卡
|
||||
dims = sm.dimensions
|
||||
@@ -369,9 +375,10 @@ def get_bsc_scorecard(
|
||||
}
|
||||
|
||||
|
||||
def _build_scorecard_from_kpis(db: Session, period: str) -> dict:
|
||||
"""没有战略地图时,直接按维度聚合KPI算分"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
def _build_scorecard_from_kpis(db: Session, period: str, entity_id: int = 1) -> dict:
|
||||
"""没有战略地图时,直接按维度聚合KPI算分(账套隔离 2026-08-31)"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||
dims: dict = {}
|
||||
|
||||
for kpi in kpis:
|
||||
@@ -520,8 +527,8 @@ BLOCK_INFO = {
|
||||
}
|
||||
|
||||
|
||||
def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]:
|
||||
"""从 subjects + kpi_values 获取科目金额数据"""
|
||||
def _get_subject_amount(db: Session, code: str, period: str, entity_id: int = 1) -> Optional[float]:
|
||||
"""从 subjects + kpi_values 获取科目金额数据(账套隔离 2026-08-31)"""
|
||||
# 尝试从KPI数据获取(KPI编码与科目编码映射)
|
||||
kpi_code_map = {
|
||||
"6001": "F_REVENUE",
|
||||
@@ -545,7 +552,8 @@ def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]:
|
||||
# 1. 优先从 kpi_values 取
|
||||
if code in kpi_code_map:
|
||||
kpi_code = kpi_code_map[code]
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code, KPIDefinition.entity_id == entity_id).first()
|
||||
if kpi:
|
||||
v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
@@ -576,19 +584,20 @@ def get_profit_statement(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
format: str = Query("old", description="old/new/dual"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""利润表 — 支持旧格式、新30号准则五板块格式、双列对比"""
|
||||
"""利润表 — 支持旧格式、新30号准则五板块格式、双列对比(账套隔离 2026-08-31)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
if format == "old":
|
||||
# 旧30号准则格式(保留兼容)
|
||||
return get_profit_summary(period=period, db=db)
|
||||
return get_profit_summary(period=period, db=db, entity_id=entity_id)
|
||||
|
||||
if format == "dual":
|
||||
# 双列对比:旧准则 vs 新准则
|
||||
old_data = get_profit_summary(period=period, db=db)
|
||||
new_data = _build_new_format_profit(db, period)
|
||||
old_data = get_profit_summary(period=period, db=db, entity_id=entity_id)
|
||||
new_data = _build_new_format_profit(db, period, entity_id)
|
||||
return {
|
||||
"period": period,
|
||||
"format": "dual",
|
||||
@@ -598,11 +607,11 @@ def get_profit_statement(
|
||||
}
|
||||
|
||||
# === 新30号准则:五板块结构 ===
|
||||
return _build_new_format_profit(db, period)
|
||||
return _build_new_format_profit(db, period, entity_id)
|
||||
|
||||
|
||||
def _build_new_format_profit(db: Session, period: str) -> dict:
|
||||
"""构建新30号准则五板块利润表(含附注明细)"""
|
||||
def _build_new_format_profit(db: Session, period: str, entity_id: int = 1) -> dict:
|
||||
"""构建新30号准则五板块利润表(含附注明细)(账套隔离 2026-08-31)"""
|
||||
blocks = []
|
||||
total_net_profit = 0
|
||||
all_items_have_data = True
|
||||
@@ -614,7 +623,7 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
|
||||
block_has_data = False
|
||||
|
||||
for item_cfg in block_cfg["items"]:
|
||||
amount = _get_subject_amount(db, item_cfg["code"], period)
|
||||
amount = _get_subject_amount(db, item_cfg["code"], period, entity_id)
|
||||
if amount is not None:
|
||||
effective = amount * item_cfg["sign"]
|
||||
block_subtotal += effective
|
||||
@@ -646,7 +655,7 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
|
||||
total_net_profit += block_subtotal
|
||||
|
||||
# 附注明细(对外法定报表披露要求)
|
||||
notes = _build_profit_notes(db, period, blocks, total_net_profit)
|
||||
notes = _build_profit_notes(db, period, blocks, total_net_profit, entity_id)
|
||||
|
||||
# 合计行:净利润 = 一二三+四+五
|
||||
return {
|
||||
@@ -662,10 +671,10 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _build_profit_notes(db: Session, period: str, blocks: list, net_profit: float) -> dict:
|
||||
"""利润表附注明细 — 收入/费用/财务费用拆解 + 板块勾稽 + 关键比率"""
|
||||
def _build_profit_notes(db: Session, period: str, blocks: list, net_profit: float, entity_id: int = 1) -> dict:
|
||||
"""利润表附注明细 — 收入/费用/财务费用拆解 + 板块勾稽 + 关键比率(账套隔离 2026-08-31)"""
|
||||
def amt(code):
|
||||
return _get_subject_amount(db, code, period)
|
||||
return _get_subject_amount(db, code, period, entity_id)
|
||||
|
||||
revenue_main = amt("6001")
|
||||
revenue_other = amt("6051")
|
||||
@@ -819,8 +828,9 @@ class MpmCalculateRequest(BaseModel):
|
||||
def mpm_calculate(
|
||||
req: MpmCalculateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""MPM管理层指标计算器 — 生成合规调节表"""
|
||||
"""MPM管理层指标计算器 — 生成合规调节表(账套隔离 2026-08-31)"""
|
||||
if req.period is None:
|
||||
req.period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
@@ -829,12 +839,12 @@ def mpm_calculate(
|
||||
raise HTTPException(status_code=400, detail=f"不支持的指标类型: {req.indicator_type}")
|
||||
|
||||
# 获取基准值:净利润
|
||||
net_profit = _calc_new_net_profit(db, req.period)
|
||||
net_profit = _calc_new_net_profit(db, req.period, entity_id)
|
||||
if net_profit is None:
|
||||
net_profit = 0
|
||||
|
||||
# 经营现金流(自由现金流的基准)
|
||||
operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period)
|
||||
operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period, entity_id)
|
||||
|
||||
# 确定基准值
|
||||
if req.indicator_type == "free_cash_flow":
|
||||
@@ -872,7 +882,7 @@ def mpm_calculate(
|
||||
|
||||
# 尝试自动取值
|
||||
if amount is None and checked:
|
||||
amount = _get_adjustment_value(db, code, req.period)
|
||||
amount = _get_adjustment_value(db, code, req.period, entity_id)
|
||||
|
||||
effective = round(amount * sign, 2) if amount is not None else None
|
||||
|
||||
@@ -913,9 +923,10 @@ def mpm_calculate(
|
||||
}
|
||||
|
||||
|
||||
def _get_kpi_val(db: Session, code: str, period: str) -> Optional[float]:
|
||||
"""从KPI定义+值获取数值"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
def _get_kpi_val(db: Session, code: str, period: str, entity_id: int = 1) -> Optional[float]:
|
||||
"""从KPI定义+值获取数值(账套隔离 2026-08-31)"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||
if not kpi:
|
||||
return None
|
||||
v = db.query(KPIValue).filter(
|
||||
@@ -924,14 +935,14 @@ def _get_kpi_val(db: Session, code: str, period: str) -> Optional[float]:
|
||||
return float(v.actual_value) if v and v.actual_value is not None else None
|
||||
|
||||
|
||||
def _calc_new_net_profit(db: Session, period: str) -> Optional[float]:
|
||||
"""计算新30号准则下的净利润"""
|
||||
def _calc_new_net_profit(db: Session, period: str, entity_id: int = 1) -> Optional[float]:
|
||||
"""计算新30号准则下的净利润(账套隔离 2026-08-31)"""
|
||||
total = 0
|
||||
has_data = False
|
||||
for block_key in ["operating", "investing", "financing", "tax", "discontinued"]:
|
||||
block_cfg = BLOCK_INFO[block_key]
|
||||
for item_cfg in block_cfg["items"]:
|
||||
amount = _get_subject_amount(db, item_cfg["code"], period)
|
||||
amount = _get_subject_amount(db, item_cfg["code"], period, entity_id)
|
||||
if amount is not None:
|
||||
total += amount * item_cfg["sign"]
|
||||
has_data = True
|
||||
@@ -940,14 +951,14 @@ def _calc_new_net_profit(db: Session, period: str) -> Optional[float]:
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
def _get_adjustment_value(db: Session, adj_code: str, period: str) -> Optional[float]:
|
||||
"""获取调整项的自动取值"""
|
||||
def _get_adjustment_value(db: Session, adj_code: str, period: str, entity_id: int = 1) -> Optional[float]:
|
||||
"""获取调整项的自动取值(账套隔离 2026-08-31)"""
|
||||
mapping = ADJUSTMENT_VALUE_MAP.get(adj_code)
|
||||
if mapping is None:
|
||||
return None # 需要用户输入
|
||||
|
||||
code = mapping["code"]
|
||||
amount = _get_subject_amount(db, code, period)
|
||||
amount = _get_subject_amount(db, code, period, entity_id)
|
||||
if amount is None:
|
||||
return None
|
||||
|
||||
@@ -977,14 +988,16 @@ def _get_demo_block_total(block_key: str) -> float:
|
||||
def get_restatement(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""2026年数据按新准则重述 — 旧口径vs新口径双列对比,自动标记调整项"""
|
||||
"""2026年数据按新准则重述 — 旧口径vs新口径双列对比,自动标记调整项(账套隔离 2026-08-31)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
# 旧口径数据 (传统利润表项目)
|
||||
def _old_kpi_val(code: str):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||
if not kpi:
|
||||
return None
|
||||
v = db.query(KPIValue).filter(
|
||||
@@ -1002,22 +1015,22 @@ def get_restatement(
|
||||
old_rd_exp = _old_kpi_val("F_RD_EXP")
|
||||
|
||||
# 新口径数据 (从科目映射或kpi_values获取)
|
||||
new_revenue = _get_subject_amount(db, "6001", period)
|
||||
new_revenue_other = _get_subject_amount(db, "6051", period)
|
||||
new_cost = _get_subject_amount(db, "6401", period)
|
||||
new_cost_other = _get_subject_amount(db, "6402", period)
|
||||
new_selling = _get_subject_amount(db, "6601", period)
|
||||
new_admin = _get_subject_amount(db, "6602", period)
|
||||
new_rd = _get_subject_amount(db, "660204", period)
|
||||
new_interest_income = _get_subject_amount(db, "6011", period)
|
||||
new_interest_exp = _get_subject_amount(db, "660301", period)
|
||||
new_fx = _get_subject_amount(db, "6603", period)
|
||||
new_fx_financing = _get_subject_amount(db, "660302", period)
|
||||
new_invest_income = _get_subject_amount(db, "6111", period)
|
||||
new_impairment = _get_subject_amount(db, "6701", period)
|
||||
new_invest_impairment = _get_subject_amount(db, "670101", period)
|
||||
new_tax = _get_subject_amount(db, "6801", period)
|
||||
new_discontinued = _get_subject_amount(db, "6901", period)
|
||||
new_revenue = _get_subject_amount(db, "6001", period, entity_id)
|
||||
new_revenue_other = _get_subject_amount(db, "6051", period, entity_id)
|
||||
new_cost = _get_subject_amount(db, "6401", period, entity_id)
|
||||
new_cost_other = _get_subject_amount(db, "6402", period, entity_id)
|
||||
new_selling = _get_subject_amount(db, "6601", period, entity_id)
|
||||
new_admin = _get_subject_amount(db, "6602", period, entity_id)
|
||||
new_rd = _get_subject_amount(db, "660204", period, entity_id)
|
||||
new_interest_income = _get_subject_amount(db, "6011", period, entity_id)
|
||||
new_interest_exp = _get_subject_amount(db, "660301", period, entity_id)
|
||||
new_fx = _get_subject_amount(db, "6603", period, entity_id)
|
||||
new_fx_financing = _get_subject_amount(db, "660302", period, entity_id)
|
||||
new_invest_income = _get_subject_amount(db, "6111", period, entity_id)
|
||||
new_impairment = _get_subject_amount(db, "6701", period, entity_id)
|
||||
new_invest_impairment = _get_subject_amount(db, "670101", period, entity_id)
|
||||
new_tax = _get_subject_amount(db, "6801", period, entity_id)
|
||||
new_discontinued = _get_subject_amount(db, "6901", period, entity_id)
|
||||
|
||||
# 旧口径汇总计算
|
||||
old_operating_items = [
|
||||
@@ -1252,7 +1265,7 @@ def get_restatement(
|
||||
total = 0
|
||||
for codes in [op_items, inv_items, fin_items, tax_items, dis_items]:
|
||||
for code in codes:
|
||||
amt = _get_subject_amount(db, code, period)
|
||||
amt = _get_subject_amount(db, code, period, entity_id)
|
||||
if amt is not None:
|
||||
# 根据BLOCK_INFO中的sign处理
|
||||
for bk in BLOCK_INFO.values():
|
||||
@@ -1285,7 +1298,9 @@ def get_restatement(
|
||||
def get_category_map(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""返回科目→新30号准则板块映射"""
|
||||
"""返回科目→新30号准则板块映射
|
||||
豁免多租户隔离(2026-08-31):Subject 为全局会计科目字典(无 entity_id 列),
|
||||
返回的是科目分类映射常量,非企业业务数据,故不做 entity 过滤"""
|
||||
subjects_data = db.query(Subject).filter(Subject.is_active == 1).order_by(Subject.subject_code).all()
|
||||
|
||||
map_list = []
|
||||
@@ -1443,8 +1458,8 @@ def _prev_period_str(period: str) -> str:
|
||||
return period
|
||||
|
||||
|
||||
def _get_bs_amount(db: Session, codes: list, period: str) -> Optional[float]:
|
||||
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None"""
|
||||
def _get_bs_amount(db: Session, codes: list, period: str, entity_id: int = 1) -> Optional[float]:
|
||||
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None(账套隔离 2026-08-31)"""
|
||||
total = 0.0
|
||||
has_data = False
|
||||
try:
|
||||
@@ -1464,9 +1479,9 @@ def _get_bs_amount(db: Session, codes: list, period: str) -> Optional[float]:
|
||||
return round(total, 2) if has_data else None
|
||||
|
||||
|
||||
def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end") -> dict:
|
||||
"""单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)"""
|
||||
real = _get_bs_amount(db, line["codes"], period)
|
||||
def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end", entity_id: int = 1) -> dict:
|
||||
"""单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)(账套隔离 2026-08-31)"""
|
||||
real = _get_bs_amount(db, line["codes"], period, entity_id)
|
||||
if real is not None:
|
||||
return {"value": real, "is_demo": False}
|
||||
key = "|".join(c for c, _ in line["codes"])
|
||||
@@ -1480,8 +1495,9 @@ def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end") -
|
||||
def get_balance_sheet(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初"""
|
||||
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初(账套隔离 2026-08-31)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
prev_period = _prev_period_str(period)
|
||||
@@ -1497,8 +1513,8 @@ def get_balance_sheet(
|
||||
sec_end = sec_begin = 0.0
|
||||
sec_real = False
|
||||
for line in sec["lines"]:
|
||||
end = _bs_line_amount(db, line, period, column="end")
|
||||
begin = _bs_line_amount(db, line, prev_period, column="begin")
|
||||
end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id)
|
||||
begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id)
|
||||
if end["is_demo"] or begin["is_demo"]:
|
||||
all_real = False
|
||||
if end["value"] is not None:
|
||||
@@ -1588,14 +1604,14 @@ CF_DEMO_FX = 0 # 汇率变动对现金的影响
|
||||
CF_DEMO_BEGIN = 1200 # 期初现金及现金等价物余额
|
||||
|
||||
|
||||
def _get_cf_amount(db: Session, line: dict, period: str) -> dict:
|
||||
"""现金流量表行项目 — 优先KPI/凭证,否则示例数据"""
|
||||
def _get_cf_amount(db: Session, line: dict, period: str, entity_id: int = 1) -> dict:
|
||||
"""现金流量表行项目 — 优先KPI/凭证,否则示例数据(账套隔离 2026-08-31)"""
|
||||
# 经营净额行特殊处理:优先取 F_OP_CFLOW
|
||||
if line.get("kpi_code"):
|
||||
kpi_val = _get_kpi_val(db, line["kpi_code"], period)
|
||||
kpi_val = _get_kpi_val(db, line["kpi_code"], period, entity_id)
|
||||
if kpi_val is not None:
|
||||
return {"value": round(kpi_val, 2), "is_demo": False}
|
||||
real = _get_bs_amount(db, [(line["code"], line["sign"])], period)
|
||||
real = _get_bs_amount(db, [(line["code"], line["sign"])], period, entity_id)
|
||||
if real is not None:
|
||||
return {"value": real, "is_demo": False}
|
||||
demo = CF_DEMO.get(line["code"])
|
||||
@@ -1608,8 +1624,9 @@ def _get_cf_amount(db: Session, line: dict, period: str) -> dict:
|
||||
def get_cash_flow_statement(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""现金流量表 — 经营/投资/筹资三活动(新30号准则直接法)"""
|
||||
"""现金流量表 — 经营/投资/筹资三活动(新30号准则直接法)(账套隔离 2026-08-31)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
@@ -1630,7 +1647,7 @@ def get_cash_flow_statement(
|
||||
for line in CASH_FLOW_LINES:
|
||||
if line["section"] != sc["key"]:
|
||||
continue
|
||||
v = _get_cf_amount(db, line, period)
|
||||
v = _get_cf_amount(db, line, period, entity_id)
|
||||
if v["is_demo"]:
|
||||
all_real = False
|
||||
if v["value"] is not None:
|
||||
@@ -1653,12 +1670,12 @@ def get_cash_flow_statement(
|
||||
})
|
||||
|
||||
# 经营净额行优先取 KPI F_OP_CFLOW(真实数据优先)
|
||||
op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period)
|
||||
op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period, entity_id)
|
||||
if op_kpi is not None:
|
||||
sections[0]["net"] = round(op_kpi, 2)
|
||||
net_by_section["operating"] = round(op_kpi, 2)
|
||||
|
||||
fx = _get_kpi_val(db, "F_FX_LOSS", period)
|
||||
fx = _get_kpi_val(db, "F_FX_LOSS", period, entity_id)
|
||||
if fx is None:
|
||||
fx = CF_DEMO_FX
|
||||
fx_demo = True
|
||||
@@ -1690,6 +1707,7 @@ def get_cash_flow_statement(
|
||||
def get_statutory_reports(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""对外法定报表(新30号准则)— 利润表+资产负债表+现金流量表 组合视图"""
|
||||
if period is None:
|
||||
@@ -1697,9 +1715,9 @@ def get_statutory_reports(
|
||||
return {
|
||||
"period": period,
|
||||
"title": f"对外法定报表 — 新30号准则({period})",
|
||||
"profit": _build_new_format_profit(db, period),
|
||||
"balance_sheet": get_balance_sheet(period=period, db=db),
|
||||
"cash_flow": get_cash_flow_statement(period=period, db=db),
|
||||
"profit": _build_new_format_profit(db, period, entity_id),
|
||||
"balance_sheet": get_balance_sheet(period=period, db=db, entity_id=entity_id),
|
||||
"cash_flow": get_cash_flow_statement(period=period, db=db, entity_id=entity_id),
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
@@ -1708,11 +1726,12 @@ def get_statutory_reports(
|
||||
def export_statutory_reports(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""导出对外法定报表(新30号准则)— Excel 三表合一"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
data = get_statutory_reports(period=period, db=db)
|
||||
data = get_statutory_reports(period=period, db=db, entity_id=entity_id)
|
||||
|
||||
from io import BytesIO
|
||||
from openpyxl import Workbook
|
||||
@@ -1869,7 +1888,11 @@ def get_dupont_analysis(
|
||||
entity: str = Query("bohai"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""杜邦分析 — ROE三级拆解 (CMA P2)"""
|
||||
"""杜邦分析 — ROE三级拆解 (CMA P2)
|
||||
豁免多租户隔离(2026-08-31):跨实体对比分析端点,entity 参数显式指定
|
||||
分析对象(bohai→entity 2 / hanke→entity 1),非默认全库查询,故不叠加
|
||||
Depends(get_entity_id)(叠加会导致 token 绑定的 entity 与显式 entity 参数
|
||||
不一致时被 403 拦截,破坏跨企业对比功能)"""
|
||||
if entity == "bohai":
|
||||
# 博海标准KPI(F_REVENUE/F_NET_PROFIT)无verified值 → 优先DB读,读不到回退文档确认常量
|
||||
net_profit = _get_dupont_kpi(db, 2, "F_NET_PROFIT")
|
||||
@@ -2060,9 +2083,10 @@ def _get_month_period_prefix(period: str) -> str:
|
||||
return f"{y}-{m:02d}"
|
||||
|
||||
|
||||
def _fetch_kpi_data(db: Session) -> list:
|
||||
"""获取所有活跃KPI的当前值、目标值、维度、预警"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
def _fetch_kpi_data(db: Session, entity_id: int = 1) -> list:
|
||||
"""获取当前企业所有活跃KPI的当前值、目标值、维度、预警(账套隔离 2026-08-31)"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||
result = []
|
||||
for k in kpis:
|
||||
latest = db.query(KPIValue).filter(
|
||||
@@ -2071,6 +2095,7 @@ def _fetch_kpi_data(db: Session) -> list:
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.created_at.desc()).all()
|
||||
@@ -2094,9 +2119,9 @@ def _fetch_kpi_data(db: Session) -> list:
|
||||
return result
|
||||
|
||||
|
||||
def _build_weekly_report(db: Session, period: str) -> dict:
|
||||
"""生成周报"""
|
||||
kpis = _fetch_kpi_data(db)
|
||||
def _build_weekly_report(db: Session, period: str, entity_id: int = 1) -> dict:
|
||||
"""生成周报(账套隔离 2026-08-31)"""
|
||||
kpis = _fetch_kpi_data(db, entity_id)
|
||||
monday, sunday = _calc_week_range(period)
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
@@ -2104,6 +2129,7 @@ def _build_weekly_report(db: Session, period: str) -> dict:
|
||||
from datetime import timedelta
|
||||
seven_days_ago = datetime.now() - timedelta(days=7)
|
||||
recent_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.created_at >= seven_days_ago,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.created_at.desc()).all()
|
||||
@@ -2189,6 +2215,7 @@ def _build_weekly_report(db: Session, period: str) -> dict:
|
||||
"## 四、改进行动",
|
||||
])
|
||||
actions = db.query(ActionPlan).filter(
|
||||
ActionPlan.entity_id == entity_id,
|
||||
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||
).order_by(ActionPlan.created_at.desc()).limit(5).all()
|
||||
if actions:
|
||||
@@ -2248,9 +2275,9 @@ def _build_weekly_report(db: Session, period: str) -> dict:
|
||||
return {"markdown": markdown, "json": json_data, "title": f"经营分析周报 {monday}~{sunday}"}
|
||||
|
||||
|
||||
def _build_monthly_report(db: Session, period: str) -> dict:
|
||||
"""生成月报"""
|
||||
kpis = _fetch_kpi_data(db)
|
||||
def _build_monthly_report(db: Session, period: str, entity_id: int = 1) -> dict:
|
||||
"""生成月报(账套隔离 2026-08-31)"""
|
||||
kpis = _fetch_kpi_data(db, entity_id)
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
prev_period = _get_month_period_prefix(period)
|
||||
|
||||
@@ -2287,6 +2314,7 @@ def _build_monthly_report(db: Session, period: str) -> dict:
|
||||
|
||||
# 预警汇总
|
||||
pending_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
).all()
|
||||
red_count = sum(1 for a in pending_alerts if a.alert_level == "red")
|
||||
@@ -2309,7 +2337,7 @@ def _build_monthly_report(db: Session, period: str) -> dict:
|
||||
dim_summary[d]["failed"] += 1
|
||||
|
||||
# 改善行动
|
||||
actions = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(5).all()
|
||||
actions = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).order_by(ActionPlan.created_at.desc()).limit(5).all()
|
||||
|
||||
# ── 生成 Markdown ──
|
||||
md_lines = [
|
||||
@@ -2420,9 +2448,9 @@ def _build_monthly_report(db: Session, period: str) -> dict:
|
||||
return {"markdown": markdown, "json": json_data, "title": f"经营分析月报 {period}"}
|
||||
|
||||
|
||||
def _build_special_report(db: Session, period: str, alert_ref: str = None) -> dict:
|
||||
"""生成专项分析报告 — 聚焦KPI异常"""
|
||||
kpis = _fetch_kpi_data(db)
|
||||
def _build_special_report(db: Session, period: str, alert_ref: str = None, entity_id: int = 1) -> dict:
|
||||
"""生成专项分析报告 — 聚焦KPI异常(账套隔离 2026-08-31)"""
|
||||
kpis = _fetch_kpi_data(db, entity_id)
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 按偏差率排序(当前值/目标值)
|
||||
@@ -2597,6 +2625,7 @@ def generate_report(
|
||||
req: GenerateReportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""生成经营分析报告(周报/月报/专项),返回markdown+JSON
|
||||
|
||||
@@ -2614,9 +2643,9 @@ def generate_report(
|
||||
|
||||
# 生成报告
|
||||
builders = {
|
||||
"weekly": lambda db, period: _build_weekly_report(db, period),
|
||||
"monthly": lambda db, period: _build_monthly_report(db, period),
|
||||
"special": lambda db, period: _build_special_report(db, period, alert_ref=req.alert_ref),
|
||||
"weekly": lambda db, period: _build_weekly_report(db, period, entity_id),
|
||||
"monthly": lambda db, period: _build_monthly_report(db, period, entity_id),
|
||||
"special": lambda db, period: _build_special_report(db, period, alert_ref=req.alert_ref, entity_id=entity_id),
|
||||
}
|
||||
builder = builders[req.report_type]
|
||||
|
||||
@@ -2626,8 +2655,9 @@ def generate_report(
|
||||
logger.error(f"报告生成异常: {e}", exc_info=True)
|
||||
raise HTTPException(500, f"报告生成失败: {str(e)}")
|
||||
|
||||
# 保存到数据库
|
||||
# 保存到数据库(账套隔离 2026-08-31)
|
||||
record = ReportHistory(
|
||||
entity_id=entity_id,
|
||||
report_type=req.report_type,
|
||||
period=period,
|
||||
title=report_data["title"],
|
||||
@@ -2672,9 +2702,10 @@ def list_report_history(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""查看报告生成历史"""
|
||||
query = db.query(ReportHistory).order_by(ReportHistory.created_at.desc())
|
||||
"""查看报告生成历史(账套隔离 2026-08-31)"""
|
||||
query = db.query(ReportHistory).filter(ReportHistory.entity_id == entity_id).order_by(ReportHistory.created_at.desc())
|
||||
if report_type:
|
||||
query = query.filter(ReportHistory.report_type == report_type)
|
||||
records = query.limit(limit).all()
|
||||
@@ -2701,9 +2732,10 @@ def get_report_detail(
|
||||
report_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取单条报告详情(含完整markdown内容)"""
|
||||
r = db.query(ReportHistory).filter(ReportHistory.id == report_id).first()
|
||||
"""获取单条报告详情(含完整markdown内容,账套隔离 2026-08-31)"""
|
||||
r = db.query(ReportHistory).filter(ReportHistory.id == report_id, ReportHistory.entity_id == entity_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报告不存在")
|
||||
|
||||
@@ -2781,7 +2813,7 @@ def _find_kpi_by_code(db: Session, kpi_code: Optional[str], entity_id: int):
|
||||
).first()
|
||||
|
||||
|
||||
def _proforma_budget(db: Session, kpi_id: int, period: str, version: Optional[str] = None):
|
||||
def _proforma_budget(db: Session, kpi_id: int, period: str, version: Optional[str] = None, entity_id: int = 1):
|
||||
"""预编报表预算取数:budget_plan → target_split → none
|
||||
|
||||
与 calc_period_deviation 口径一致(无预算时用 KPI 目标值按月分摊)。
|
||||
@@ -2818,8 +2850,8 @@ def _proforma_deviation(actual: Optional[float], budget: Optional[float], ratio_
|
||||
return calc_deviation(actual, budget)
|
||||
|
||||
|
||||
def _proforma_cf_actual(db: Session, line: dict, period: str) -> Optional[float]:
|
||||
"""现金流量表行项目实际值 — 真实数据优先(KPI → 凭证),不塞 demo 数据"""
|
||||
def _proforma_cf_actual(db: Session, line: dict, period: str, entity_id: int = 1) -> Optional[float]:
|
||||
"""现金流量表行项目实际值 — 真实数据优先(KPI → 凭证),不塞 demo 数据(账套隔离 2026-08-31)"""
|
||||
if line.get("kpi_code"):
|
||||
v = _get_kpi_val(db, line["kpi_code"], period)
|
||||
if v is not None:
|
||||
@@ -2861,7 +2893,7 @@ def get_proforma_profit_statement(
|
||||
for item_cfg in block_cfg["items"]:
|
||||
code = item_cfg["code"]
|
||||
kpi_code = PROFIT_SUBJECT_KPI_MAP.get(code)
|
||||
actual = _get_subject_amount(db, code, period)
|
||||
actual = _get_subject_amount(db, code, period, entity_id)
|
||||
budget, source, ver = None, "none", None
|
||||
kpi = _find_kpi_by_code(db, kpi_code, entity_id) if kpi_code else None
|
||||
if kpi:
|
||||
@@ -3053,7 +3085,7 @@ def get_proforma_cash_flow(
|
||||
if sc["key"] == "operating":
|
||||
op_kpi = _find_kpi_by_code(db, "F_OP_CFLOW", entity_id)
|
||||
if op_kpi:
|
||||
op_actual = _get_kpi_val(db, "F_OP_CFLOW", period)
|
||||
op_actual = _get_kpi_val(db, "F_OP_CFLOW", period, entity_id)
|
||||
if op_actual is not None:
|
||||
net_actual = round(float(op_actual), 2)
|
||||
op_budget, op_source, op_ver = _proforma_budget(db, op_kpi.id, period, version)
|
||||
|
||||
Reference in New Issue
Block a user