Files
cma-management/backend/app/utils/review_builder.py
T
wecom-fullstack 424c33c085
ci/woodpecker/push/main Pipeline failed
feat(kpi,review): KPI direction 列 + KPITarget 模型 + 复盘产出方入库(R1 收口)
R1 收口批次(A 侧裁定 p12-ruling-input-r1-batch.RULING.md 主路径)· 单个快进提交:
- backend/app/models/__init__.py(+42/−2):CMA-KPI-20261003 阶段1 `direction`(KPIDefinition/KPITemplate)
  + ② KPITarget(kpi_targets,11 列 + uk_entity_kpi_period_type + 2 索引)
- 复盘产出方三件(review_builder 含 getattr(direction) 硬化 / review_run / 测试件),原始提交件 26dc242 的重铸件

收口边界申报:本提交**不含** D-1 批次(VoucherDetail.row_uid / ImportLog uq_import_logs_entity_batch_type)——
只读补证显示 prod `cma` 无该列/该唯一键(证据 out/r1-t1-batches.json + out/r1-t1-d1-placement.json),
携行会导致 deploy 后 voucher_details 整实体 SELECT/INSERT 报 1054;D-1 另单(先 DDL 后 ORM)。
2026-10-13 20:33:42 +08:00

444 lines
22 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# -*- coding: utf-8 -*-
"""复盘内容生成器 —— 从 live 数据构建「O/KR/KPI 现值 + 偏差 + 关联行动」(2026-10-12)
任务: yanxue-20261012-review-records-write(任总 2026-10-12 P0-1 立单)
背景: `review_records` 表 2026-08-27 建、78 天零行 —— 写入链路(API/前端)已通,
缺的是**产出真实复盘内容的生成器**与**触发口径**。本模块补前者。
设计纪律(为什么这么写):
1. **只读**:本模块只 SELECT,不产生任何写动作;落库由调用方(scripts/review_run.py)
经已部署的 `POST /api/cma/maps/{id}/review-records` 完成 —— 写入路径保持单一。
2. **不另立口径**:
· KPI 现值取数沿用实测区口径 `app.utils.kpi_value_scope.actual_only_clause()`
(预估行不作实测值),与 `maps.py /review`、看板同源;
· 期间过滤沿用 `period <= 当前月`(与 verify 引擎一致,不取未来期间);
· 目标值读 `target_monthly or target_value`(与 maps.py /review 一致)。
3. **方向/色阶按契约**(references/kpi-direction-and-attainment-contract.md):
· `direction IS NULL` **不默认正向** —— 标 `未配方向`,不做达成率判定;
· `unit='状态'` 走 1=达成 / 0、NULL=未达成,不走达成率;
· 目标 0 / 缺失 / 现值缺失 ⇒ 一律 `info`(灰),禁除零;
· **偏差 = 现值 − 目标**(有符号),不依赖方向 ⇒ 全量可比,是本复盘的主判据。
4. 关联行动按**库内既有挂靠**取(ActionPlan.kpi_id / objective_id / kr_id),
不发明新挂靠;挂靠缺失如实计入 `linkage_gaps`(这正是 PDCA 报告指出的「89% 行动未挂 O」)。
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.models import ActionPlan, KPIDefinition, KPIValue, KR, Objective, StrategicMap
from app.utils.kpi_value_scope import actual_only_clause
# 色阶阈值(与 maps.py /review 同口径:绿≥90% / 黄60-90% / 红<60%)
GREEN_RATIO = 0.9
YELLOW_RATIO = 0.6
REVIEW_TYPES = ("monthly", "quarterly")
REVIEW_TYPE_LABEL = {"monthly": "月度运营复盘", "quarterly": "季度战略回顾"}
def _f(v) -> Optional[float]:
"""JSON 列可能回读为 Decimal / str;转不了返回 None(不抛)。"""
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _period_limit(ref: Optional[str] = None) -> str:
"""现值期间上限:'YYYY-MM'(当月及以前;不取未来期间)。"""
if ref:
return str(ref)[:7]
return datetime.now().strftime("%Y-%m")
def _level_from_ratio(ratio: Optional[float]) -> str:
if ratio is None:
return "info"
if ratio >= GREEN_RATIO:
return "green"
if ratio >= YELLOW_RATIO:
return "yellow"
return "red"
def _kpi_reading(kpi: KPIDefinition, actual: Optional[float], target: Optional[float],
period: Optional[str]) -> Dict[str, Any]:
"""单个 KPI 的现值/偏差/判定。方向未配不判定(契约 §2)。"""
direction = (getattr(kpi, "direction", None) or "").strip() or None
out: Dict[str, Any] = {
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit,
"direction": direction or "unset",
"period": period,
"actual_value": actual,
"target_value": target,
"deviation": None,
"deviation_pct": None,
"attainment": None,
"level": "info",
"verdict": "无法判定",
}
if actual is not None and target is not None:
out["deviation"] = round(actual - target, 4)
if target != 0:
out["deviation_pct"] = round((actual - target) / abs(target) * 100, 2)
# 状态类:不走达成率(契约 §2)
if (kpi.unit or "").strip() == "状态":
if actual == 1:
out.update(level="green", verdict="达成")
else:
out.update(level="red", verdict="未达成")
return out
# 零点/空值保护(契约 §3)
if actual is None or target is None or target == 0:
out["verdict"] = "数据不足" if actual is None else "目标缺失/为0"
return out
# 方向未配 ⇒ 不判达成率,只给偏差(契约 §2:禁止默认正向)
if direction is None:
out["verdict"] = "方向未配,仅列现值/偏差"
return out
ratio = target / actual if direction == "lower_better" else actual / target
if ratio is None or ratio != ratio or ratio in (float("inf"), float("-inf")):
out["verdict"] = "数据异常"
return out
out["attainment"] = round(ratio, 4)
out["level"] = _level_from_ratio(ratio)
out["verdict"] = {"green": "达标", "yellow": "接近达标", "red": "未达标"}[out["level"]]
return out
def _latest_kpi_values(db: Session, kpi_ids: List[int], entity_id: int,
limit: str) -> Dict[int, Dict[str, Any]]:
"""每个 KPI 取 period <= 当前月 的最新一条**实测区**值(预估行不作实测)。"""
out: Dict[int, Dict[str, Any]] = {}
for kid in kpi_ids:
v = (
db.query(KPIValue)
.filter(
KPIValue.kpi_id == kid,
KPIValue.entity_id == entity_id,
KPIValue.actual_value.isnot(None),
KPIValue.period <= limit,
actual_only_clause(),
)
.order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc())
.first()
)
if v:
out[kid] = {"actual_value": _f(v.actual_value), "period": v.period,
"source_type": v.source_type, "data_status": v.data_status}
return out
def build_review(db: Session, entity_id: int, map_id: int, review_type: str = "quarterly",
period_ref: Optional[str] = None, review_date: Optional[date] = None,
top_n: int = 8) -> Dict[str, Any]:
"""构建一条复盘记录载荷(只读)。
返回 {summary, adjustments, next_priorities, snapshot, meta}:
· summary 人类可读复盘结论(落 review_records.summary,复盘页可直接读回)
· adjustments 结构化「O/KR/KPI 现值 + 偏差 + 关联行动」清单
(落 review_records.adjustments;条目形如
{type, target, action, current_value, target_value, deviation, level})
· next_priorities 下期优先级(落 review_records.next_priorities)
· snapshot 本次复盘的只读数据快照(供 --print/留痕;DB 侧由 adjustments 承载)
"""
if review_type not in REVIEW_TYPES:
raise ValueError(f"review_type 必须是 {REVIEW_TYPES} 之一,收到 {review_type!r}")
review_date = review_date or datetime.now().date()
limit = _period_limit(period_ref)
m = db.query(StrategicMap).filter(StrategicMap.id == map_id,
StrategicMap.entity_id == entity_id).first()
if not m:
raise ValueError(f"战略地图不存在: map_id={map_id} entity_id={entity_id}")
dims = m.dimensions
if isinstance(dims, str):
import json as _json
dims = _json.loads(dims)
dims = dims or []
# ── 1. 战略地图维度节点(O 的战略面)───────────────────────────────
map_nodes: List[Dict[str, Any]] = []
map_kpi_codes: set[str] = set()
for dim in dims:
for obj in dim.get("objectives") or []:
map_nodes.append({"dimension": dim.get("key"), "dimension_name": dim.get("name"),
"name": obj.get("name"), "kpis": obj.get("kpis") or []})
map_kpi_codes.update(obj.get("kpis") or [])
# ── 2. 目标级 O / KR ─────────────────────────────────────────────
objectives = (
db.query(Objective).filter(Objective.entity_id == entity_id).order_by(Objective.id).all()
)
obj_ids = [o.id for o in objectives]
krs = (
db.query(KR).filter(KR.entity_id == entity_id, KR.objective_id.in_(obj_ids or [0]))
.order_by(KR.id).all()
) if obj_ids else []
obj_by_id = {o.id: o for o in objectives}
kr_kpi_ids = [k.metric_kpi_id for k in krs if k.metric_kpi_id]
# ── 3. 关联行动(按库内既有挂靠:kpi_id / objective_id / kr_id)─────
plans = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).all()
kpi_ids_from_plans = sorted({p.kpi_id for p in plans if p.kpi_id})
kpi_defs = (
db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id,
(KPIDefinition.id.in_(set(kr_kpi_ids) | set(kpi_ids_from_plans)) if
(kr_kpi_ids or kpi_ids_from_plans) else False))
.all()
) if (kr_kpi_ids or kpi_ids_from_plans) else []
if map_kpi_codes:
kpi_defs = kpi_defs + db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(map_kpi_codes),
).all()
seen_kpi_ids: set[int] = set()
kpi_defs = [k for k in kpi_defs if not (k.id in seen_kpi_ids or seen_kpi_ids.add(k.id))]
kpi_ids = [k.id for k in kpi_defs]
kpi_by_id = {k.id: k for k in kpi_defs}
latest = _latest_kpi_values(db, kpi_ids, entity_id, limit)
kpi_rows: List[Dict[str, Any]] = []
for k in sorted(kpi_defs, key=lambda x: (x.kpi_code or "")):
lv = latest.get(k.id) or {}
target = _f(k.target_monthly) if k.target_monthly is not None else _f(k.target_value)
row = _kpi_reading(k, lv.get("actual_value"), target, lv.get("period"))
row["kpi_status"] = k.status
row["kpi_level"] = k.kpi_level
kpi_rows.append(row)
# 关联行动:kpi_id / objective_id / kr_id 任一命中即关联(不发明挂靠)
in_scope_kpi_ids = {r["kpi_id"] for r in kpi_rows}
kr_ids = {k.id for k in krs}
action_rows: List[Dict[str, Any]] = []
for p in plans:
hit = []
if p.kpi_id and p.kpi_id in in_scope_kpi_ids:
hit.append("kpi")
if p.objective_id and p.objective_id in set(obj_ids):
hit.append("objective")
if p.kr_id and p.kr_id in kr_ids:
hit.append("kr")
if not hit:
continue
k = kpi_by_id.get(p.kpi_id)
action_rows.append({
"plan_id": p.id,
"title": p.title,
"status": p.status,
"progress": p.progress or 0,
"assignee": p.assignee,
"due_date": p.due_date.date().isoformat() if p.due_date else None,
"verify_status": p.verify_status,
"linked_by": hit,
"kpi_code": k.kpi_code if k else None,
"kpi_name": k.kpi_name if k else None,
"objective_id": p.objective_id,
"kr_id": p.kr_id,
})
# ── 4. 偏差清单(主判据:现值 − 目标;方向未配者只列偏差不判达标)────
# 可比偏差:目标已设且非 0 且现值可用。目标为 0/未设者单位间不可比,
# 单列 `unset_target`(这本身是「补齐 KPI 目标」的待办,不混进排名)。
_rank = {"red": 0, "yellow": 1, "green": 2, "info": 3}
comparable = [r for r in kpi_rows
if r["deviation"] is not None and (r["target_value"] or 0) != 0]
unset_target = [r for r in kpi_rows if (r["target_value"] or 0) == 0]
# 排序:非达标(红/黄/未配方向不可判)在前,各自按 |偏差%| 降序 —— 复盘的"要点先看"。
_bad = sorted([r for r in comparable if r["level"] != "green"],
key=lambda r: (-abs(r["deviation_pct"] or 0), r["kpi_code"] or ""))
_ok = sorted([r for r in comparable if r["level"] == "green"],
key=lambda r: (-abs(r["deviation_pct"] or 0), r["kpi_code"] or ""))
deviations = _bad + _ok
linkage_gaps = {
"objectives_without_map": sum(1 for o in objectives if not o.strategic_map_id),
"objectives_without_quarter": sum(1 for o in objectives if not (o.quarter or "").strip()),
"krs_without_kpi": sum(1 for k in krs if not k.metric_kpi_id),
"krs_without_due_date": sum(1 for k in krs if not k.due_date),
"krs_progress_zero": sum(1 for k in krs if not (k.progress or 0)),
"plans_total": len(plans),
"plans_linked_objective": sum(1 for p in plans if p.objective_id),
"plans_linked_kr": sum(1 for p in plans if p.kr_id),
"plans_linked_kpi": sum(1 for p in plans if p.kpi_id),
"plans_with_rule": sum(1 for p in plans if p.auto_verify_rule),
"plans_without_due_date": sum(1 for p in plans if not p.due_date),
}
# ── 5. 结构化「调整项 / 现值+偏差+行动」清单(落 adjustments)────────
adjustments: List[Dict[str, Any]] = []
for r in kpi_rows:
linked = [a["title"] for a in action_rows if a["kpi_code"] == r["kpi_code"]]
adjustments.append({
"type": "kpi",
"target": r["kpi_code"],
"target_name": r["kpi_name"],
"action": (";".join(linked) if linked else "未挂行动计划,需补挂责任人与规则"),
"current_value": r["actual_value"],
"target_value": r["target_value"],
"deviation": r["deviation"],
"deviation_pct": r["deviation_pct"],
"period": r["period"],
"unit": r["unit"],
"level": r["level"],
"verdict": r["verdict"],
"linked_plan_ids": [a["plan_id"] for a in action_rows if a["kpi_code"] == r["kpi_code"]],
})
for k in krs:
o = obj_by_id.get(k.objective_id)
adjustments.append({
"type": "kr",
"target": k.title,
"target_name": f"{o.title}/{k.title}" if o else k.title,
"action": (f"目标值 {_f(k.target_value)},现值 {_f(k.current_value)}"
if k.current_value is not None else "尚无实测现值,需接 KPI 或人工填报"),
"current_value": _f(k.current_value),
"target_value": _f(k.target_value),
"deviation": (round(_f(k.current_value) - _f(k.target_value), 4)
if k.current_value is not None and k.target_value is not None else None),
"progress": k.progress or 0,
"status": k.status,
"due_date": k.due_date.date().isoformat() if k.due_date else None,
"level": "info",
"verdict": ("进行中" if (k.progress or 0) > 0 else "无进度"),
})
for o in objectives:
adjustments.append({
"type": "objective",
"target": o.title,
"target_name": o.title,
"action": (f"季度 {o.quarter}" if (o.quarter or "").strip() else "未填季度,需补 SMARTER 的 T"),
"dimension": o.dimension,
"current_value": o.progress,
"target_value": 100,
"progress": o.progress or 0,
"status": o.status,
"owner": o.owner,
"level": "info",
"verdict": "未开工" if not (o.progress or 0) else "进行中",
})
# ── 6. 下期优先级(确定性规则,不用 LLM)────────────────────────────
priorities: List[str] = []
for r in _bad[:3]:
priorities.append(
f"{r['kpi_code']} 偏差 {r['deviation']:+.2f}{r['unit'] or ''}"
f"(现值 {r['actual_value']} / 目标 {r['target_value']},{r['verdict']})"
)
if unset_target:
priorities.append(f"补齐 KPI 目标:{len(unset_target)} 项目标为 0/未设,无目标即无法判达标"
f"(如 {', '.join(r['kpi_code'] for r in unset_target[:4])})")
if linkage_gaps["krs_progress_zero"]:
priorities.append(f"KR 进度回填:{linkage_gaps['krs_progress_zero']}/{len(krs)} 条进度为 0,接 KPI 自动算进度或人工填报")
if linkage_gaps["plans_linked_kr"] < linkage_gaps["plans_total"]:
priorities.append(f"行动挂靠补齐:{linkage_gaps['plans_total'] - linkage_gaps['plans_linked_kr']} 条行动未挂 KR(现挂 KR {linkage_gaps['plans_linked_kr']} 条)")
if linkage_gaps["krs_without_due_date"]:
priorities.append(f"KR 截止日期补齐:{linkage_gaps['krs_without_due_date']}/{len(krs)} 条无 due_date")
if linkage_gaps["objectives_without_quarter"]:
priorities.append(f"目标季度补齐:{linkage_gaps['objectives_without_quarter']}/{len(objectives)} 条未填 quarter")
# ── 7. 人读结论(落 review_records.summary,复盘页时间线直接渲染)────
label = REVIEW_TYPE_LABEL[review_type]
lines: List[str] = []
lines.append(f"【{label}】{m.title}")
lines.append(f"复盘日期 {review_date.isoformat()}|口径:KPI 现值取实测区最新值(period ≤ {limit}),"
f"偏差 = 现值 − 目标(有符号)")
lines.append("")
lines.append(f"一、目标级(小 PDCA)现状:O {len(objectives)} 条、KR {len(krs)} 条;"
f"O 进度非零 {len(objectives) - sum(1 for o in objectives if not (o.progress or 0))} 条、"
f"KR 有进度 {len(krs) - linkage_gaps['krs_progress_zero']} 条、"
f"KR 已挂 KPI {len(krs) - linkage_gaps['krs_without_kpi']} 条。")
dims_summary = ", ".join(f"{n['dimension_name']}({sum(1 for x in map_nodes if x['dimension'] == n['dimension'])})"
for n in [{"dimension": d.get("key"), "dimension_name": d.get("name")} for d in dims])
lines.append(f"  战略地图节点 {len(map_nodes)} 个:{dims_summary}")
lines.append("")
lines.append(f"二、KPI 现值与偏差({len(kpi_rows)} 项纳入复盘;未达标/不可判在前,各按 |偏差%| 降序,"
f"列前 {min(top_n, len(deviations))}):")
if deviations:
for r in deviations[:top_n]:
pct = (f"({r['deviation_pct']:+.1f}%)" if r["deviation_pct"] is not None else "(目标缺失,无百分比)")
lines.append(f" · {r['kpi_code']} {r['kpi_name']}:现值 {r['actual_value']}{r['unit'] or ''}"
f"({r['period']})/ 目标 {r['target_value']}{r['unit'] or ''}"
f" 偏差 {r['deviation']:+.2f}{r['unit'] or ''}"
f"{pct} 判定:{r['verdict']}")
else:
lines.append(" · 本期无可比偏差(无「目标已设且现值可用」的 KPI)")
if unset_target:
lines.append(f" (另 {len(unset_target)} 项 KPI 目标为 0 或未设 ⇒ 无可比偏差,需先补目标:"
+ ", ".join(r["kpi_code"] for r in unset_target[:6])
+ ("…" if len(unset_target) > 6 else "") + ")")
no_dir = [r for r in kpi_rows if r["direction"] == "unset"]
if no_dir:
lines.append(f" (其中 {len(no_dir)} 项 KPI 未配方向,按契约只列现值/偏差、不作达标判定:"
+ ", ".join(r["kpi_code"] for r in no_dir[:6])
+ ("…" if len(no_dir) > 6 else "") + ")")
lines.append("")
lines.append(f"三、关联行动:命中 {len(action_rows)} 条(挂 KPI {sum(1 for a in action_rows if 'kpi' in a['linked_by'])}、"
f"挂 O {sum(1 for a in action_rows if 'objective' in a['linked_by'])}、"
f"挂 KR {sum(1 for a in action_rows if 'kr' in a['linked_by'])});"
f"状态 completed {sum(1 for a in action_rows if a['status'] == 'completed')} / "
f"in_progress {sum(1 for a in action_rows if a['status'] == 'in_progress')} / "
f"pending {sum(1 for a in action_rows if a['status'] == 'pending')}")
lines.append(f"  全量行动 {linkage_gaps['plans_total']} 条中:挂 O {linkage_gaps['plans_linked_objective']} 条、"
f"挂 KR {linkage_gaps['plans_linked_kr']} 条、已配验证规则 {linkage_gaps['plans_with_rule']} 条、"
f"无截止日期 {linkage_gaps['plans_without_due_date']} 条")
lines.append("")
lines.append("四、为什么没达成 / 关键偏差:")
if deviations:
top = deviations[0]
lines.append(f" · 最大偏差项 {top['kpi_code']}:{top['verdict']},偏差 {top['deviation']:+.2f}{top['unit'] or ''}"
f"(现值 {top['actual_value']} vs 目标 {top['target_value']})")
lines.append(f" · 结构性问题:O 未挂战略地图 {linkage_gaps['objectives_without_map']} 条、"
f"未填季度 {linkage_gaps['objectives_without_quarter']} 条;KR 无 KPI 挂靠 {linkage_gaps['krs_without_kpi']} 条、"
f"无截止日期 {linkage_gaps['krs_without_due_date']} 条;行动未挂 KR {linkage_gaps['plans_total'] - linkage_gaps['plans_linked_kr']} 条")
lines.append("")
lines.append("五、下期优先级:")
for i, p in enumerate(priorities, 1):
lines.append(f" {i}. {p}")
summary = "\n".join(lines)
snapshot = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"entity_id": entity_id,
"map_id": map_id,
"map_title": m.title,
"review_type": review_type,
"review_date": review_date.isoformat(),
"period_limit": limit,
"map_nodes": map_nodes,
"kpis": kpi_rows,
"kpis_unset_target": [r["kpi_code"] for r in unset_target],
"objectives": [{"id": o.id, "title": o.title, "dimension": o.dimension,
"quarter": o.quarter, "status": o.status, "progress": o.progress,
"owner": o.owner} for o in objectives],
"krs": [{"id": k.id, "objective_id": k.objective_id, "title": k.title,
"metric_kpi_id": k.metric_kpi_id, "target_value": _f(k.target_value),
"current_value": _f(k.current_value), "progress": k.progress,
"status": k.status} for k in krs],
"actions": action_rows,
"linkage_gaps": linkage_gaps,
}
return {"summary": summary, "adjustments": adjustments,
"next_priorities": priorities, "snapshot": snapshot,
"meta": {"entity_id": entity_id, "map_id": map_id, "review_type": review_type,
"review_date": review_date.isoformat(), "period_limit": limit,
"kpi_count": len(kpi_rows), "action_count": len(action_rows)}}