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

This commit is contained in:
Hermes CI Fix
2026-09-21 10:36:02 +08:00
parent a0762b367f
commit 42ff836309
9 changed files with 906 additions and 115 deletions
+23 -7
View File
@@ -150,9 +150,12 @@ def update_budget_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
"""更新预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(BudgetPlan).filter(
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
@@ -173,9 +176,12 @@ def update_budget_plan(
def delete_budget_plan(
plan_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""删除预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
"""删除预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(BudgetPlan).filter(
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
db.delete(plan)
@@ -311,7 +317,9 @@ def auto_decompose_budget(
if annual_budget is None:
raise HTTPException(400, "缺少必要参数: annual_budget(单KPI分解需传年度预算值)")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -338,6 +346,7 @@ def auto_decompose_budget(
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
@@ -347,6 +356,7 @@ def auto_decompose_budget(
existing.budget_value = monthly_value
else:
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, period=period,
budget_value=monthly_value, budget_year=year,
budget_month=m, version=version, status="active",
@@ -362,6 +372,7 @@ def auto_decompose_budget(
period = f"{year}-{m:02d}"
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
@@ -370,6 +381,7 @@ def auto_decompose_budget(
existing.budget_value = monthly
else:
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, period=period,
budget_value=monthly, budget_year=year,
budget_month=m, version=version, status="active",
@@ -1119,10 +1131,14 @@ def update_deviation_alert(
alert_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新偏差预警(如标记已解决)"""
"""更新偏差预警(如标记已解决)(账套隔离 2026-09-21:跨账套 id → 404)"""
from app.models import BudgetDeviationAlert
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == alert_id).first()
alert = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.id == alert_id,
BudgetDeviationAlert.entity_id == entity_id,
).first()
if not alert:
raise HTTPException(404, "预警记录不存在")
if "status" in data:
+36 -10
View File
@@ -177,9 +177,19 @@ def list_quality_logs(
check_type: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取数据质量日志"""
query = db.query(KpiDataQualityLog)
"""获取数据质量日志(账套隔离 2026-09-21)
kpi_data_quality_log 表无 entity_id 列(见本文件 :235 说明),按本单选项(1)
经 kpi_id join kpi_definitions 以 kpi_definitions.entity_id 收口(零迁移)。
join 为内连接:kpi_id 无对应 KPI 行的孤儿日志无法归属账套,一律不返回。
"""
query = (
db.query(KpiDataQualityLog)
.join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id)
.filter(KPIDefinition.entity_id == entity_id)
)
if kpi_id:
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
if severity:
@@ -201,10 +211,22 @@ def list_quality_logs(
return {"data": result, "total": len(result)}
def _scoped_quality_log(db: Session, log_id: int, entity_id: int):
"""按会话账套取质量日志行(表无 entity_id 列 → 经 kpi_id join kpi_definitions 归属)"""
return (
db.query(KpiDataQualityLog)
.join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id)
.filter(KpiDataQualityLog.id == log_id,
KPIDefinition.entity_id == entity_id)
.first()
)
@router.put("/logs/{log_id}")
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""更新质量日志(解决/忽略)"""
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""更新质量日志(解决/忽略)(账套隔离 2026-09-21:跨账套 → 404)"""
log = _scoped_quality_log(db, log_id, entity_id)
if not log:
raise HTTPException(404, "日志不存在")
if "status" in data:
@@ -218,14 +240,18 @@ def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), u
@router.delete("/logs/{log_id}")
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
if log:
db.delete(log)
db.commit()
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""删除质量日志(账套隔离 2026-09-21:跨账套 → 404;不存在 → 404,对齐 PUT 语义)"""
log = _scoped_quality_log(db, log_id, entity_id)
if not log:
raise HTTPException(404, "日志不存在")
db.delete(log)
db.commit()
return {"message": "已删除"}
# ============================================================
# 数据质量看板统计
# ============================================================
+26 -13
View File
@@ -404,8 +404,9 @@ def list_reimbursements(
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
q = db.query(ExpenseReimbursement)
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)(账套隔离 2026-09-21:列表按会话账套)"""
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.entity_id == entity_id)
if status:
q = q.filter(ExpenseReimbursement.status == status)
if expense_type:
@@ -427,20 +428,29 @@ def list_reimbursements(
def get_reimbursement(
reimb_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""报销单详情"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
"""报销单详情(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
if not r:
raise HTTPException(404, "报销单不存在")
return _reimb_to_dict(r)
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
"""执行审批动作: approve/reject/return"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str,
entity_id: int = None):
"""执行审批动作: approve/reject/return(账套隔离 2026-09-21:跨账套 → 404)"""
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id)
if entity_id is not None:
q = q.filter(ExpenseReimbursement.entity_id == entity_id)
r = q.first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("pending", "returned"):
raise HTTPException(400, f"当前状态({r.status})不可审批")
@@ -464,10 +474,11 @@ def approve_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""审批通过"""
"""审批通过(账套隔离 2026-09-21:跨账套 → 404)"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver, entity_id)
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
@@ -477,13 +488,14 @@ def reject_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""审批拒绝"""
"""审批拒绝(账套隔离 2026-09-21:跨账套 → 404)"""
comment = (data or {}).get("comment", "")
if not comment:
raise HTTPException(400, "拒绝时必须填写审批意见")
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "reject", comment, approver)
r = _do_approve(db, reimb_id, "reject", comment, approver, entity_id)
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
@@ -493,10 +505,11 @@ def return_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""人工打回"""
"""人工打回(账套隔离 2026-09-21:跨账套 → 404)"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver, entity_id)
return {"message": "已打回", "data": _reimb_to_dict(r)}
+15 -4
View File
@@ -918,9 +918,16 @@ def _log(db, user_id, action, target_type, target_id, detail):
@router.get("/{kpi_id}/objectives")
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
"""查看KPI所属的目标和战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查看KPI所属的目标和战略地图(账套隔离 2026-09-21:跨账套 id → 404)
鉴权口径:本 router 级 `dependencies=[Depends(require_role(...))]` 已覆盖本端点
(无 token → 401),此处不再重复挂 require_auth,只补缺失的账套收口。
"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -933,15 +940,19 @@ def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
}
if kpi.map_id:
m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first()
m = db.query(StrategicMap).filter(
StrategicMap.id == kpi.map_id, StrategicMap.entity_id == entity_id,
).first()
if m:
result["map"] = {"id": m.id, "title": m.title, "status": m.status}
if kpi.objective:
objs = db.query(MapObjective).filter(
MapObjective.map_id == kpi.map_id,
MapObjective.entity_id == entity_id,
MapObjective.name == kpi.objective,
).all()
result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs]
return result
+155 -68
View File
@@ -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,
+6 -2
View File
@@ -831,7 +831,7 @@ def seed_demo_data(
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
if exists:
continue
db.add(ExpenseReimbursement(**d))
db.add(ExpenseReimbursement(entity_id=eid, **d))
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
invoice_demo = [
@@ -854,7 +854,11 @@ def seed_demo_data(
)
if exists:
continue
inv = InvoiceCheck(entity_id=eid, **d)
inv_data = dict(d)
# 演示日期按既有写入口径解析为 datetime(字符串日期在 SQLite 下无法绑定 DateTime 列;
# MySQL 表现不变——同文件 :402 正常写入路径亦经 _parse_date)
inv_data["invoice_date"] = _parse_date(inv_data.get("invoice_date"))
inv = InvoiceCheck(entity_id=eid, **inv_data)
_check_invoice(db, inv)
db.add(inv)
created["invoice"] += 1