Files
cma-management/backend/scripts/review_run.py
T
wecom-fullstack 424c33c085
ci/woodpecker/push/main Pipeline failed
feat(kpi,review): KPI direction 列 + KPITarget 模型 + 复盘产出方入库(R1 收口)
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)。
2026-10-13 20:33:42 +08:00

210 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())