1603 lines
65 KiB
Python
1603 lines
65 KiB
Python
"""战略地图 API"""
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
from typing import Optional
|
||
from datetime import date, datetime, timedelta
|
||
from app.database import get_db
|
||
from app.deps import get_entity_id
|
||
from app.auth_middleware import require_auth, require_role
|
||
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
||
from app.utils.kr_name import sanitize_kr_name
|
||
from app.utils.kpi_value_scope import actual_only_clause
|
||
import copy
|
||
import json
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── C1 数据前置(改造单 v2 §3,硬约束)──────────────────────────────
|
||
# C_CUST_CONCENTRATION 的 69 个实测点含 2026-10 未来期与重复值;清理完成前:
|
||
# ① 相关目标节点显示「数据待核」、**不着色**(level=pending);
|
||
# ② 该 KPI 的实测值/达成率不得进入摘要条、节点详情、趋势图(96.3% 零出口)。
|
||
# 清理单据落地后删除本表即可恢复;不要在别处另写白名单。
|
||
DATA_HOLD_KPIS = {
|
||
"C_CUST_CONCENTRATION": "数据待核:C1 实测点含未来期与重复值,清理未完成",
|
||
}
|
||
|
||
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||
)
|
||
|
||
# ── 四维度模板 ──────────────────────────────
|
||
STRATEGIC_MAP_TEMPLATE = [
|
||
{
|
||
"key": "finance",
|
||
"name": "财务层",
|
||
"icon": "💰",
|
||
"color": "#F56C6C",
|
||
"objectives": [
|
||
{"name": "营收目标", "kpis": ["F_REVENUE"]},
|
||
{"name": "净利润率", "kpis": ["F_NET_PROFIT"]},
|
||
{"name": "现金流", "kpis": ["F_OP_CFLOW"]},
|
||
],
|
||
},
|
||
{
|
||
"key": "customer",
|
||
"name": "客户层",
|
||
"icon": "👥",
|
||
"color": "#409EFF",
|
||
"objectives": [
|
||
{"name": "客户满意度", "kpis": ["C_SATISFACTION"]},
|
||
{"name": "市场份额", "kpis": ["C_MARKET_SHARE"]},
|
||
{"name": "客户保留率", "kpis": ["C_RETENTION_RATE"]},
|
||
],
|
||
},
|
||
{
|
||
"key": "process",
|
||
"name": "内部流程层",
|
||
"icon": "⚙️",
|
||
"color": "#67C23A",
|
||
"objectives": [
|
||
{"name": "运营效率", "kpis": ["P_DELIVERY"]},
|
||
{"name": "质量合格率", "kpis": ["P_PASS_RATE"]},
|
||
],
|
||
},
|
||
{
|
||
"key": "learning",
|
||
"name": "学习成长层",
|
||
"icon": "📚",
|
||
"color": "#E6A23C",
|
||
"objectives": [
|
||
{"name": "关键岗位胜任度", "kpis": ["L_COMPETENCY"]},
|
||
{"name": "培训完成率", "kpis": ["L_TRAINING"]},
|
||
],
|
||
},
|
||
]
|
||
|
||
# ── CRUD ────────────────────────────────────
|
||
|
||
@router.get("")
|
||
def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
# 账套隔离: 仅当前企业地图 (2026-08-25)
|
||
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
|
||
return {"data": [m_to_dict(m, db) for m in maps]}
|
||
|
||
@router.get("/strategy/revenue-caliber")
|
||
def strategy_revenue_caliber(entity_id: int = Depends(get_entity_id)):
|
||
"""战略地图摘要条 · 收入口径状态(口径已闭合,金额取 /maps/summary)。
|
||
|
||
2026-10-19 A1 终稿:两家均总额法;增值税销售货物 13%。
|
||
本端点只出口径与业务线维度(不含金额);金额一律由 /maps/summary 现算。
|
||
"""
|
||
from app.data import revenue_caliber
|
||
return revenue_caliber.snapshot(entity_id)
|
||
|
||
|
||
@router.post("")
|
||
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
|
||
if "dimensions" not in data or data.get("dimensions") is None:
|
||
data = {**data, "dimensions": [
|
||
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", "objectives": []},
|
||
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": []},
|
||
{"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A", "objectives": []},
|
||
{"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C", "objectives": []},
|
||
]}
|
||
if "canvas_data" not in data or data.get("canvas_data") is None:
|
||
data = {**data, "canvas_data": {"connections": []}}
|
||
data["entity_id"] = entity_id # 账套隔离: 强制token企业
|
||
m = StrategicMap(**data)
|
||
db.add(m)
|
||
db.commit()
|
||
db.refresh(m)
|
||
_sync_map_objectives(m, db)
|
||
return m_to_dict(m, db)
|
||
|
||
|
||
@router.post("/create-with-template")
|
||
def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""一键创建带四维度模板的战略地图(账套隔离: 强制token企业)"""
|
||
m = StrategicMap(
|
||
title=data.get("title", "新建战略地图"),
|
||
version=data.get("version", "v1.0"),
|
||
status="draft",
|
||
entity_id=entity_id,
|
||
dimensions=STRATEGIC_MAP_TEMPLATE,
|
||
canvas_data={"connections": []},
|
||
)
|
||
db.add(m)
|
||
db.commit()
|
||
db.refresh(m)
|
||
_sync_map_objectives(m, db)
|
||
return m_to_dict(m, db)
|
||
|
||
|
||
# ── 乐观并发保护(缺陷修复 cma-strategy-map-autosave-stale-overwrite, 2026-10-12)──
|
||
# 背景(生产实测,非推测):在线画布页把「浏览器本地快照」每 30s 整表 PUT 一次
|
||
# (dimensions + canvas_data)。若期间服务端已被他方写入(批处理/脚本/他方 API/他人编辑),
|
||
# 过期快照会静默回置服务端数据,并驱动 _sync_map_objectives 依快照重建节点行
|
||
# —— 实测把 19 行 map_objectives 删掉且未插回。
|
||
# 规则:凡请求体要覆写 dimensions/canvas_data 的写,必须携带客户端基准
|
||
# base_updated_at(或 updated_at;取库内既有列,不新增表/列);与库内现值不符 → 409。
|
||
# 校验在**任何写入之前**完成 ⇒ 校验不过时一行都不删/不改。
|
||
CONCURRENCY_GUARDED_FIELDS = ("dimensions", "canvas_data")
|
||
CONCURRENCY_BASE_KEYS = ("base_updated_at", "updated_at")
|
||
|
||
|
||
def _stamp(dt) -> Optional[str]:
|
||
"""库内时间戳 → 秒级 ISO 字符串(响应体/409 头统一口径)"""
|
||
if isinstance(dt, datetime):
|
||
return dt.replace(microsecond=0).isoformat()
|
||
return str(dt) if dt is not None else None
|
||
|
||
|
||
def _norm_stamp(raw) -> Optional[datetime]:
|
||
"""客户端基准 → 秒级 datetime(带时区先转服务器本地时区);不可解析返回 None"""
|
||
if raw is None:
|
||
return None
|
||
if isinstance(raw, datetime):
|
||
dt = raw
|
||
else:
|
||
s = str(raw).strip()
|
||
if not s:
|
||
return None
|
||
if s.endswith("Z"):
|
||
s = s[:-1] + "+00:00"
|
||
try:
|
||
dt = datetime.fromisoformat(s)
|
||
except ValueError:
|
||
return None
|
||
if dt.tzinfo is not None:
|
||
dt = dt.astimezone().replace(tzinfo=None)
|
||
return dt.replace(microsecond=0)
|
||
|
||
|
||
def _require_base_stamp(m: StrategicMap, data: dict) -> datetime:
|
||
"""并发基准唯一取值口:缺失 / 不可解析 → 409 fail-closed(调用方据此保证「一行都不改」)"""
|
||
base_raw = None
|
||
for k in CONCURRENCY_BASE_KEYS:
|
||
if data.get(k) is not None:
|
||
base_raw = data[k]
|
||
break
|
||
current = _stamp(m.updated_at)
|
||
if base_raw is None:
|
||
code = "concurrency_base_required"
|
||
detail = "缺少并发基准(base_updated_at):覆写战略地图内容须携带载入时的 updated_at"
|
||
else:
|
||
base = _norm_stamp(base_raw)
|
||
if base is not None:
|
||
return base
|
||
code = "concurrency_base_invalid"
|
||
detail = "并发基准(base_updated_at)格式无法解析"
|
||
raise HTTPException(409, detail, headers={
|
||
"X-CMA-Concurrency-Code": code,
|
||
"X-CMA-Map-Updated-At": current or "",
|
||
})
|
||
|
||
|
||
def assert_map_not_stale(m: StrategicMap, data: dict, fields=CONCURRENCY_GUARDED_FIELDS):
|
||
"""覆写 <fields> 前的前置校验(A 兜底 —— 本单不得移除)
|
||
|
||
- 不涉及护栏字段(或值为 None ⇒ 不会被赋值)→ 直接放行(如 MapList 只改 title/version);
|
||
- 缺基准 / 基准不可解析 / 基准与服务端现值不符 → 409(fail-closed:宁拒不覆写);
|
||
- 409 响应体带 detail 文案,响应头带 X-CMA-Concurrency-Code 与 X-CMA-Map-Updated-At。
|
||
- fields 参数供 P2 的按 objective 粒度通道复用(该通道只对 canvas_data 沿用本粒度)。
|
||
"""
|
||
overwriting = [k for k in fields if k in data and data.get(k) is not None]
|
||
if not overwriting:
|
||
return
|
||
base = _require_base_stamp(m, data)
|
||
if base != _norm_stamp(m.updated_at):
|
||
raise HTTPException(409, "地图已被修改,刷新后重试", headers={
|
||
"X-CMA-Concurrency-Code": "stale_map",
|
||
"X-CMA-Map-Updated-At": _stamp(m.updated_at) or "",
|
||
})
|
||
|
||
|
||
# ── 按 objective 粒度合并(P2 cma-strategy-map-objective-merge, 2026-10-12)──────
|
||
# 残留面(A 落地后):dimensions 仍是整表提交与覆写 ⇒ 两个会话编辑同一地图的**不同** objective 时,
|
||
# 后到者带过期基准被 409 拒绝:安全,但用户被迫整页刷新重做,且 409 语义被稀释
|
||
# (真冲突与「互不相干的并发」同码)。
|
||
#
|
||
# 本层把写通道从「整表覆写」收敛为「按 objective 粒度合并」:服务端保留未被本地改动的节点
|
||
# (含他方新增/删除的节点),仅对本地脏项做增/改/删;校验仍在任何写之前完成。
|
||
#
|
||
# 请求契约(全部沿用既有列,不新增表/列):
|
||
# dimensions 本地快照(结构/顺序;未标脏的节点内容服务端一律不采纳)
|
||
# dirty_objectives [{"dim_key": "finance", "objective_id": 12|null, "name": "目标A",
|
||
# "base": {...}(客户端载入时该目标的服务端原文)|null(本地新增),
|
||
# "value": {...}(新值)|null(本地删除)}]
|
||
# canvas_data 与库内现值一致 ⇒ 视为未改动(不覆写);有实质差异 ⇒ 退回 A 的 map 级校验
|
||
# base_updated_at 仍必需(缺失/不可解析 → 409,与 A 同)
|
||
#
|
||
# 冲突判定:脏项 base 与服务端现值不一致 ⇒ **同一 objective** 被并发改 ⇒ 409
|
||
# X-CMA-Concurrency-Code: stale_objective(内容冲突)/ stale_objective_missing(已被他人删除)
|
||
DIRTY_OBJECTIVES_KEY = "dirty_objectives"
|
||
|
||
|
||
def _is_granular_payload(data: dict) -> bool:
|
||
"""按 objective 粒度通道的开关:请求携带 dirty_objectives 列表(旧调用方不带 ⇒ 旧整表语义)"""
|
||
return isinstance(data.get(DIRTY_OBJECTIVES_KEY), list)
|
||
|
||
|
||
def _dims_list(raw) -> list:
|
||
"""dimensions 容错读:str / None / 非 list → [];只保留 dict 元素"""
|
||
if isinstance(raw, str):
|
||
try:
|
||
raw = json.loads(raw)
|
||
except ValueError:
|
||
raw = []
|
||
if not isinstance(raw, list):
|
||
return []
|
||
return [d for d in raw if isinstance(d, dict)]
|
||
|
||
|
||
def _obj_tag(o) -> Optional[int]:
|
||
"""objective 的服务端行 id(_objective_id / objective_id),无则 None"""
|
||
if not isinstance(o, dict):
|
||
return None
|
||
raw = o.get("_objective_id")
|
||
if raw is None:
|
||
raw = o.get("objective_id")
|
||
if raw is None:
|
||
return None
|
||
try:
|
||
return int(raw)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _obj_key(dim_key: str, o) -> tuple:
|
||
"""objective 稳定标识:优先行 id,退化到 (维度, 名称)"""
|
||
tag = _obj_tag(o)
|
||
if tag is not None:
|
||
return (dim_key, "id", tag)
|
||
return (dim_key, "name", str((o or {}).get("name") or "").strip())
|
||
|
||
|
||
def _obj_name_key(dim_key: str, o) -> tuple:
|
||
return (dim_key, "name", str((o or {}).get("name") or "").strip())
|
||
|
||
|
||
def _canon_obj(o) -> str:
|
||
"""objective 内容归一化(并发冲突比对):丢 _ 前缀簿记键 / None / 空串,稳定序列化"""
|
||
def clean(v):
|
||
if isinstance(v, dict):
|
||
return {k: clean(x) for k, x in v.items()
|
||
if not str(k).startswith("_") and x is not None and x != ""}
|
||
if isinstance(v, list):
|
||
return [clean(x) for x in v]
|
||
return v
|
||
return json.dumps(clean(o or {}), ensure_ascii=False, sort_keys=True)
|
||
|
||
|
||
def _canon_json(v) -> str:
|
||
return json.dumps(v, ensure_ascii=False, sort_keys=True, default=str)
|
||
|
||
|
||
def _canon_canvas(v) -> str:
|
||
"""canvas_data 归一化:None / {} / {'connections': []} 等价 ⇒ 视为「无改动」"""
|
||
if not isinstance(v, dict):
|
||
return _canon_json({"connections": []})
|
||
conns = v.get("connections")
|
||
return _canon_json({"connections": conns if isinstance(conns, list) else []})
|
||
|
||
|
||
def _find_objective(dim: dict, probe, dim_key: str) -> Optional[int]:
|
||
"""在 dim.objectives 中定位(先按行 id,退化按名称);返回下标,未命中 None"""
|
||
objs = dim.get("objectives") or []
|
||
key = _obj_key(dim_key, probe)
|
||
name_key = _obj_name_key(dim_key, probe)
|
||
for i, o in enumerate(objs):
|
||
if _obj_key(dim_key, o) == key:
|
||
return i
|
||
for i, o in enumerate(objs):
|
||
if _obj_key(dim_key, o) == name_key:
|
||
return i
|
||
return None
|
||
|
||
|
||
def _raise_stale_objective(m: StrategicMap, name: str, why: str):
|
||
raise HTTPException(409, f"目标「{name}」{why},刷新后重试", headers={
|
||
"X-CMA-Concurrency-Code": "stale_objective",
|
||
"X-CMA-Map-Updated-At": _stamp(m.updated_at) or "",
|
||
})
|
||
|
||
|
||
def _merge_dimensions_by_objective(m: StrategicMap, data: dict):
|
||
"""按 objective 粒度合并 dimensions;与库内现值等价时返回 None(调用方不写该字段)
|
||
|
||
顺序:
|
||
① 冲突校验(全部脏项,任何写之前):脏项 base(客户端载入时原文)≠ 服务端现值 ⇒ 409;
|
||
② 合并:仅对脏项增/改/删,服务端其余节点(含他方新增)原样保留;
|
||
③ 顺序:本地脏过的维度按提交快照顺序重排(服务端独有节点稳定排末尾);
|
||
④ 维度层元数据不在本粒度内(无编辑入口)⇒ 服务端保留现值,新维度取提交元数据。
|
||
"""
|
||
dims_in = _dims_list(data.get("dimensions"))
|
||
dirty = [e for e in (data.get(DIRTY_OBJECTIVES_KEY) or []) if isinstance(e, dict)]
|
||
current = copy.deepcopy(_dims_list(m.dimensions))
|
||
submitted_by_key = {d.get("key") or "": d for d in dims_in}
|
||
server_by_key = {d.get("key") or "": d for d in current}
|
||
|
||
# ── ① 冲突校验(fail-closed:任一脏项冲突即整体 409,此处尚未动任何数据)──
|
||
for e in dirty:
|
||
base = e.get("base")
|
||
if base is None:
|
||
continue # 本地新增:无基准可比
|
||
dk = e.get("dim_key") or ""
|
||
srv_dim = server_by_key.get(dk)
|
||
srv_obj = None
|
||
if srv_dim is not None:
|
||
pos = _find_objective(srv_dim, e, dk)
|
||
if pos is not None:
|
||
srv_obj = (srv_dim.get("objectives") or [])[pos]
|
||
name = str(e.get("name") or (base or {}).get("name") or "")
|
||
if srv_obj is None:
|
||
_raise_stale_objective(m, name, "已被他人删除")
|
||
if _canon_obj(srv_obj) != _canon_obj(base):
|
||
_raise_stale_objective(m, name, "已被他人修改")
|
||
|
||
# ── ② 合并 ──
|
||
merged = copy.deepcopy(current)
|
||
merged_by_key = {d.get("key") or "": d for d in merged}
|
||
touched = []
|
||
for e in dirty:
|
||
dk = e.get("dim_key") or ""
|
||
dim = merged_by_key.get(dk)
|
||
if dim is None:
|
||
sub = submitted_by_key.get(dk) or {"key": dk}
|
||
dim = {"key": dk, "name": sub.get("name") or sub.get("label") or dk,
|
||
"icon": sub.get("icon"), "color": sub.get("color"), "objectives": []}
|
||
merged.append(dim)
|
||
merged_by_key[dk] = dim
|
||
if dk not in touched:
|
||
touched.append(dk)
|
||
value = e.get("value")
|
||
if value is None: # 本地删除:定位不到 ⇒ 已被他人删除(幂等)
|
||
pos = _find_objective(dim, e, dk)
|
||
if pos is not None:
|
||
dim.setdefault("objectives", []).pop(pos)
|
||
continue
|
||
if e.get("base") is None: # 本地新增:追加(绝不覆盖服务端既有同名节点)
|
||
dim.setdefault("objectives", []).append(copy.deepcopy(value))
|
||
continue
|
||
pos = _find_objective(dim, e, dk)
|
||
if pos is None:
|
||
dim.setdefault("objectives", []).append(copy.deepcopy(value))
|
||
else:
|
||
dim["objectives"][pos] = copy.deepcopy(value)
|
||
|
||
# ── ③ 顺序:本地脏过的维度按提交快照重排 ──
|
||
for dk in touched:
|
||
dim = merged_by_key[dk]
|
||
sub_objs = (submitted_by_key.get(dk) or {}).get("objectives") or []
|
||
ranks = []
|
||
for o in sub_objs:
|
||
ranks.append(_obj_key(dk, o))
|
||
ranks.append(_obj_name_key(dk, o))
|
||
|
||
def _rank(o, ranks=ranks, dk=dk):
|
||
for cand in (_obj_key(dk, o), _obj_name_key(dk, o)):
|
||
if cand in ranks:
|
||
return ranks.index(cand)
|
||
return len(ranks)
|
||
|
||
dim["objectives"] = sorted(dim.get("objectives") or [], key=_rank)
|
||
|
||
if _canon_json(merged) == _canon_json(current):
|
||
return None
|
||
return merged
|
||
|
||
|
||
def _guard_canvas_data_granular(m: StrategicMap, data: dict):
|
||
"""granular 通道下 canvas_data 的护栏:
|
||
与库内现值一致(含 None / {} / {'connections': []} 等价)⇒ 视为未改动,剔除该字段不覆写;
|
||
有实质差异 ⇒ 退回 A 的 map 级基准校验(过期 ⇒ 409 stale_map)。
|
||
"""
|
||
if data.get("canvas_data") is None:
|
||
data.pop("canvas_data", None)
|
||
return
|
||
if _canon_canvas(data["canvas_data"]) == _canon_canvas(m.canvas_data):
|
||
data.pop("canvas_data", None)
|
||
return
|
||
assert_map_not_stale(m, data, fields=("canvas_data",))
|
||
|
||
|
||
@router.put("/{map_id}")
|
||
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||
if not m:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
# 账套隔离: 禁止跨企业修改
|
||
if m.entity_id != entity_id:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
|
||
# 并发保护(A 兜底 2026-10-12 + 按 objective 粒度合并 P2 2026-10-12):
|
||
# - 旧调用方(不带 dirty_objectives)⇒ 整表语义不变:覆写 dimensions/canvas_data 前校验基准;
|
||
# - 带 dirty_objectives ⇒ 按 objective 粒度合并:基准仍必需(fail-closed),但只有「同一 objective」
|
||
# 被并发改才 409(stale_objective);他方在其它 objective 上的改动被保留而非拒绝。
|
||
# 校验全部在任何 setattr/删行/同步之前完成 ⇒ 校验不过时一行都不改。
|
||
if _is_granular_payload(data):
|
||
_require_base_stamp(m, data)
|
||
_guard_canvas_data_granular(m, data)
|
||
merged_dims = _merge_dimensions_by_objective(m, data)
|
||
if merged_dims is None:
|
||
data.pop("dimensions", None) # 合并结果与库内现值等价 ⇒ 不写该字段
|
||
else:
|
||
data["dimensions"] = merged_dims
|
||
else:
|
||
assert_map_not_stale(m, data)
|
||
# 基准字段/脏项明细不是业务字段,剔除后不参与赋值
|
||
data = {k: v for k, v in data.items()
|
||
if k not in CONCURRENCY_BASE_KEYS and k != DIRTY_OBJECTIVES_KEY}
|
||
|
||
old_status = m.status
|
||
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
|
||
# status 允许但仅限合法值域(draft/published), 防止任意发布
|
||
ALLOWED_MAP_FIELDS = {"title", "version", "dimensions", "canvas_data", "description", "status"}
|
||
for k, v in data.items():
|
||
if k in ALLOWED_MAP_FIELDS and hasattr(m, k) and v is not None:
|
||
if k == "status" and v not in ("draft", "published"):
|
||
continue
|
||
setattr(m, k, v)
|
||
|
||
db.commit()
|
||
# 同步目标到map_objectives表
|
||
_sync_map_objectives(m, db)
|
||
# KR完整修复(2026-08-27): 同步 objectives+krs 表
|
||
_sync_objectives_krs(m, db)
|
||
|
||
# ├─ 版本管理: draft → published 时自动创建快照
|
||
if old_status == "draft" and m.status == "published":
|
||
_auto_snapshot(m, db)
|
||
|
||
return m_to_dict(m, db)
|
||
|
||
|
||
# ── 删除地图 ─────────────────────────────────
|
||
|
||
|
||
@router.delete("/{map_id}")
|
||
def delete_map(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""删除战略地图(账套隔离: 禁止跨企业删除)"""
|
||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||
if not m:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
if m.entity_id != entity_id:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
db.delete(m)
|
||
db.commit()
|
||
return {"message": "已删除"}
|
||
|
||
|
||
@router.post("/batch-delete")
|
||
def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""批量删除战略地图(账套隔离: 仅本企业地图)"""
|
||
ids = data.get("ids", [])
|
||
if not ids:
|
||
raise HTTPException(400, "请选择要删除的地图")
|
||
deleted = 0
|
||
for mid in ids:
|
||
m = db.query(StrategicMap).filter(StrategicMap.id == mid, StrategicMap.entity_id == entity_id).first()
|
||
if m:
|
||
db.delete(m)
|
||
deleted += 1
|
||
db.commit()
|
||
return {"message": f"已删除 {deleted} 个地图", "deleted": deleted}
|
||
|
||
|
||
# ── 连线管理 ─────────────────────────────────
|
||
|
||
def _get_connections(m: StrategicMap) -> list:
|
||
if not m.canvas_data:
|
||
m.canvas_data = {"connections": []}
|
||
if isinstance(m.canvas_data, str):
|
||
try:
|
||
m.canvas_data = json.loads(m.canvas_data)
|
||
except:
|
||
m.canvas_data = {"connections": []}
|
||
if "connections" not in m.canvas_data:
|
||
m.canvas_data["connections"] = []
|
||
return m.canvas_data["connections"]
|
||
|
||
|
||
@router.post("/{map_id}/connections")
|
||
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
|
||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||
if not m:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
|
||
from_id = data.get("from", "")
|
||
to_id = data.get("to", "")
|
||
|
||
if not from_id or not to_id:
|
||
raise HTTPException(400, "请提供 from 和 to")
|
||
|
||
# 校验: 不能自连
|
||
if from_id == to_id:
|
||
raise HTTPException(400, "不能自身连线")
|
||
|
||
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
|
||
from_dim = from_id.rsplit("-", 1)[0]
|
||
to_dim = to_id.rsplit("-", 1)[0]
|
||
|
||
conns = _get_connections(m)
|
||
|
||
# 校验: 不能重复
|
||
for c in conns:
|
||
if c.get("from") == from_id and c.get("to") == to_id:
|
||
raise HTTPException(400, "已存在相同的连线")
|
||
|
||
conns.append({
|
||
"from": from_id,
|
||
"to": to_id,
|
||
"style": "solid",
|
||
"effect": data.get("effect", "positive"),
|
||
"label": data.get("label", ""),
|
||
})
|
||
m.canvas_data["connections"] = conns
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
flag_modified(m, "canvas_data") # 同上:JSON 列需显式标脏才能落库
|
||
db.commit()
|
||
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
|
||
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
|
||
|
||
|
||
@router.delete("/{map_id}/connections")
|
||
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""根据 from/to 删除连线"""
|
||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||
if not m:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
|
||
from_id = data.get("from", "")
|
||
to_id = data.get("to", "")
|
||
|
||
conns = _get_connections(m)
|
||
new_conns = [c for c in conns if not (c.get("from") == from_id and c.get("to") == to_id)]
|
||
|
||
if len(new_conns) == len(conns):
|
||
raise HTTPException(404, "连线不存在")
|
||
|
||
m.canvas_data["connections"] = new_conns
|
||
# JSON 列就地/换列表赋值不会被 ORM 自动标记为 dirty ⇒ 必须显式 flag_modified,
|
||
# 否则接口返回 200 且响应列表已减 1,但 commit 不 flush,重读仍是旧值(删连线变哑弹)。
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
flag_modified(m, "canvas_data")
|
||
db.commit()
|
||
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
|
||
"updated_at": _stamp(m.updated_at)}
|
||
|
||
|
||
# ── 版本管理 ─────────────────────────────────
|
||
|
||
def _auto_snapshot(m: StrategicMap, db: Session):
|
||
"""发布时自动创建版本快照"""
|
||
from app.models import StrategicMapVersion
|
||
import re
|
||
|
||
# 自动递增版本号: 找到最大次版本号
|
||
existing = db.query(StrategicMapVersion).filter(
|
||
StrategicMapVersion.map_id == m.id
|
||
).order_by(StrategicMapVersion.id.desc()).first()
|
||
|
||
if existing:
|
||
match = re.search(r"v(\d+)\.(\d+)", existing.version)
|
||
if match:
|
||
major = int(match.group(1))
|
||
minor = int(match.group(2)) + 1
|
||
new_ver = f"v{major}.{minor}"
|
||
else:
|
||
new_ver = "v1.0"
|
||
else:
|
||
new_ver = "v1.0"
|
||
|
||
# 确保 JSON 序列化
|
||
dims = m.dimensions
|
||
canvas = m.canvas_data
|
||
if isinstance(dims, str):
|
||
try:
|
||
dims = json.loads(dims)
|
||
except:
|
||
dims = []
|
||
if isinstance(canvas, str):
|
||
try:
|
||
canvas = json.loads(canvas)
|
||
except:
|
||
canvas = {"connections": []}
|
||
|
||
snapshot = StrategicMapVersion(
|
||
map_id=m.id,
|
||
entity_id=m.entity_id if m.entity_id is not None else 1, # 继承地图企业(2026-09-08 修复: 原缺失致entity2版本行落entity1)
|
||
version=new_ver,
|
||
dimensions=dims,
|
||
canvas_data=canvas,
|
||
comment=f"发布 {new_ver}",
|
||
)
|
||
db.add(snapshot)
|
||
db.commit()
|
||
|
||
|
||
# ── 工具函数 ─────────────────────────────────
|
||
|
||
def m_to_dict(m, db: Session = None):
|
||
d = {c.name: getattr(m, c.name) for c in m.__table__.columns}
|
||
if db:
|
||
_merge_map_objectives(m, db)
|
||
d["dimensions"] = m.dimensions
|
||
return d
|
||
|
||
|
||
def _sync_map_objectives(m, db):
|
||
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
|
||
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
|
||
dims = m.dimensions
|
||
if isinstance(dims, str):
|
||
dims = json.loads(dims)
|
||
dims = dims or []
|
||
for dim in dims:
|
||
for i, obj in enumerate(dim.get("objectives", [])):
|
||
mo = MapObjective(
|
||
map_id=m.id,
|
||
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
|
||
dimension_key=dim.get("key", ""),
|
||
name=obj.get("name", ""),
|
||
description=obj.get("description", ""),
|
||
icon=obj.get("icon", "target"),
|
||
kpis=obj.get("kpis", []),
|
||
sort_order=i,
|
||
)
|
||
db.add(mo)
|
||
db.commit()
|
||
|
||
|
||
def _merge_map_objectives(m, db):
|
||
"""读取时:将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
|
||
dims = m.dimensions
|
||
if isinstance(dims, str):
|
||
dims = json.loads(dims)
|
||
dims = dims or []
|
||
# 按dimension_key分组
|
||
from collections import defaultdict
|
||
grouped = defaultdict(list)
|
||
for o in objs:
|
||
grouped[o.dimension_key].append(o)
|
||
for dim in dims:
|
||
key = dim.get("key", "")
|
||
if key in grouped:
|
||
merged = []
|
||
for i, o in enumerate(grouped[key]):
|
||
# 保留原有JSON中的额外字段(targetValue/krs等)
|
||
existing = dim["objectives"][i] if i < len(dim["objectives"]) else {}
|
||
merged.append({
|
||
"name": o.name,
|
||
"description": o.description or existing.get("description", ""),
|
||
"icon": o.icon or existing.get("icon", "target"),
|
||
"kpis": o.kpis or existing.get("kpis", []),
|
||
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")},
|
||
})
|
||
dim["objectives"] = merged
|
||
m.dimensions = dims
|
||
|
||
|
||
# ── KR完整修复(2026-08-27): 地图节点 → objectives + krs 表同步 ──
|
||
|
||
KR_OPERATORS = (">=", "<=", ">", "<", "=")
|
||
|
||
|
||
def _parse_kr_operator_target(raw_target, operator=None):
|
||
"""解析KR目标值字符串:
|
||
旧格式 '≥20%'/'<=18' → ('>=', 20.0)
|
||
新格式 operator='>=' + target=20 → ('>=', 20.0)
|
||
"""
|
||
if operator:
|
||
op = str(operator).strip()
|
||
if op == "≥":
|
||
op = ">="
|
||
elif op == "≤":
|
||
op = "<="
|
||
if op not in KR_OPERATORS:
|
||
op = ">="
|
||
else:
|
||
op = ">="
|
||
if raw_target is None:
|
||
return op, None
|
||
if isinstance(raw_target, (int, float)):
|
||
return op, float(raw_target)
|
||
s = str(raw_target).strip()
|
||
if s == "":
|
||
return op, None
|
||
# 前缀符号
|
||
for cand in KR_OPERATORS:
|
||
if s.startswith(cand):
|
||
op = cand
|
||
s = s[len(cand):]
|
||
break
|
||
if s.startswith("≥"):
|
||
op = ">="
|
||
s = s[1:]
|
||
elif s.startswith("≤"):
|
||
op = "<="
|
||
s = s[1:]
|
||
# 去掉单位(%、天、小时等非数字后缀)
|
||
num = s
|
||
while num and not (num[0].isdigit() or num[0] in ".+-"):
|
||
num = num[1:]
|
||
import re
|
||
m = re.match(r"[-+]?\d*\.?\d+", num)
|
||
if not m:
|
||
return op, None
|
||
return op, float(m.group(0))
|
||
|
||
|
||
def _sync_objectives_krs(m, db):
|
||
"""保存地图时:将dimensions JSON中带krs的目标节点同步到objectives表+krs表。
|
||
- 每个带krs的目标节点 → 确保objectives表有记录(按entity_id+name匹配,无则创建)
|
||
- krs数组 → krs表 upsert(按title匹配;删除本目标下JSON中不存在的krs)
|
||
- 回写 objective_id / kr_id 到节点JSON,方便前端下次直接使用
|
||
"""
|
||
dims = m.dimensions
|
||
if isinstance(dims, str):
|
||
try:
|
||
dims = json.loads(dims)
|
||
except Exception:
|
||
return
|
||
dims = dims or []
|
||
entity_id = m.entity_id if m.entity_id is not None else 1
|
||
for dim in dims:
|
||
dim_key = dim.get("key", "")
|
||
for obj in dim.get("objectives", []):
|
||
krs = obj.get("krs") or []
|
||
if not krs:
|
||
continue
|
||
name = (obj.get("name") or "").strip()
|
||
if not name:
|
||
continue
|
||
# 1. 确保 Objective 记录存在
|
||
objective = db.query(Objective).filter(
|
||
Objective.entity_id == entity_id,
|
||
Objective.title == name,
|
||
).first()
|
||
if not objective:
|
||
objective = Objective(
|
||
entity_id=entity_id,
|
||
title=name,
|
||
description=obj.get("description") or "",
|
||
dimension=dim_key,
|
||
quarter=_map_quarter(m),
|
||
owner=obj.get("owner") or "",
|
||
status="active",
|
||
progress=0,
|
||
)
|
||
db.add(objective)
|
||
db.flush() # 拿到 id
|
||
obj["_objective_id"] = objective.id
|
||
# 2. upsert krs 表
|
||
existing_krs = {kr.title: kr for kr in db.query(KR).filter(KR.objective_id == objective.id).all()}
|
||
keep_ids = set()
|
||
for i, kr_item in enumerate(krs):
|
||
if not isinstance(kr_item, dict):
|
||
continue
|
||
title = (kr_item.get("title") or kr_item.get("name") or "").strip()
|
||
if not title:
|
||
# 跳过空KR(前端允许空白行,不入库)
|
||
continue
|
||
op, tgt = _parse_kr_operator_target(
|
||
kr_item.get("target_value"), kr_item.get("operator"))
|
||
kpi_id = kr_item.get("metric_kpi_id")
|
||
if not kpi_id and kr_item.get("kpi_code"):
|
||
kpi = db.query(KPIDefinition).filter(
|
||
KPIDefinition.entity_id == entity_id,
|
||
KPIDefinition.kpi_code == kr_item["kpi_code"],
|
||
).first()
|
||
kpi_id = kpi.id if kpi else None
|
||
# 关联KPI时自动继承方向(KPI阈值如 '>=800')
|
||
if kpi_id and not kr_item.get("operator"):
|
||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||
if kpi and kpi.threshold_green:
|
||
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
|
||
op, tgt = op2, (tgt2 if tgt is None else tgt)
|
||
# 护栏(2026-09-16):名称尾部「≥/≤+数字」与目标值不一致 = 历史污染截断值,
|
||
# 在此透传点(JSON/模板名 → krs.title)规整,防止污染继续传染(判据见 app/utils/kr_name.py)
|
||
clean_title, _polluted = sanitize_kr_name(title, kr_item.get("target_value"), tgt)
|
||
if _polluted:
|
||
logger.warning(
|
||
"KR名称污染已规整: map=%s objective=%s %r -> %r (target=%s)",
|
||
m.id, objective.id, title, clean_title, tgt,
|
||
)
|
||
title = clean_title
|
||
kr_item["name"] = clean_title # 同步回写 JSON,保持展示口径=落库口径
|
||
kr = existing_krs.get(title)
|
||
if kr is None:
|
||
kr = KR(
|
||
entity_id=entity_id,
|
||
objective_id=objective.id,
|
||
title=title,
|
||
)
|
||
db.add(kr)
|
||
kr.metric_kpi_id = kpi_id
|
||
kr.operator = op
|
||
if kr_item.get("tolerance") is not None:
|
||
kr.tolerance = kr_item.get("tolerance")
|
||
if kr_item.get("weight") is not None:
|
||
kr.weight = kr_item.get("weight")
|
||
if tgt is not None:
|
||
kr.target_value = tgt
|
||
if kr_item.get("current_value") is not None:
|
||
kr.current_value = kr_item.get("current_value")
|
||
if kr_item.get("due_date"):
|
||
kr.due_date = _parse_kr_date(kr_item.get("due_date"))
|
||
kr.sort_order = i + 1
|
||
keep_ids.add(kr.id)
|
||
kr_item["_kr_id"] = kr.id
|
||
# 3. 删除JSON中不存在的krs(空标题的除外——它从未入库)
|
||
for title, kr in existing_krs.items():
|
||
if kr.id not in keep_ids:
|
||
db.delete(kr)
|
||
db.commit()
|
||
# 回写dimensions(含_objective_id/_kr_id)
|
||
m.dimensions = dims
|
||
db.commit()
|
||
|
||
|
||
def _map_quarter(m) -> str:
|
||
"""从地图标题提取季度标识(如 2026Q3),无则空字符串"""
|
||
title = m.title or ""
|
||
import re
|
||
m_q = re.search(r"(20\d{2})\s*[Qq](\d)", title)
|
||
if m_q:
|
||
return f"{m_q.group(1)}Q{m_q.group(2)}"
|
||
return ""
|
||
|
||
|
||
def _parse_kr_date(v):
|
||
if v is None or v == "":
|
||
return None
|
||
if isinstance(v, datetime):
|
||
return v.date()
|
||
try:
|
||
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
# ── 战略回顾会 聚合接口 ──────────────────────
|
||
|
||
|
||
@router.get("/{map_id}/krs")
|
||
def get_map_krs(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""战略回顾会 KR进度: 从krs表读取地图各目标的KR (KR修复 2026-08-27)
|
||
|
||
地图dimensions JSON中每个带krs的目标节点已同步到objectives+krs表,
|
||
此处按 节点._objective_id(或 entity+name 回查)取KR。
|
||
"""
|
||
from app.models import KPIDefinition, KR, Objective
|
||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||
if not m:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
|
||
dims = m.dimensions
|
||
if isinstance(dims, str):
|
||
try:
|
||
dims = json.loads(dims)
|
||
except Exception:
|
||
dims = []
|
||
dims = dims or []
|
||
|
||
obj_ids = []
|
||
for dim in dims:
|
||
for obj in dim.get("objectives", []):
|
||
oid = obj.get("_objective_id")
|
||
if oid:
|
||
obj_ids.append(int(oid))
|
||
continue
|
||
# 兼容无回写字段的旧节点: 按 entity+name 回查
|
||
name = (obj.get("name") or "").strip()
|
||
if name:
|
||
o = db.query(Objective).filter(
|
||
Objective.entity_id == entity_id,
|
||
Objective.title == name,
|
||
).first()
|
||
if o:
|
||
obj_ids.append(o.id)
|
||
|
||
result = []
|
||
if obj_ids:
|
||
krs = db.query(KR).filter(
|
||
KR.entity_id == entity_id,
|
||
KR.objective_id.in_(obj_ids),
|
||
).order_by(KR.objective_id, KR.sort_order, KR.id).all()
|
||
for kr in krs:
|
||
mkpi = None
|
||
if kr.metric_kpi_id:
|
||
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
|
||
result.append({
|
||
"id": kr.id,
|
||
"objective_id": kr.objective_id,
|
||
"title": kr.title,
|
||
"metric_kpi_id": kr.metric_kpi_id,
|
||
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||
"operator": _parse_kr_operator_target(kr.operator)[0] if hasattr(kr, "operator") and kr.operator else ">=",
|
||
"weight": float(kr.weight) if kr.weight is not None else None,
|
||
"target_value": float(kr.target_value) if kr.target_value is not None else None,
|
||
"current_value": float(kr.current_value) if kr.current_value is not None else None,
|
||
"progress": kr.progress or 0,
|
||
"status": kr.status,
|
||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||
})
|
||
return {"data": result}
|
||
|
||
|
||
@router.get("/{map_id}/review")
|
||
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""战略回顾会:返回目标状态、KPI值、改善行动
|
||
|
||
level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI;
|
||
不传则返回全部KPI(向后兼容)。
|
||
"""
|
||
from app.models import KPIDefinition, KPIValue, ActionPlan
|
||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||
if not m:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
|
||
dims = m.dimensions
|
||
if isinstance(dims, str):
|
||
dims = json.loads(dims)
|
||
dims = dims or [] # NULL容错
|
||
|
||
# 收集所有KPI code
|
||
all_kpi_codes = set()
|
||
for dim in dims:
|
||
for obj in dim.get("objectives", []):
|
||
for code in obj.get("kpis", []):
|
||
all_kpi_codes.add(code)
|
||
|
||
# 查询KPI定义(账套隔离: 同编码跨企业会混入, OpenCode审查#10)
|
||
kpi_defs = db.query(KPIDefinition).filter(
|
||
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False,
|
||
KPIDefinition.entity_id == entity_id,
|
||
).all() if all_kpi_codes else []
|
||
kpi_map = {k.kpi_code: k for k in kpi_defs}
|
||
|
||
# 查询最新KPI实际值
|
||
kpi_ids = [k.id for k in kpi_defs]
|
||
latest_values = {}
|
||
if kpi_ids:
|
||
# 取每个KPI的最新一条
|
||
for kid in kpi_ids:
|
||
v = db.query(KPIValue).filter(
|
||
KPIValue.kpi_id == kid,
|
||
# 实测区口径:预估行禁作地图节点实测值(kpi_value_scope.actual_only_clause)
|
||
actual_only_clause(),
|
||
).order_by(KPIValue.calculated_at.desc()).first()
|
||
if v:
|
||
latest_values[kid] = {
|
||
"actual_value": v.actual_value,
|
||
"period": v.period,
|
||
"source_type": v.source_type,
|
||
}
|
||
|
||
# 查询改善行动(按KPI_id关联)
|
||
action_plans_data = []
|
||
if kpi_ids:
|
||
plans = db.query(ActionPlan).filter(
|
||
ActionPlan.kpi_id.in_(kpi_ids)
|
||
).order_by(ActionPlan.created_at.desc()).all()
|
||
for p in plans:
|
||
action_plans_data.append({
|
||
"id": p.id,
|
||
"kpi_id": p.kpi_id,
|
||
"title": p.title,
|
||
"assignee": p.assignee,
|
||
"priority": p.priority,
|
||
"due_date": p.due_date.isoformat() if p.due_date else None,
|
||
"status": p.status,
|
||
"progress": p.progress or 0,
|
||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||
})
|
||
|
||
# 构建维度目标状态
|
||
dim_results = []
|
||
total_ok = 0
|
||
total_warn = 0
|
||
total_err = 0
|
||
total_obj_count = 0
|
||
focus_items = []
|
||
|
||
for dim in dims:
|
||
dim_key = dim.get("key", "")
|
||
dim_name = dim.get("name", "")
|
||
dim_icon = dim.get("icon", "")
|
||
dim_color = dim.get("color", "")
|
||
objectives = []
|
||
for obj in dim.get("objectives", []):
|
||
total_obj_count += 1
|
||
obj_kpis = []
|
||
worst_level = "green"
|
||
for code in obj.get("kpis", []):
|
||
kpi_def = kpi_map.get(code)
|
||
if not kpi_def:
|
||
continue
|
||
# 规则4: 战略地图默认只显示strategic级KPI(level过滤)
|
||
if level and kpi_def.kpi_level != level:
|
||
continue
|
||
lv = latest_values.get(kpi_def.id, {})
|
||
# C1 数据前置:held KPI 的实测值一律不外泄(96.3% 零出口)
|
||
held = code in DATA_HOLD_KPIS
|
||
actual = None if held else lv.get("actual_value")
|
||
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
|
||
target = kpi_def.target_monthly or kpi_def.target_value
|
||
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
||
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
|
||
level = "gray"
|
||
ratio_val = None
|
||
if actual is not None and target:
|
||
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
|
||
lower_better = any(k in name for k in
|
||
("费用率", "成本率", "损耗", "逾期", "投诉", "流失", "负债率", "返修", "缺勤"))
|
||
if lower_better:
|
||
ratio = target / actual if actual else 0 # 实际越低越好: 目标/实际
|
||
else:
|
||
ratio = actual / target
|
||
if ratio < 0:
|
||
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
|
||
ratio_val = ratio
|
||
if ratio >= 0.9:
|
||
level = "green"
|
||
elif ratio >= 0.6:
|
||
level = "yellow"
|
||
else:
|
||
level = "red"
|
||
else:
|
||
level = "gray"
|
||
|
||
# C1 数据前置:held KPI 不着色、不透出达成率(level=pending 由前端灰虚线呈现)
|
||
if held:
|
||
level = "pending"
|
||
ratio_val = None
|
||
|
||
if level == "red":
|
||
worst_level = "red"
|
||
elif level == "yellow" and worst_level != "red":
|
||
worst_level = "yellow"
|
||
|
||
obj_kpis.append({
|
||
"kpi_id": kpi_def.id,
|
||
"kpi_code": code,
|
||
"kpi_name": kpi_def.kpi_name,
|
||
"target_value": target,
|
||
"actual_value": actual,
|
||
"unit": kpi_def.unit,
|
||
"level": level,
|
||
# 达成率(按指标方向归一,None=无目标/无实测,调用方须显式显示"无数据")
|
||
"ratio": round(ratio_val, 4) if ratio_val is not None else None,
|
||
})
|
||
|
||
kpi_ratios = [k["ratio"] for k in obj_kpis if k.get("ratio") is not None]
|
||
obj_ratio = round(sum(kpi_ratios) / len(kpi_ratios), 4) if kpi_ratios else None
|
||
|
||
# C1 数据前置:目标含 held KPI ⇒ 整节点「数据待核」,不计入任何达成率汇总
|
||
hold_reason = next((DATA_HOLD_KPIS[c] for c in obj.get("kpis", []) if c in DATA_HOLD_KPIS), None)
|
||
|
||
obj_item = {
|
||
"name": obj.get("name", ""),
|
||
"icon": obj.get("icon", ""),
|
||
"kpis": obj_kpis,
|
||
"level": "pending" if hold_reason else worst_level,
|
||
"has_data": len(obj_kpis) > 0,
|
||
# 目标达成率 = 有实测值 KPI 的方向归一达成率均值;None ⇒ 前端显示"无数据"
|
||
"ratio": None if hold_reason else obj_ratio,
|
||
"kpi_total": len(obj_kpis),
|
||
"kpi_with_data": 0 if hold_reason else len(kpi_ratios),
|
||
"has_actual": False if hold_reason else bool(kpi_ratios),
|
||
"data_hold": bool(hold_reason),
|
||
"hold_reason": hold_reason,
|
||
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
|
||
"action_plan_summary": {
|
||
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
|
||
"pending": len([p for p in action_plans_data
|
||
if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]
|
||
and p["status"] not in ("done", "completed")]),
|
||
} if obj_kpis else {"total": 0, "pending": 0},
|
||
}
|
||
objectives.append(obj_item)
|
||
|
||
# C1 数据前置:数据待核目标单列,不进达标/关注/风险任何一档
|
||
if hold_reason:
|
||
total_hold += 1
|
||
continue
|
||
|
||
if worst_level == "green":
|
||
total_ok += 1
|
||
elif worst_level == "yellow":
|
||
total_warn += 1
|
||
elif worst_level == "red":
|
||
total_err += 1
|
||
|
||
# 红色和黄色归入需重点关注
|
||
if worst_level in ("red", "yellow"):
|
||
focus_items.append(obj_item)
|
||
|
||
dim_results.append({
|
||
"key": dim_key,
|
||
"name": dim_name,
|
||
"icon": dim_icon,
|
||
"color": dim_color,
|
||
"objectives": objectives,
|
||
})
|
||
|
||
# 排序:红色在前,黄色在后
|
||
focus_items.sort(key=lambda x: (0 if x["level"] == "red" else 1, x["name"]))
|
||
|
||
return {
|
||
"map_id": m.id,
|
||
"title": m.title,
|
||
"version": m.version,
|
||
"status": m.status,
|
||
"dimensions": dim_results,
|
||
"summary": {
|
||
"total": total_obj_count,
|
||
"green": total_ok,
|
||
"yellow": total_warn,
|
||
"red": total_err,
|
||
"health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0,
|
||
},
|
||
"focus_items": focus_items,
|
||
"action_plans": action_plans_data,
|
||
}
|
||
|
||
|
||
# ── 复盘记录持久化(大PDCA Act闭环 2026-08-27)────────────────
|
||
def review_to_dict(r: ReviewRecord) -> dict:
|
||
return {
|
||
"id": r.id,
|
||
"entity_id": r.entity_id,
|
||
"map_id": r.map_id,
|
||
"review_type": r.review_type,
|
||
"review_date": r.review_date.isoformat() if r.review_date else None,
|
||
"summary": r.summary,
|
||
"adjustments": r.adjustments,
|
||
"next_priorities": r.next_priorities,
|
||
"created_by": r.created_by,
|
||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||
}
|
||
|
||
|
||
def _parse_review_date(raw) -> Optional[date]:
|
||
"""兼容 'YYYY-MM-DD' / datetime / date 输入"""
|
||
if raw is None:
|
||
return datetime.now().date()
|
||
if isinstance(raw, datetime):
|
||
return raw.date()
|
||
if isinstance(raw, date):
|
||
return raw
|
||
try:
|
||
return datetime.strptime(str(raw)[:10], "%Y-%m-%d").date()
|
||
except ValueError:
|
||
return datetime.now().date()
|
||
|
||
|
||
def _get_map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
|
||
m = db.query(StrategicMap).filter(
|
||
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id
|
||
).first()
|
||
if not m:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
return m
|
||
|
||
|
||
@router.post("/{map_id}/review-records")
|
||
def create_review_record(
|
||
map_id: int,
|
||
data: dict,
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth),
|
||
):
|
||
"""保存复盘结论(战略回顾会/运营复盘会结束时落库)"""
|
||
_get_map_or_404(db, map_id, entity_id)
|
||
summary = (data.get("summary") or "").strip()
|
||
if not summary:
|
||
raise HTTPException(400, "复盘结论(summary)不能为空")
|
||
|
||
r = ReviewRecord(
|
||
entity_id=entity_id, # 多租户隔离: 强制token企业
|
||
map_id=map_id,
|
||
review_type=data.get("review_type", "quarterly"),
|
||
review_date=_parse_review_date(data.get("review_date")),
|
||
summary=summary,
|
||
adjustments=data.get("adjustments") or [],
|
||
next_priorities=data.get("next_priorities") or [],
|
||
created_by=current_user.id if current_user else None,
|
||
)
|
||
db.add(r)
|
||
db.commit()
|
||
db.refresh(r)
|
||
return review_to_dict(r)
|
||
|
||
|
||
@router.get("/{map_id}/review-records")
|
||
def list_review_records(
|
||
map_id: int,
|
||
review_type: Optional[str] = None,
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""历史复盘记录列表(按复盘日期倒序)"""
|
||
_get_map_or_404(db, map_id, entity_id)
|
||
q = db.query(ReviewRecord).filter(
|
||
ReviewRecord.map_id == map_id,
|
||
ReviewRecord.entity_id == entity_id,
|
||
)
|
||
if review_type:
|
||
q = q.filter(ReviewRecord.review_type == review_type)
|
||
records = q.order_by(ReviewRecord.review_date.desc(), ReviewRecord.id.desc()).all()
|
||
return {"data": [review_to_dict(r) for r in records]}
|
||
|
||
|
||
@router.get("/{map_id}/review-records/{record_id}")
|
||
def get_review_record(
|
||
map_id: int,
|
||
record_id: int,
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""单条复盘记录详情"""
|
||
_get_map_or_404(db, map_id, entity_id)
|
||
r = db.query(ReviewRecord).filter(
|
||
ReviewRecord.id == record_id,
|
||
ReviewRecord.map_id == map_id,
|
||
ReviewRecord.entity_id == entity_id,
|
||
).first()
|
||
if not r:
|
||
raise HTTPException(404, "复盘记录不存在")
|
||
return review_to_dict(r)
|
||
|
||
|
||
# ── 战略地图「活看板」(只读)────────────────────────────────────────
|
||
# 任务:finance-20261011-strategy-map-frontend-fullstack(任总 2026-10-11 19:07「收口」)
|
||
# 口径单一源:app/utils/strategy_board.py(色阶/逆指标白名单/状态类/零点保护)
|
||
# 只读:本端点不写任何表;entity_id 严格取 token → 博海 e2 天然隔离。
|
||
|
||
# kpi_targets.target_type 取值:兼容期读两形(不阻塞渲染)
|
||
# - 现状(2026-10-11 只读实测):全库仅 `annual: 57`(e1 战略框架同步写入,值域外)
|
||
# - P0 修正单 finance-20261011-kpi-targets-targettype-fix-dama 会把它们改成 `yearly`
|
||
# - 两形都读 ⇒ 修正前后同一份代码都取得到数;同 (kpi_id, period) 重复时偏好 `yearly`
|
||
_PERIOD_TYPES = ("yearly", "annual")
|
||
|
||
|
||
def _sb_resolve_bindings(db: Session, entity_id: int, objs: list) -> tuple[dict, dict]:
|
||
"""节点 ↔ KPI 绑定:优先 `map_objectives.kpis`(收口单已回填),缺失时按名称回查。
|
||
|
||
返回 (bindings, sources):``bindings[obj.id] = (kpi_id, kpi_code)``;
|
||
``sources[obj.id]`` ∈ {kpis, kpis_code, name, note, none} —— 供复核方判断
|
||
是否已用上 19 条回填绑定(回填前 name/note 兜底)。
|
||
"""
|
||
from app.models import KPIDefinition
|
||
|
||
bindings: dict = {}
|
||
sources: dict = {}
|
||
declared_codes: dict = {}
|
||
|
||
for obj in objs:
|
||
raw = obj.kpis
|
||
if isinstance(raw, str):
|
||
try:
|
||
raw = json.loads(raw)
|
||
except Exception:
|
||
raw = None
|
||
if isinstance(raw, dict):
|
||
raw = [raw]
|
||
if not isinstance(raw, list):
|
||
continue
|
||
for item in raw:
|
||
if isinstance(item, dict):
|
||
kid, code = item.get("kpi_id"), item.get("kpi_code") or item.get("code")
|
||
if kid:
|
||
bindings[obj.id] = (int(kid), code)
|
||
sources[obj.id] = "kpis"
|
||
break
|
||
if code:
|
||
declared_codes[obj.id] = code
|
||
break
|
||
elif isinstance(item, str):
|
||
declared_codes[obj.id] = item
|
||
break
|
||
|
||
def _def_by_code(code: str, cache: dict):
|
||
if code in cache:
|
||
return cache[code]
|
||
row = db.query(KPIDefinition).filter(
|
||
KPIDefinition.entity_id == entity_id,
|
||
KPIDefinition.kpi_code == code,
|
||
).first()
|
||
cache[code] = row
|
||
return row
|
||
|
||
code_cache: dict = {}
|
||
for oid, code in declared_codes.items():
|
||
row = _def_by_code(code, code_cache)
|
||
if row:
|
||
bindings[oid] = (row.id, row.kpi_code)
|
||
sources[oid] = "kpis_code"
|
||
|
||
# 兜底:按节点名回查 KPI 名 / kpi_targets.note("名称 · 期间")
|
||
for obj in objs:
|
||
if obj.id in bindings:
|
||
continue
|
||
row = db.query(KPIDefinition).filter(
|
||
KPIDefinition.entity_id == entity_id,
|
||
KPIDefinition.kpi_name == obj.name,
|
||
).first()
|
||
if row:
|
||
bindings[obj.id] = (row.id, row.kpi_code)
|
||
sources[obj.id] = "name"
|
||
continue
|
||
hit = db.execute(text(
|
||
"SELECT kpi_id FROM kpi_targets "
|
||
"WHERE entity_id = :e AND target_type IN ('yearly', 'annual') AND note LIKE :pat "
|
||
"ORDER BY period LIMIT 1"
|
||
), {"e": entity_id, "pat": f"{obj.name} · %"}).first()
|
||
if hit:
|
||
bindings[obj.id] = (int(hit[0]), None)
|
||
sources[obj.id] = "note"
|
||
else:
|
||
sources[obj.id] = "none"
|
||
return bindings, sources
|
||
|
||
|
||
@router.get("/{map_id}/strategy-board")
|
||
def get_strategy_board(
|
||
map_id: int,
|
||
period: Optional[str] = Query(None, description="期间 2027/2028/2029,默认取最小可用期间"),
|
||
db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""战略地图活看板(只读):19 节点分层 + 达成率色阶 + 因果箭头。
|
||
|
||
- 目标值:`kpi_targets`(`target_type` 兼容 `yearly`/`annual` 两形,按 period)
|
||
- 实际值:`kpi_values` **实测区**最新期(`kpi_value_scope.actual_only_clause`)
|
||
- 色阶:r = 实际/目标;≥1.0 success / ≥0.8 warning / <0.8 danger / 无实际值 info
|
||
- 逆指标(显式白名单 504/511/230/23)取倒数;状态类(unit='状态')按 1/0 判色
|
||
"""
|
||
from app.models import KPIValue, KPICausality, KPIDefinition
|
||
from app.utils.strategy_board import (
|
||
INVERSE_KPI_IDS, LAYER_DISPLAY_ORDER, LAYER_META, LEVEL_COLORS, STATUS_UNIT,
|
||
compute_level, layer_meta, period_rank, pick_latest,
|
||
)
|
||
|
||
warnings: list = []
|
||
m = db.query(StrategicMap).filter(
|
||
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id,
|
||
).first()
|
||
if not m:
|
||
raise HTTPException(404, "战略地图不存在")
|
||
|
||
objs = db.query(MapObjective).filter(
|
||
MapObjective.map_id == map_id, MapObjective.entity_id == entity_id,
|
||
).order_by(MapObjective.sort_order, MapObjective.id).all()
|
||
if not objs:
|
||
warnings.append("该地图下无 map_objectives 节点(map_objectives 为空)")
|
||
|
||
bindings, sources = _sb_resolve_bindings(db, entity_id, objs)
|
||
kpi_ids = sorted({v[0] for v in bindings.values() if v and v[0]})
|
||
kpi_defs = {}
|
||
if kpi_ids:
|
||
for k in db.query(KPIDefinition).filter(
|
||
KPIDefinition.entity_id == entity_id, KPIDefinition.id.in_(kpi_ids),
|
||
).all():
|
||
kpi_defs[k.id] = k
|
||
|
||
# ── 目标值(kpi_targets 无 ORM 模型,用只读 text() 取)──
|
||
targets: dict = {}
|
||
periods: list = []
|
||
if kpi_ids:
|
||
id_list = ",".join(str(int(i)) for i in kpi_ids) # 已 int 化,无注入面
|
||
ttype = ",".join("'%s'" % t for t in _PERIOD_TYPES)
|
||
rows = db.execute(text(
|
||
"SELECT kpi_id, period, target_type, target_value FROM kpi_targets "
|
||
f"WHERE entity_id = :e AND target_type IN ({ttype}) AND kpi_id IN ({id_list})"
|
||
), {"e": entity_id}).fetchall()
|
||
# 同 (kpi_id, period) 两形并存(兼容期)⇒ 取 `_PERIOD_TYPES` 中序更靠前者(yearly 优先)
|
||
_type_rank = {t: i for i, t in enumerate(_PERIOD_TYPES)}
|
||
best: dict = {}
|
||
for r in rows:
|
||
kid, per, ttype_row, tv = int(r[0]), str(r[1]), str(r[2]), r[3]
|
||
rank = _type_rank.get(ttype_row, len(_PERIOD_TYPES))
|
||
if (kid, per) in best and best[(kid, per)][0] <= rank:
|
||
continue
|
||
best[(kid, per)] = (rank, float(tv) if tv is not None else None)
|
||
for (kid, per) in best:
|
||
targets[(kid, per)] = best[(kid, per)][1]
|
||
if per not in periods:
|
||
periods.append(per)
|
||
periods.sort(key=period_rank)
|
||
|
||
# `direction` 列在 ORM 模型里未映射(库内有、模型无);判色**不依赖**它
|
||
# (逆指标用显式白名单,见 strategy_board.INVERSE_KPI_IDS),此处仅为回执留证
|
||
directions: dict = {}
|
||
if kpi_ids:
|
||
id_list_d = ",".join(str(int(i)) for i in kpi_ids)
|
||
for r in db.execute(text(
|
||
f"SELECT id, direction FROM kpi_definitions WHERE id IN ({id_list_d})"
|
||
)).fetchall():
|
||
directions[int(r[0])] = r[1]
|
||
|
||
# ── 实际值:实测区最新期(每 KPI 一条)──
|
||
currents: dict = {}
|
||
if kpi_ids:
|
||
vrows = db.query(KPIValue).filter(
|
||
KPIValue.entity_id == entity_id,
|
||
KPIValue.kpi_id.in_(kpi_ids),
|
||
actual_only_clause(),
|
||
).all()
|
||
by_kpi: dict = {}
|
||
for v in vrows:
|
||
by_kpi.setdefault(v.kpi_id, []).append(v)
|
||
for kid, rows_k in by_kpi.items():
|
||
currents[kid] = pick_latest(rows_k)
|
||
|
||
# ── 因果箭头(先按本单 source_type,为空则退回该实体全部)──
|
||
edges_raw = db.query(KPICausality).filter(
|
||
KPICausality.entity_id == entity_id,
|
||
KPICausality.source_type == "strategy-2027",
|
||
).all()
|
||
if not edges_raw:
|
||
edges_raw = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
|
||
if edges_raw:
|
||
warnings.append("未命中 source_type='strategy-2027' 的因果链,已退回该实体全部因果链")
|
||
|
||
chosen_period = period if period else (periods[0] if periods else None)
|
||
obj_by_kpi = {v[0]: k for k, v in bindings.items() if v and v[0]}
|
||
|
||
nodes: list = []
|
||
for obj in objs:
|
||
kid, code = bindings.get(obj.id, (None, None))
|
||
kdef = kpi_defs.get(kid) if kid else None
|
||
unit = (kdef.unit if kdef else None) or None
|
||
target = targets.get((kid, chosen_period)) if kid else None
|
||
vrow = currents.get(kid) if kid else None
|
||
cur = float(vrow.actual_value) if (vrow and vrow.actual_value is not None) else None
|
||
calc = compute_level(kpi_id=kid, unit=unit, current=cur, target=target)
|
||
lmeta = layer_meta(obj.dimension_key)
|
||
owner = None
|
||
if kdef:
|
||
owner = getattr(kdef, "responsible_user", None) or getattr(kdef, "data_owner", None)
|
||
dept = getattr(kdef, "responsible_dept", None)
|
||
if dept and owner:
|
||
owner = f"{dept}·{owner}"
|
||
elif dept:
|
||
owner = dept
|
||
nodes.append({
|
||
"id": obj.id,
|
||
"name": obj.name,
|
||
"dimension_key": obj.dimension_key,
|
||
"layer_name": lmeta["name"],
|
||
"layer_color": lmeta["color"],
|
||
"icon": obj.icon,
|
||
"sort_order": obj.sort_order,
|
||
"description": obj.description,
|
||
"kpi_id": kid,
|
||
"kpi_code": code or (kdef.kpi_code if kdef else None),
|
||
"kpi_name": kdef.kpi_name if kdef else None,
|
||
"unit": unit,
|
||
"direction": directions.get(kid),
|
||
"owner": owner,
|
||
"binding_source": sources.get(obj.id, "none"),
|
||
"current": cur,
|
||
"current_period": (vrow.period if vrow else None),
|
||
"current_data_status": (vrow.data_status if vrow else None),
|
||
"current_source_type": (vrow.source_type if vrow else None),
|
||
"target": target,
|
||
"rate": calc["rate"],
|
||
"level": calc["level"],
|
||
"color": calc["color"],
|
||
"mode": calc["mode"],
|
||
"reason": calc["reason"],
|
||
})
|
||
if obj.id not in bindings or not kid:
|
||
warnings.append(f"节点「{obj.name}」未绑定 KPI(map_objectives.kpis 为空且回查未命中)")
|
||
elif code is None and kdef:
|
||
code = kdef.kpi_code
|
||
|
||
layer_buckets: dict = {k: [] for k in LAYER_DISPLAY_ORDER}
|
||
for n in nodes:
|
||
layer_buckets.setdefault(n["dimension_key"] if n["dimension_key"] in LAYER_META else "unknown", []).append(n)
|
||
|
||
layers: list = []
|
||
for key in LAYER_DISPLAY_ORDER:
|
||
lmeta = LAYER_META[key]
|
||
layers.append({"key": key, "name": lmeta["name"], "color": lmeta["color"],
|
||
"nodes": layer_buckets.get(key, [])})
|
||
for key, bucket in layer_buckets.items():
|
||
if key not in LAYER_META and bucket:
|
||
layers.append({"key": key, "name": key, "color": LEVEL_COLORS["info"], "nodes": bucket})
|
||
warnings.append(f"存在规范外 dimension_key='{key}'({len(bucket)} 个节点)")
|
||
|
||
edges: list = []
|
||
dropped = 0
|
||
for e in edges_raw:
|
||
sid, tid = obj_by_kpi.get(e.source_kpi_id), obj_by_kpi.get(e.target_kpi_id)
|
||
if sid is None or tid is None:
|
||
dropped += 1
|
||
continue
|
||
edges.append({
|
||
"id": e.id,
|
||
"source_node_id": sid, "target_node_id": tid,
|
||
"source_kpi_id": e.source_kpi_id, "target_kpi_id": e.target_kpi_id,
|
||
"strength": float(e.strength) if e.strength is not None else None,
|
||
"lag_months": e.lag_months,
|
||
"direction": e.direction,
|
||
"formula": e.formula,
|
||
})
|
||
if dropped:
|
||
warnings.append(f"{dropped} 条因果链端点不在本图 19 节点内,已剔除")
|
||
|
||
asof = None
|
||
asof_rank = -1
|
||
for v in currents.values():
|
||
if period_rank(v.period) > asof_rank:
|
||
asof, asof_rank = v.period, period_rank(v.period)
|
||
|
||
with_actual = sum(1 for n in nodes if n["current"] is not None)
|
||
return {
|
||
"map": {
|
||
"id": m.id, "entity_id": m.entity_id, "title": m.title,
|
||
"version": m.version, "status": m.status,
|
||
"updated_at": m.updated_at.isoformat() if getattr(m, "updated_at", None) else None,
|
||
},
|
||
"period": chosen_period,
|
||
"periods": periods,
|
||
"data_asof": asof,
|
||
"layers": layers,
|
||
"edges": edges,
|
||
"stats": {
|
||
"nodes": len(nodes),
|
||
"with_actual": with_actual,
|
||
"missing_actual": len(nodes) - with_actual,
|
||
"edges": len(edges),
|
||
"by_level": {lv: sum(1 for n in nodes if n["level"] == lv) for lv in LEVEL_COLORS},
|
||
},
|
||
"caliber": {
|
||
"source": "app/utils/strategy_board.py",
|
||
"level_rule": "r=actual/target;>=1.0 success;>=0.8 warning;<0.8 danger;no actual info",
|
||
"inverse_whitelist": sorted(INVERSE_KPI_IDS),
|
||
"status_unit": STATUS_UNIT,
|
||
"actual_scope": "kpi_values 实测区(排除 estimated/forecast)最新期",
|
||
"zero_guard": "target 0/缺失 ⇒ info,禁 NaN/Inf",
|
||
},
|
||
"warnings": warnings,
|
||
}
|
||
|
||
|