feat(kpi,review): KPI direction 列 + KPITarget 模型 + 复盘产出方入库(R1 收口)
ci/woodpecker/push/main Pipeline failed

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)。
This commit is contained in:
wecom-fullstack
2026-10-13 20:33:42 +08:00
parent 4cdd30e321
commit 424c33c085
4 changed files with 914 additions and 2 deletions
+443
View File
@@ -0,0 +1,443 @@
# -*- 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)}}