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)。
210 lines
9.6 KiB
Python
210 lines
9.6 KiB
Python
#!/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())
|