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
+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())