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
+42 -2
View File
@@ -1,5 +1,5 @@
"""管理会计OS 数据模型"""
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint, Numeric, Date
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, text, UniqueConstraint, Index, Numeric, Date
from app.database import Base
from app.models.budget_plan import BudgetPlan
@@ -93,7 +93,13 @@ class KPIDefinition(Base):
data_owner = Column(String(100), default="待指定", comment="数据责任人")
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
unit = Column(String(50), default="%", comment="单位")
target_value = Column(Float, nullable=True, comment="目标值(兼容旧字段)")
# 2026-10-03 KPI字典分层治理(任务 CMA-KPI-20261003)阶段1:direction 结构列。
# 枚举 higher_better(越大越好)/lower_better(越小越好),NULL=未定。
# 既有行由 migrate_kpi_direction_20261003.py 按 threshold_green 比较符**结构推定**回填
# (非口径裁定);口径/阈值语义仍属 finance/DAMA 在途批次。
direction = Column(String(16), nullable=True,
comment="指标方向: higher_better越大越好/lower_better越小越好/NULL未定")
target_value = Column(Float, nullable=True, comment="目标值(NULL=未设目标;0=真零目标;兼容旧字段)")
target_monthly = Column(Float, nullable=True, comment="月度目标值")
target_quarterly = Column(Float, nullable=True, comment="季度目标值")
target_yearly = Column(Float, nullable=True, comment="年度目标值")
@@ -491,6 +497,10 @@ class KPITemplate(Base):
formula = Column(Text)
formula_desc = Column(String(500))
unit = Column(String(50))
# 2026-10-03 KPI字典分层治理(任务 CMA-KPI-20261003)阶段1:字典层同加 direction;
# 回填规则 = 继承同 kpi_code 实例的已定方向(见 docs/kpi-dictionary-layering-design.md §2.2)。
direction = Column(String(16), nullable=True,
comment="指标方向: higher_better越大越好/lower_better越小越好/NULL未定")
target_value = Column(Float)
description = Column(Text)
is_system = Column(Integer, default=0)
@@ -864,6 +874,36 @@ class ObjectiveKPI(Base):
__table_args__ = (UniqueConstraint("objective_id", "kpi_id", name="uk_obj_kpi"),)
class KPITarget(Base):
"""KPI 期间目标(考核型·方案 A)—— ③ 读点收敛 CMA-KPI-20261003
唯一真源(考核型):`(entity_id, kpi_id, period, target_type)` 唯一;
承诺型目标只写 `krs`(裁定 §二 边界声明:同一 KPI 不得两处均有 target)。
**列定义与 ② DDL 出件包 `up.sql` 逐字一致**(11 列 + uk_entity_kpi_period_type + 2 索引);
`entity_id` 无 DDL DEFAULT(写入口必须显式传值,防静默默认把实体隔离降级为约定);
`updated_at` 无 `ON UPDATE CURRENT_TIMESTAMP`(由 ORM/写入口维护,与现网 kpi_definitions 同风格)。
"""
__tablename__ = "kpi_targets"
id = Column(Integer, primary_key=True, autoincrement=True)
entity_id = Column(Integer, nullable=False, comment="账套(企业ID);写入口必须显式传值(实体隔离)")
kpi_id = Column(Integer, nullable=False, comment="指标实例 → kpi_definitions.id")
period = Column(String(20), nullable=False, comment="期间: 2026-08(月)/2026Q3(季)/2026(年)")
target_type = Column(String(16), nullable=False, comment="目标粒度: monthly/quarterly/yearly")
target_value = Column(Numeric(15, 2), nullable=True, comment="NULL=该期未设;0=真零")
source = Column(String(20), nullable=False, server_default=text("'manual'"),
comment="manual/okr/budget/import")
note = Column(String(255), nullable=True, comment="备注")
created_by = Column(String(64), nullable=True, comment="写入者(用户/Bot标识)")
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now())
__table_args__ = (
UniqueConstraint("entity_id", "kpi_id", "period", "target_type", name="uk_entity_kpi_period_type"),
Index("ix_kpi_targets_entity_period", "entity_id", "period"),
Index("ix_kpi_targets_kpi", "kpi_id"),
)
class KR(Base):
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
__tablename__ = "krs"
+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)}}
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""复盘落地执行器 —— 生成「月度运营复盘 / 季度战略回顾」并经既有 API 落库(2026-10-12)
任务: yanxue-20261012-review-records-write(任总 2026-10-12 P0-1)
链路: app/utils/review_builder.build_review(只读算)
→ POST /api/cma/maps/{map_id}/review-records(**唯一写入口**,已部署端点,不改后端)
→ GET /api/cma/maps/{map_id}/review-records(读回校验)
用法(默认只预览,不写):
cd /srv/hermes/shared/cma-management/backend
./venv/bin/python3 scripts/review_run.py --entity 2 --map 49 --type monthly --print
./venv/bin/python3 scripts/review_run.py --entity 2 --map 49 --type quarterly --write \
--evidence /path/evidence.json
写动作纪律:
· **幂等(2026-10-13 P1-1)**:--write 前先按同一读路径列出该 map 的复盘;同日同 review_type 已存在 ⇒ NOOP 零写(`--force` 才强制再写)。
· 幂等闸读不到(列表接口非 200)⇒ **fail-closed 不写**(rc=6),宁可缺一条也不产重复行。
· 本脚本不做任何直接 SQL 写;落库一律经 HTTP API(走鉴权/多租户/审计同一路径)。
· 凭据进程内从 600 权限文件读取(--admin-env 或 $CMA_ADMIN_ENV_FILE),不入命令行/日志。
· 生产写前先过 approval-gate(调用方负责),本脚本只负责「按放行结果执行一次」。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import date, datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_db # noqa: E402
from app.utils.review_builder import ( # noqa: E402
REVIEW_TYPES, build_review,
)
def _load_env_file(path: str) -> dict:
d = {}
with open(path) as fh:
for line in fh:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
d[k.strip()] = v.strip()
return d
def _http(method: str, url: str, token=None, body=None, timeout=30):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:800]
except Exception as e: # noqa: BLE001
return -1, repr(e)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--entity", type=int, required=True)
ap.add_argument("--map", type=int, required=True, dest="map_id")
ap.add_argument("--type", required=True, choices=list(REVIEW_TYPES))
ap.add_argument("--date", default=None, help="复盘日期 YYYY-MM-DD(缺省今天)")
ap.add_argument("--period", default=None, help="现值期间上限 YYYY-MM(缺省当月)")
ap.add_argument("--api-base", default="http://127.0.0.1:8010")
ap.add_argument("--admin-env", default=os.environ.get("CMA_ADMIN_ENV_FILE", ""),
help="登录凭据文件(600);仅 --write 需要")
ap.add_argument("--print", dest="do_print", action="store_true", help="打印复盘全文(预览)")
ap.add_argument("--json-out", default=None, help="把生成载荷落 JSON(留痕)")
ap.add_argument("--write", action="store_true", help="经 API 落库(默认不写)")
ap.add_argument("--force", action="store_true",
help="跳过同日同 type 幂等闸(默认关;常态化触发勿用)")
ap.add_argument("--evidence", default=None, help="写模式证据 JSON 落盘路径")
args = ap.parse_args()
rdate = datetime.strptime(args.date, "%Y-%m-%d").date() if args.date else date.today()
db = next(get_db())
try:
payload = build_review(db, entity_id=args.entity, map_id=args.map_id,
review_type=args.type, period_ref=args.period,
review_date=rdate)
finally:
try:
db.close()
except Exception: # noqa: BLE001
pass
post_body = {
"review_type": args.type,
"review_date": rdate.isoformat(),
"summary": payload["summary"],
"adjustments": payload["adjustments"],
"next_priorities": payload["next_priorities"],
}
if args.do_print:
print(payload["summary"])
print("\n--- next_priorities ---")
for i, p in enumerate(payload["next_priorities"], 1):
print(f"{i}. {p}")
print(f"\n--- meta --- {json.dumps(payload['meta'], ensure_ascii=False)}")
print(f"--- adjustments 条目数 {len(payload['adjustments'])} "
f"(kpi {sum(1 for a in payload['adjustments'] if a['type'] == 'kpi')} / "
f"kr {sum(1 for a in payload['adjustments'] if a['type'] == 'kr')} / "
f"objective {sum(1 for a in payload['adjustments'] if a['type'] == 'objective')})")
if args.json_out:
with open(args.json_out, "w") as fh:
json.dump({"post_body": post_body, "snapshot": payload["snapshot"]},
fh, ensure_ascii=False, indent=1, default=str)
print(f"[json-out] {args.json_out}")
if not args.write:
print("[preview] 未写库(需 --write)")
return 0
if not args.admin_env or not os.path.exists(args.admin_env):
print(f"❌ --write 需要凭据文件(--admin-env 或 $CMA_ADMIN_ENV_FILE):{args.admin_env}")
return 2
cred = _load_env_file(args.admin_env)
st, res = _http("POST", f"{args.api_base}/api/cma/auth/login", body={
"username": cred["CMA_ADMIN_USER"],
"password": cred["CMA_ADMIN_PASS"],
"entity_id": int(cred["CMA_ADMIN_ENTITY_ID"]),
})
if st != 200:
print(f"❌ 登录失败 {st}: {res}")
return 3
token = res["token"]
# ---- 幂等闸(P1-1):同日同 type 已存在 ⇒ NOOP(零写)----
if not args.force:
st_l0, listed0 = _http("GET",
f"{args.api_base}/api/cma/maps/{args.map_id}/review-records",
token=token)
if st_l0 != 200 or not isinstance(listed0, dict):
print(f"❌ 幂等闸无法读取历史复盘(GET list={st_l0})⇒ fail-closed 不写,rc=6")
return 6
existing_id = None
for r0 in listed0.get("data", []):
if (r0.get("review_type") == args.type
and str(r0.get("review_date") or "")[:10] == rdate.isoformat()):
existing_id = r0.get("id")
break
if existing_id is not None:
print(f"[idempotent] {args.type} / {rdate.isoformat()} 已存在 id={existing_id} ⇒ NOOP(零写)")
if args.evidence:
with open(args.evidence, "w") as fh:
json.dump({"task": "yanxue-20261012-review-records-write",
"at": datetime.now().isoformat(timespec="seconds"),
"entity_id": args.entity, "map_id": args.map_id,
"review_type": args.type, "review_date": rdate.isoformat(),
"action": "NOOP", "record_id": existing_id,
"idempotent_guard": True, "list_status": st_l0,
"reason": "same review_type+review_date already present"},
fh, ensure_ascii=False, indent=1, default=str)
print(f"[evidence] {args.evidence}")
return 0
st, created = _http("POST",
f"{args.api_base}/api/cma/maps/{args.map_id}/review-records",
token=token, body=post_body)
print(f"[write] POST review-records -> {st}")
if st not in (200, 201):
print(f"❌ 落库失败: {created}")
return 4
record_id = created.get("id") if isinstance(created, dict) else None
# 读回校验(同 API,与复盘页同一读路径)
st_l, listed = _http("GET", f"{args.api_base}/api/cma/maps/{args.map_id}/review-records",
token=token)
st_d, detail = _http("GET",
f"{args.api_base}/api/cma/maps/{args.map_id}/review-records/{record_id}",
token=token)
ok = (st_l == 200 and st_d == 200
and isinstance(listed, dict) and any(r.get("id") == record_id
for r in listed.get("data", [])))
print(f"[read-back] list={st_l} detail={st_d} 命中记录 {record_id}: {ok}")
if args.evidence:
with open(args.evidence, "w") as fh:
json.dump({
"task": "yanxue-20261012-review-records-write",
"at": datetime.now().isoformat(timespec="seconds"),
"entity_id": args.entity, "map_id": args.map_id, "review_type": args.type,
"review_date": rdate.isoformat(),
"record_id": record_id,
"post_status": st, "list_status": st_l, "detail_status": st_d,
"read_back_ok": ok,
"meta": payload["meta"],
"created": created,
"read_back": detail if isinstance(detail, dict) else detail,
}, fh, ensure_ascii=False, indent=1, default=str)
print(f"[evidence] {args.evidence}")
return 0 if ok else 5
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,220 @@
"""复盘内容生成器 + 落库链路测试(任务 yanxue-20261012-review-records-write)
覆盖:
1. build_review 只读产出 summary / adjustments / next_priorities,含 KPI 现值+偏差+关联行动
2. 方向契约: lower_better 用 target/actual;direction IS NULL **不**默认正向(不作达标判定);
unit='状态' 走 1=达成;目标 0 / 现值为空 ⇒ 不崩、不除零
3. 期间口径: period <= 当前月(不取未来期间);预估行(estimated/forecast)不作实测现值
4. 落库链路: POST /maps/{id}/review-records → GET 读回(同一读路径,复盘页同源)
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import (ActionPlan, KPIDefinition, KPIValue, KR, Objective,
StrategicMap)
from app.utils.review_builder import build_review
from tests.conftest import auth_header, create_test_user, get_token_for_user
def _map(db: Session, entity_id: int = 1) -> StrategicMap:
m = StrategicMap(
entity_id=entity_id,
title="测试战略地图",
version="v1.0",
status="published",
dimensions=[{"key": "finance", "name": "财务层", "icon": "F", "color": "#fff",
"objectives": [{"name": "营收目标", "kpis": ["RB_REV"]}]}],
)
db.add(m)
db.commit()
db.refresh(m)
return m
def _bind_map(db: Session, m: StrategicMap, *codes: str) -> StrategicMap:
"""把 KPI code 挂进地图维度节点 —— 复盘范围 = 地图节点 KPI ∪ KR 挂靠 KPI ∪ 行动挂靠 KPI。"""
dims = list(m.dimensions)
dims[0] = dict(dims[0])
dims[0]["objectives"] = [{"name": dims[0]["objectives"][0]["name"],
"kpis": ["RB_REV"] + list(codes)}]
m.dimensions = dims
db.commit()
db.refresh(m)
return m
def _kpi(db: Session, code: str, **kw) -> KPIDefinition:
defaults = dict(entity_id=1, kpi_code=code, kpi_name=code + "名", unit="万元",
direction="higher_better", target_value=100.0, status="active")
defaults.update(kw)
k = KPIDefinition(**defaults)
db.add(k)
db.commit()
db.refresh(k)
return k
def _value(db: Session, kpi: KPIDefinition, value, period: str, **kw) -> KPIValue:
v = KPIValue(entity_id=kpi.entity_id, kpi_id=kpi.id, period=period, actual_value=value, **kw)
db.add(v)
db.commit()
db.refresh(v)
return v
def _seed_min(db: Session):
m = _map(db)
obj = Objective(entity_id=1, title="扩大收入规模", dimension="finance", quarter="",
status="active", progress=0)
db.add(obj)
db.commit()
db.refresh(obj)
kr = KR(entity_id=1, objective_id=obj.id, title="营收增长率≥20%", target_value=20,
progress=0, status="pending")
db.add(kr)
db.commit()
db.refresh(kr)
kpi = _kpi(db, "RB_REV")
_value(db, kpi, 80.0, datetime.now().strftime("%Y-%m"))
plan = ActionPlan(entity_id=1, kpi_id=kpi.id, title="营收攻坚行动", status="pending",
progress=0, verify_status="pending")
db.add(plan)
db.commit()
db.refresh(plan)
return m, obj, kr, kpi, plan
class TestBuildReviewReadonly:
def test_payload_shape_and_content(self, db: Session):
m, obj, kr, kpi, plan = _seed_min(db)
out = build_review(db, entity_id=1, map_id=m.id, review_type="monthly")
assert out["summary"].strip()
assert "RB_REV" in out["summary"] and "偏差" in out["summary"]
types = {a["type"] for a in out["adjustments"]}
assert {"kpi", "kr", "objective"} <= types
kpi_row = next(a for a in out["adjustments"] if a["type"] == "kpi")
assert kpi_row["target"] == "RB_REV"
assert kpi_row["current_value"] == 80.0
assert kpi_row["target_value"] == 100.0
assert kpi_row["deviation"] == -20.0 # 现值 − 目标(有符号)
# 关联行动: 按 kpi_id 既有挂靠命中
assert plan.id in kpi_row["linked_plan_ids"]
assert out["next_priorities"]
assert out["meta"]["kpi_count"] == 1
def test_readonly_no_write(self, db: Session):
"""生成器只读:不得改动任何行(回查计数与值不变)"""
m, obj, kr, kpi, plan = _seed_min(db)
before = (db.query(KR).count(), db.query(ActionPlan).count(),
db.query(KPIValue).count(), kr.progress, plan.status)
build_review(db, entity_id=1, map_id=m.id, review_type="quarterly")
after = (db.query(KR).count(), db.query(ActionPlan).count(),
db.query(KPIValue).count(), kr.progress, plan.status)
assert before == after
class TestDirectionContract:
def test_lower_better_uses_target_over_actual(self, db: Session):
m = _map(db)
_bind_map(db, m, "RB_COST")
k = _kpi(db, "RB_COST", unit="%", direction="lower_better", target_value=20.0)
_value(db, k, 10.0, datetime.now().strftime("%Y-%m"))
out = build_review(db, entity_id=1, map_id=m.id, review_type="monthly")
row = next(a for a in out["adjustments"] if a["target"] == "RB_COST")
assert row["level"] == "green" and row["verdict"] == "达标"
def test_direction_null_not_defaulted_to_positive(self, db: Session):
"""契约 §2:direction IS NULL 不默认正向 —— 不判达标,只列现值/偏差"""
m = _map(db)
_bind_map(db, m, "RB_NULLDIR")
k = _kpi(db, "RB_NULLDIR", direction=None, target_value=150.0)
_value(db, k, 186.0, datetime.now().strftime("%Y-%m"))
out = build_review(db, entity_id=1, map_id=m.id, review_type="monthly")
row = next(a for a in out["adjustments"] if a["target"] == "RB_NULLDIR")
assert row["level"] == "info"
assert "方向未配" in row["verdict"]
assert row["deviation"] == 36.0
def test_status_unit_uses_flag(self, db: Session):
"""契约 §2:unit='状态' 走 1=达成 / 0=未达成,不走达成率"""
m = _map(db)
_bind_map(db, m, "RB_FLAG")
k = _kpi(db, "RB_FLAG", unit="状态", direction=None, target_value=1)
_value(db, k, 1, datetime.now().strftime("%Y-%m"))
out = build_review(db, entity_id=1, map_id=m.id, review_type="monthly")
row = next(a for a in out["adjustments"] if a["target"] == "RB_FLAG")
assert row["level"] == "green" and row["verdict"] == "达成"
def test_zero_target_and_missing_actual_no_crash(self, db: Session):
"""契约 §3:目标 0 / 现值缺失 ⇒ 不除零、不 NaN,落 info"""
m = _map(db)
_bind_map(db, m, "RB_ZERO", "RB_NOACT")
k0 = _kpi(db, "RB_ZERO", target_value=0.0)
_value(db, k0, 5.0, datetime.now().strftime("%Y-%m"))
k_null = _kpi(db, "RB_NOACT", target_value=10.0) # 无 KPIValue
out = build_review(db, entity_id=1, map_id=m.id, review_type="monthly")
r0 = next(a for a in out["adjustments"] if a["target"] == "RB_ZERO")
rn = next(a for a in out["adjustments"] if a["target"] == "RB_NOACT")
assert r0["level"] == "info" and r0["deviation_pct"] is None
assert rn["current_value"] is None and rn["level"] == "info"
class TestPeriodCaliber:
def test_future_period_excluded(self, db: Session):
"""期间口径:period <= 当前月(不取未来期间)"""
m = _map(db)
_bind_map(db, m, "RB_FUT")
k = _kpi(db, "RB_FUT", target_value=100.0)
_value(db, k, 50.0, datetime.now().strftime("%Y-%m"))
future = (datetime.now() + timedelta(days=62)).strftime("%Y-%m")
_value(db, k, 150.0, future)
out = build_review(db, entity_id=1, map_id=m.id, review_type="monthly")
row = next(a for a in out["adjustments"] if a["target"] == "RB_FUT")
assert row["current_value"] == 50.0
def test_forecast_row_not_used_as_actual(self, db: Session):
"""实测区口径:estimated / forecast 行不作实测现值"""
m = _map(db)
_bind_map(db, m, "RB_FC")
k = _kpi(db, "RB_FC", target_value=100.0)
now = datetime.now().strftime("%Y-%m")
_value(db, k, 30.0, now, data_status="verified")
_value(db, k, 999.0, now, data_status="estimated", source_type="forecast",
calculated_at=datetime.now() + timedelta(minutes=5))
out = build_review(db, entity_id=1, map_id=m.id, review_type="monthly")
row = next(a for a in out["adjustments"] if a["target"] == "RB_FC")
assert row["current_value"] == 30.0
class TestWriteReadChain:
def test_post_then_get_readback(self, db: Session, client: TestClient):
"""落库链路: POST 写 → GET 读回(复盘页同一读路径)"""
m, obj, kr, kpi, plan = _seed_min(db)
create_test_user(db)
token = get_token_for_user(client)
out = build_review(db, entity_id=1, map_id=m.id, review_type="quarterly")
body = {"review_type": "quarterly", "review_date": date.today().isoformat(),
"summary": out["summary"], "adjustments": out["adjustments"],
"next_priorities": out["next_priorities"]}
r = client.post(f"/api/cma/maps/{m.id}/review-records", json=body,
headers=auth_header(token))
assert r.status_code == 200, r.text
rid = r.json()["id"]
r2 = client.get(f"/api/cma/maps/{m.id}/review-records", headers=auth_header(token))
assert r2.status_code == 200
ids = [x["id"] for x in r2.json()["data"]]
assert rid in ids
r3 = client.get(f"/api/cma/maps/{m.id}/review-records/{rid}", headers=auth_header(token))
assert r3.status_code == 200
got = r3.json()
assert got["review_type"] == "quarterly"
assert "RB_REV" in got["summary"]
# 结构化「O/KR/KPI 现值+偏差+关联行动」随记录回读
assert any(a["type"] == "kpi" and a["target"] == "RB_REV" for a in got["adjustments"])