#!/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())