Files
cma-management/backend/app/api/alerts.py
T
Hermes CI Fix d03e322a81 fix(cma): action plan entity_id at the three residual construction sites
alert-to-plan, deviation-push review topic and AI-suggestion-to-plan built ActionPlan(...) without entity_id, so non-e1 sessions wrote rows with the model default 1 and entity_id stayed untrustworthy on those paths. Write the session entity at all three sites, scope the deviation-push topic dedupe by entity (a foreign same-title topic no longer suppresses the session entity's own topic), and require the applied suggestion to belong to the session entity. Adds 7 regression tests (both directions + cross-entity negative controls + auth floor).
2026-09-18 20:12:46 +08:00

354 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""预警 API"""
from fastapi import APIRouter, Depends, Query, HTTPException, Request
from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
import logging
logger = logging.getLogger("cma.alerts")
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
@router.get("")
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b)
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id)
if status:
query = query.filter(KPIAlert.status == status)
total = query.count()
alerts = query.order_by(KPIAlert.created_at.desc()).offset((page-1)*20).limit(20).all()
return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]}
@router.post("/{alert_id}/resolve")
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
alert.status = "resolved"
alert.resolution = data.get("resolution", "")
alert.assignee = data.get("assignee", alert.assignee)
from datetime import datetime; alert.resolved_at = datetime.now()
db.commit()
db.refresh(alert)
return {
"message": "已处理",
"assignee": alert.assignee,
"alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns},
"suggest_create_action_plan": alert.alert_level == "red",
}
@router.post("/{alert_id}/process")
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""标记预警为处理中(账套隔离: join kpi_definitions 按企业过滤, 2026-09-18)"""
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.status == "resolved":
raise HTTPException(400, "已处理的预警不能重复处理")
assignee = data.get("assignee")
if not assignee:
raise HTTPException(400, "缺少处理人")
alert.status = "processing"
alert.assignee = assignee
db.commit()
db.refresh(alert)
return {"message": "已标记为处理中", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
@router.post("/{alert_id}/escalate")
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""升级预警级别(账套隔离: join kpi_definitions 按企业过滤, 2026-09-18)"""
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
assignee = data.get("assignee")
if not assignee:
raise HTTPException(400, "缺少处理人")
if alert.status == "resolved":
raise HTTPException(400, "已处理的预警不能升级")
if alert.alert_level != "red":
alert.alert_level = "red"
alert.assignee = assignee
db.commit()
db.refresh(alert)
return {"message": "已升级", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
@router.get("/check-timeout")
def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色
(账套隔离: join kpi_definitions 按企业过滤, 2026-09-18 — 原为全账套批处理)"""
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
timeout_alerts = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
KPIDefinition.entity_id == entity_id,
KPIAlert.status == "pending",
KPIAlert.created_at < cutoff,
KPIAlert.alert_level != "red",
).all()
upgraded_count = 0
for alert in timeout_alerts:
alert.alert_level = "red"
alert.status = "processing"
upgraded_count += 1
if upgraded_count:
db.commit()
return {"total_timeout": len(timeout_alerts), "upgraded_count": upgraded_count}
@router.post("/{alert_id}/create-action-plan")
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""从预警创建改善行动计划(账套隔离: join kpi_definitions 按企业过滤, 2026-09-18)"""
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.action_plan_linked_id:
raise HTTPException(400, "已关联改善计划")
plan = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
alert_id=alert.id,
kpi_id=alert.kpi_id,
title=f"改善: {alert.alert_message}",
assignee=data.get("assignee", ""),
priority="high" if alert.alert_level == "red" else "medium",
created_by=data.get("created_by", ""),
)
db.add(plan)
db.commit()
db.refresh(plan)
alert.action_plan_linked_id = plan.id
db.commit()
return {"message": "改善行动计划已创建", "plan_id": plan.id, "priority": plan.priority}
# ──────────────────────────────────────────────
# 功能4: 风险矩阵热力图 (CMA P2 - ERM框架、风险识别四象限)
RISK_MATRIX_DATA = {
"hanke": {
"entity_name": "陕西酣客(白酒经销)",
"quadrants": [
{
"impact": "high",
"probability": "high",
"label": "高影响×高概率",
"risks": [
{
"id": "risk_001",
"name": "流动性风险",
"impact_label": "高",
"probability_label": "高",
"detail": "现金2.2万 vs 短债350万 → 断流风险",
"impact_value": 90,
"probability_value": 85,
"type": "red",
"measures": ["催收大额应收", "协商短期借款续贷"],
"responsible": "任富海",
"deadline": "7月底",
},
{
"id": "risk_002",
"name": "合规风险",
"impact_label": "高",
"probability_label": "高",
"detail": "欠税426万 · 折旧违规",
"impact_value": 95,
"probability_value": 80,
"type": "red",
"measures": ["补缴欠税计划", "重新梳理折旧政策"],
"responsible": "任富海",
"deadline": "8月底",
},
],
},
{
"impact": "high",
"probability": "medium",
"label": "高影响×中概率",
"risks": [
{
"id": "risk_003",
"name": "政策风险",
"impact_label": "高",
"probability_label": "中",
"detail": "白酒消费税调整可能导致成本上升15-20%",
"impact_value": 85,
"probability_value": 50,
"type": "orange",
"measures": ["关注政策动向", "预留税务缓冲资金"],
"responsible": "财务部",
"deadline": "持续关注",
},
],
},
{
"impact": "medium",
"probability": "high",
"label": "中影响×高概率",
"risks": [
{
"id": "risk_004",
"name": "运营风险",
"impact_label": "中",
"probability_label": "高",
"detail": "Model C 成本模型未落地,成本核算偏差",
"impact_value": 60,
"probability_value": 80,
"type": "yellow",
"measures": ["推动Model C落地", "建立成本标准化流程"],
"responsible": "财务部",
"deadline": "8月中",
},
],
},
{
"impact": "medium",
"probability": "medium",
"label": "中影响×中概率",
"risks": [
{
"id": "risk_005",
"name": "战略风险",
"impact_label": "中",
"probability_label": "中",
"detail": "酒类零交易,新业务方向不确定",
"impact_value": 55,
"probability_value": 55,
"type": "yellow",
"measures": ["制定新业务评估框架", "定期战略复盘"],
"responsible": "管理层",
"deadline": "9月底",
},
],
},
{
"impact": "low",
"probability": "low",
"label": "低影响×低概率",
"risks": [
{
"id": "risk_006",
"name": "市场风险",
"impact_label": "低",
"probability_label": "低",
"detail": "行业需求波动,但酣客已基本退出市场",
"impact_value": 25,
"probability_value": 20,
"type": "green",
"measures": ["定期监控行业数据"],
"responsible": "业务部",
"deadline": "每季度",
},
{
"id": "risk_007",
"name": "人员风险",
"impact_label": "低",
"probability_label": "低",
"detail": "核心团队稳定,短期内无流失风险",
"impact_value": 20,
"probability_value": 15,
"type": "green",
"measures": ["保持团队激励", "关键岗位备份"],
"responsible": "人事部",
"deadline": "持续",
},
],
},
],
},
"bohai": {
"entity_name": "陕西博海科技(IT服务)",
"quadrants": [
{
"impact": "high",
"probability": "medium",
"label": "高影响×中概率",
"risks": [
{
"id": "risk_b_001",
"name": "现金流风险",
"impact_label": "高",
"probability_label": "中",
"detail": "应收账款账期延长,现金流紧张",
"impact_value": 85,
"probability_value": 55,
"type": "orange",
"measures": ["加快应收催收", "建立信用管理制度"],
"responsible": "任富海",
"deadline": "7月底",
},
],
},
{
"impact": "medium",
"probability": "high",
"label": "中影响×高概率",
"risks": [
{
"id": "risk_b_002",
"name": "项目交付风险",
"impact_label": "中",
"probability_label": "高",
"detail": "多个项目并行,交付压力大",
"impact_value": 65,
"probability_value": 75,
"type": "yellow",
"measures": ["优化项目排期", "增加外包资源"],
"responsible": "项目部",
"deadline": "持续",
},
],
},
{
"impact": "low",
"probability": "medium",
"label": "低影响×中概率",
"risks": [
{
"id": "risk_b_003",
"name": "技术迭代风险",
"impact_label": "低",
"probability_label": "中",
"detail": "新技术跟踪不及时,可能落后",
"impact_value": 30,
"probability_value": 45,
"type": "green",
"measures": ["定期技术培训", "技术栈评估"],
"responsible": "技术部",
"deadline": "每季度",
},
],
},
],
},
}
@router.get("/risk-matrix")
def get_risk_matrix(
request: Request,
entity: Optional[str] = Query(None, description="hanke/bohai(须与会话账套一致);缺省按会话账套"),
):
"""风险矩阵热力图数据 - CMA P2 ERM四象限
多租户(cma-cross-entity-endpoints-auth-20260914):
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
"""
slug = assert_entity_slug_scope(request, entity)
if slug is None:
slug = resolve_session_slug(request, "hanke")
data = RISK_MATRIX_DATA.get(slug)
if not data:
raise HTTPException(404, f"实体 entity={slug} 无风险矩阵数据")
return data