pbl_blueprint/tools/m1b_register_sync.py
2026-09-17 15:16:08 +08:00

188 lines
7.9 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 -*-
"""M1b 注册同步执行器QC 退回意见 #8需真实执行记录 + 库内注册数据证据)。
做三件事并**落盘可复核证据**
1. 建表(幂等 DDLsqlite 方言mariadb DDL 见 sql/m1b_ddl.sql
2. 注册 7 类子对象 + 模板/蓝图的扩展字段定义pbl_ext_field_def幂等 upsert
3. 注册平台公共模板pbl_blueprint_templatetenant_id NULL幂等 upsert
4. 回读库内注册数据 -> 写 projects/pbls/deliverables/m1b/register_sync_report.json
+ projects/pbls/deliverables/audit/pbl_blueprint_audit.jsonl审计轨迹
用法:
python3 tools/m1b_register_sync.py # 执行并落盘证据
python3 tools/m1b_register_sync.py --db path.db # 指定库文件
python3 tools/m1b_register_sync.py --twice # 连跑两次验证幂等(行数不增)
"""
import argparse
import json
import os
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
for p in (REPO_ROOT, MOD_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
from pbl_blueprint.m1b import init as m1b_init # noqa: E402
from pbl_blueprint.m1b.audit import flush_memory_audit # noqa: E402
from pbl_blueprint.m1b.dbutil import ( # noqa: E402
get_conn, reset_conn, sql_rows, table_exists,
)
from pbl_blueprint.m1b.tables import SUBOBJECT_TYPES, TABLES # noqa: E402
from pbl_blueprint.m1b.util import now_str # noqa: E402
DEFAULT_DB = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables",
"db", "pbl_m1b.sqlite3")
REPORT_DIR = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables", "m1b")
def snapshot(conn):
"""回读库内注册数据(表存在性 + 行数 + 关键明细)。"""
out = {"tables": {}, "counts": {}, "details": {}}
for t in TABLES:
n = t["name"]
ready = table_exists(n, conn=conn)
out["tables"][n] = ready
if not ready:
out["counts"][n] = None
continue
rows = sql_rows("SELECT COUNT(*) AS c FROM %s" % n, conn=conn)
out["counts"][n] = int(rows[0]["c"] or 0) if rows else 0
if out["tables"].get("pbl_ext_field_def"):
out["details"]["ext_field_defs_by_owner"] = sql_rows(
"SELECT owner_type, COUNT(*) AS n FROM pbl_ext_field_def "
"GROUP BY owner_type ORDER BY owner_type", conn=conn)
out["details"]["ext_field_defs_platform_null_tenant"] = sql_rows(
"SELECT COUNT(*) AS n FROM pbl_ext_field_def WHERE tenant_id IS NULL",
conn=conn)
out["details"]["ext_field_def_sample"] = sql_rows(
"SELECT owner_type, owner_code, field_key, field_type, required "
"FROM pbl_ext_field_def ORDER BY owner_type, field_key LIMIT 25",
conn=conn)
if out["tables"].get("pbl_blueprint_template"):
out["details"]["platform_templates"] = sql_rows(
"SELECT id, code, name, version, status, scope, tenant_id "
"FROM pbl_blueprint_template WHERE tenant_id IS NULL "
"ORDER BY code, version", conn=conn)
out["details"]["tenant_templates_count"] = sql_rows(
"SELECT COUNT(*) AS n FROM pbl_blueprint_template "
"WHERE tenant_id IS NOT NULL", conn=conn)
return out
def run(db_path=None, twice=False, actor_id="m1b_register_sync"):
"""执行注册同步,返回完整报告 dict。"""
db_path = db_path or os.environ.get("PBL_M1B_DB") or DEFAULT_DB
os.environ["PBL_M1B_DB"] = db_path
os.environ.setdefault("PBL_AUDIT_DIR",
os.path.join(REPO_ROOT, "projects", "pbls",
"deliverables", "audit"))
reset_conn()
conn = get_conn(db_path)
log = []
def step(name, payload):
log.append({"step": name, "at": now_str(), "result": payload})
r1 = m1b_init.load_m1b(conn=conn, actor_id=actor_id)
step("load_m1b#1", {"ddl": r1["ddl"], "ext_field_defs": r1["ext_field_defs"],
"platform_templates": r1["platform_templates"]})
snap1 = snapshot(conn)
step("snapshot#1", {"counts": snap1["counts"]})
snap2 = None
if twice:
r2 = m1b_init.load_m1b(conn=conn, actor_id=actor_id)
step("load_m1b#2(idempotency)", {
"ddl": r2["ddl"], "ext_field_defs": r2["ext_field_defs"],
"platform_templates": r2["platform_templates"]})
snap2 = snapshot(conn)
step("snapshot#2", {"counts": snap2["counts"]})
flushed, audit_path = flush_memory_audit()
step("audit_flush", {"records": flushed, "path": audit_path})
idempotent = None
if snap2 is not None:
idempotent = snap1["counts"] == snap2["counts"]
step("idempotency_check", {
"ok": idempotent, "counts_run1": snap1["counts"],
"counts_run2": snap2["counts"],
"diff": {k: [snap1["counts"].get(k), snap2["counts"].get(k)]
for k in snap1["counts"]
if snap1["counts"].get(k) != snap2["counts"].get(k)} or None})
report = {
"tool": "tools/m1b_register_sync.py",
"milestone": "M1b",
"executed_at": now_str(),
"actor_id": actor_id,
"db": {"path": db_path,
"exists": os.path.exists(db_path) if db_path != ":memory:" else False,
"size_bytes": (os.path.getsize(db_path)
if db_path != ":memory:" and os.path.exists(db_path)
else None),
"dialect": "sqlite",
"mariadb_ddl": "modules/pbl_blueprint/pbl_blueprint/sql/m1b_ddl.sql"},
"tables_declared": [t["name"] for t in TABLES],
"tables_ready": snap1["tables"],
"row_counts": snap1["counts"],
"subobject_types_registered": list(SUBOBJECT_TYPES),
"ext_field_def_seeds": len(m1b_init.EXT_FIELD_SEED),
"details": snap1["details"],
"idempotent": idempotent,
"audit": {"flushed_records": flushed, "path": audit_path},
"log": log,
}
return report
def main(argv=None):
ap = argparse.ArgumentParser(description="M1b register sync executor")
ap.add_argument("--db", help="sqlite 库文件路径(默认 deliverables/db/pbl_m1b.sqlite3")
ap.add_argument("--twice", action="store_true", help="连跑两次验证幂等")
ap.add_argument("--report", help="报告输出路径")
args = ap.parse_args(argv)
report = run(db_path=args.db, twice=args.twice)
out = args.report or os.path.join(REPORT_DIR, "register_sync_report.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
fh.write(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n")
brief = {
"ok": True,
"report": os.path.relpath(out, REPO_ROOT),
"db": report["db"]["path"],
"tables_ready": report["tables_ready"],
"row_counts": report["row_counts"],
"idempotent": report["idempotent"],
"audit_records": report["audit"]["flushed_records"],
}
print(json.dumps(brief, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
sys.exit(main())