fix(cma): entity isolation batch5 (budget writes, kpi objectives scope, quality logs join, growth-quality db source, expenses scope)
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
This commit is contained in:
+155
-68
@@ -10,7 +10,7 @@ from app.utils.cash_forecast_engine import (
|
||||
calculate_accuracy, generate_scenario_suggestion,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request
|
||||
from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope
|
||||
from app.auth_middleware import require_auth_401
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -724,95 +724,182 @@ def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
|
||||
return indicators
|
||||
|
||||
|
||||
# ── 增长质量诊断取数(账套隔离 2026-09-21)────────────────────────
|
||||
# 输入 ← 库内 KPI 实际值(kpi_values),编码口径沿用 kpi_definitions 既有编码
|
||||
# (同 app/api/growth_quality.py 的 KPI_CODES),不新增计算口径。
|
||||
_GQ_KPI_CODE_MAP = {
|
||||
"C_REBATE_RATE": "rebateRate", # 渠补率(%)
|
||||
"F_GROSS_MARGIN": "trueGrossMargin", # 毛利率(%)
|
||||
"F_REVENUE_GROWTH": "revenueGrowthRate", # 收入增长率(%)
|
||||
}
|
||||
|
||||
_GQ_DIM_SCORERS = {
|
||||
"revenueStructure": _score_revenue_structure,
|
||||
"profitStructure": _score_profit_structure,
|
||||
"cashAssets": _score_cash_assets,
|
||||
"growthDriver": _score_growth_driver,
|
||||
"orgEfficiency": _score_org_efficiency,
|
||||
}
|
||||
|
||||
# 各维度所需输入键。缺任一输入 → 该维度不评分(避免用缺省 0 编造低分)
|
||||
_GQ_DIM_INPUTS = {
|
||||
"revenueStructure": ("rebateRate",),
|
||||
"profitStructure": ("trueGrossMargin",),
|
||||
"cashAssets": ("cashRatio",),
|
||||
"growthDriver": ("expenseGrowthRate", "revenueGrowthRate"),
|
||||
"orgEfficiency": ("mgmtRatio",),
|
||||
}
|
||||
|
||||
|
||||
def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
|
||||
"""按会话账套从库内 KPI 实际值取增长质量输入。
|
||||
|
||||
返回 (entity_dict|None, collected):
|
||||
- entity_dict 只含库内真实取到的指标(缺的键不填,绝不用请求体/硬编码兜底)
|
||||
- collected = 本账套是否存在增长质量相关 KPI 实际值
|
||||
"""
|
||||
from app.models import Entity, KPIDefinition, KPIValue
|
||||
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not ent:
|
||||
return None, False
|
||||
q = (
|
||||
db.query(KPIDefinition.kpi_code, KPIValue.actual_value, KPIValue.period)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(list(_GQ_KPI_CODE_MAP.keys())),
|
||||
KPIValue.actual_value.isnot(None))
|
||||
)
|
||||
if period:
|
||||
q = q.filter(KPIValue.period == period)
|
||||
rows = q.order_by(KPIValue.period.desc()).all()
|
||||
|
||||
entity = {"entity": ent.name}
|
||||
seen = set()
|
||||
for code, value, _period in rows:
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
entity[_GQ_KPI_CODE_MAP[code]] = float(value)
|
||||
return entity, bool(seen)
|
||||
|
||||
|
||||
def _growth_quality_score(entity: dict):
|
||||
"""计算「有输入的维度」评分 + 明细 + 缺失维度清单(缺输入的维度不评分)。"""
|
||||
scores, detail, missing = {}, {}, []
|
||||
for dim, inputs in _GQ_DIM_INPUTS.items():
|
||||
if any(k not in entity for k in inputs):
|
||||
missing.append(dim)
|
||||
detail[dim] = {"score": None, "data_missing": True,
|
||||
"indicators": [], "suggestions": []}
|
||||
continue
|
||||
score = _GQ_DIM_SCORERS[dim](entity)
|
||||
scores[dim] = score
|
||||
detail[dim] = {
|
||||
"score": score,
|
||||
"indicators": _get_dim_detail_indicators(dim, entity),
|
||||
"suggestions": _generate_improvement_suggestions(dim, score, entity),
|
||||
}
|
||||
return scores, detail, missing
|
||||
|
||||
|
||||
def _gq_level_of(overall: float):
|
||||
if overall is None:
|
||||
return None, None
|
||||
if overall >= 4:
|
||||
return "🟢 越增长越轻", "excellent"
|
||||
if overall >= 3:
|
||||
return "🟡 增长质量中等", "medium"
|
||||
if overall >= 2:
|
||||
return "🟠 需关注", "warning"
|
||||
return "🔴 越增长越重", "danger"
|
||||
|
||||
|
||||
@router.post("/growth-quality")
|
||||
def api_growth_quality(request: Request, data: dict):
|
||||
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
|
||||
def api_growth_quality(request: Request, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""增长质量诊断 — 五维度评分+综合评分+诊断结论(账套隔离 2026-09-21)
|
||||
|
||||
取数:按会话账套从库内 KPI 实际值取,移除请求体 `data.get("entity", data)` 回退
|
||||
(原回退可用客户端可控的「经营数据」伪造诊断结论);库内无数据时如实返回
|
||||
collected=false + 空维度,不编造评分。请求体显式 entity_id ≠ 会话账套 → 403。
|
||||
"""
|
||||
try:
|
||||
entity_id = resolve_entity_for_request(request, data.get("entity_id"))
|
||||
ENTITY_DATA = {
|
||||
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447},
|
||||
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4},
|
||||
}
|
||||
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
|
||||
entity_name = entity.get("entity", entity.get("name", "该企业"))
|
||||
period = entity.get("period", data.get("period", "当前"))
|
||||
data = data or {}
|
||||
# 显式 body entity_id 与会话账套不一致 → 403(文案同 app/deps.py)
|
||||
assert_entity_scope(request, data.get("entity_id"), db)
|
||||
period = data.get("period")
|
||||
entity, collected = _growth_quality_entity_from_db(db, entity_id, period)
|
||||
if entity is None:
|
||||
raise HTTPException(404, f"实体 {entity_id} 不存在")
|
||||
entity_name = entity.get("entity", "该企业")
|
||||
period_label = period or "当前"
|
||||
|
||||
# 五维度评分
|
||||
dim_scores = {
|
||||
"revenueStructure": _score_revenue_structure(entity),
|
||||
"profitStructure": _score_profit_structure(entity),
|
||||
"cashAssets": _score_cash_assets(entity),
|
||||
"growthDriver": _score_growth_driver(entity),
|
||||
"orgEfficiency": _score_org_efficiency(entity),
|
||||
}
|
||||
|
||||
overall = round(sum(dim_scores.values()) / 5, 1)
|
||||
|
||||
# 综合等级
|
||||
if overall >= 4:
|
||||
level = "🟢 越增长越轻"
|
||||
level_type = "excellent"
|
||||
elif overall >= 3:
|
||||
level = "🟡 增长质量中等"
|
||||
level_type = "medium"
|
||||
elif overall >= 2:
|
||||
level = "🟠 需关注"
|
||||
level_type = "warning"
|
||||
else:
|
||||
level = "🔴 越增长越重"
|
||||
level_type = "danger"
|
||||
|
||||
# 诊断结论
|
||||
dimensions_payload = {}
|
||||
detail_payload = {}
|
||||
for dim, score in dim_scores.items():
|
||||
dimensions_payload[dim] = {"score": score, "weight": 20}
|
||||
detail_payload[dim] = {
|
||||
"score": score,
|
||||
"indicators": _get_dim_detail_indicators(dim, entity),
|
||||
"suggestions": _generate_improvement_suggestions(dim, score, entity),
|
||||
if not collected:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": entity_name,
|
||||
"period": period_label,
|
||||
"collected": False,
|
||||
"data_source": "kpi_values",
|
||||
"message": "该账套暂无增长质量相关 KPI 实际值(无数据不评分)",
|
||||
"overall": None,
|
||||
"level": None,
|
||||
"level_type": None,
|
||||
"dimensions": {},
|
||||
"detail": {},
|
||||
"dimensions_missing": list(_GQ_DIM_INPUTS.keys()),
|
||||
"missing_metrics": sorted(set(_GQ_KPI_CODE_MAP.values())),
|
||||
"diagnosis": None,
|
||||
"compare": None,
|
||||
}
|
||||
|
||||
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
|
||||
dim_scores, detail_payload, dims_missing = _growth_quality_score(entity)
|
||||
dimensions_payload = {dim: {"score": s, "weight": 20} for dim, s in dim_scores.items()}
|
||||
overall = round(sum(dim_scores.values()) / len(dim_scores), 1) if dim_scores else None
|
||||
level, level_type = _gq_level_of(overall)
|
||||
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name) if overall is not None else None
|
||||
|
||||
# 对比数据(如果请求中包含多个实体)
|
||||
compare = data.get("compare", None)
|
||||
# 对比:只接受账套内 compare_entity_id 并从库内取数(原请求体 compare 数据不再采信)
|
||||
compare_result = None
|
||||
if compare:
|
||||
compare_entity = compare
|
||||
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
|
||||
cdims = {
|
||||
"revenueStructure": _score_revenue_structure(compare_entity),
|
||||
"profitStructure": _score_profit_structure(compare_entity),
|
||||
"cashAssets": _score_cash_assets(compare_entity),
|
||||
"growthDriver": _score_growth_driver(compare_entity),
|
||||
"orgEfficiency": _score_org_efficiency(compare_entity),
|
||||
}
|
||||
compare_overall = round(sum(cdims.values()) / 5, 1)
|
||||
compare_result = {
|
||||
"entity_name": compare_name,
|
||||
"overall": compare_overall,
|
||||
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
|
||||
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
|
||||
"🟡 增长质量中等" if compare_overall >= 3 else
|
||||
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
|
||||
}
|
||||
compare_entity_id = data.get("compare_entity_id")
|
||||
if compare_entity_id:
|
||||
assert_entity_scope(request, int(compare_entity_id), db)
|
||||
c_entity, c_collected = _growth_quality_entity_from_db(db, int(compare_entity_id), period)
|
||||
if c_entity is not None and c_collected:
|
||||
cdims, _c_detail, _c_missing = _growth_quality_score(c_entity)
|
||||
if cdims:
|
||||
c_overall = round(sum(cdims.values()) / len(cdims), 1)
|
||||
compare_result = {
|
||||
"entity_id": int(compare_entity_id),
|
||||
"entity_name": c_entity.get("entity", "对比企业"),
|
||||
"overall": c_overall,
|
||||
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
|
||||
"level": _gq_level_of(c_overall)[0],
|
||||
}
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": entity_name,
|
||||
"period": period,
|
||||
"period": period_label,
|
||||
"collected": True,
|
||||
"data_source": "kpi_values",
|
||||
"overall": overall,
|
||||
"level": level,
|
||||
"level_type": level_type,
|
||||
"dimensions": dimensions_payload,
|
||||
"detail": detail_payload,
|
||||
"dimensions_missing": dims_missing,
|
||||
"diagnosis": diagnosis,
|
||||
"compare": compare_result,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
|
||||
|
||||
|
||||
|
||||
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
||||
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
||||
MODELS, forecast_kpi, forecast_finance_kpis,
|
||||
|
||||
Reference in New Issue
Block a user