# -*- 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)}}