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
@@ -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"])