Files
cma-management/backend/scripts/migrate_analysis_results_entity_id_20260923.py
Hermes CI Fix fb4609be14
ci/woodpecker/push/main Pipeline failed
feat(entity-dim): 账套维度收口批 — subjects/analysis_results 加列 + 分析结论账套化 + 模板实例化构造点补账套
- ① kpi_templates instantiate 构造点显式传会话账套(原缺 entity_id ⇒ 静默落 e1),kpi_code 判重按账套收紧
- ② kpi_subject_map 3 个读取点加护栏注释(不加列、不改语义)
- ③ subjects 分层:读口 union(NULL=全局标准准则科目 / N=账套私有),UPSERT 唯一键改 (entity_id, subject_code)
- ④ analysis_results 账套化:读/写/删三端点按会话账套判定归属(列未落地时 kpi_code JOIN 止血),
     无归属行进 unattributed 登记、删除一律 404
- 模型层按现网 DDL 补齐:KPIDefinition(template_id/is_system + uk_entity_kpi)、AnalysisResult.entity_id、Subject.entity_id
- 新增 migrate_{subjects,analysis_results}_entity_id_20260923.py(--check/--apply/--down)、
  22 条对账用例 test_entity_dimension_convergence_20260923.py

DDL 已由 ops 受控通道落地(subjects 21:31:56 / analysis_results 21:31:59 CST,审批 1e9b4d9a/5ffb1640)
本提交为切片提交,未 push(发布须另取放行)
2026-09-23 21:39:24 +08:00

204 lines
8.1 KiB
Python
Raw Permalink 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 -*-
"""`analysis_results` 账套化 DDL — 2026-09-23
任务:`0-P1-project-20260923-entity-dimension-convergence-fullstack` §三④
(裁定件 `project-entity-dimension-ruling-20260923` §3.2「读侧先止血、写侧随 DDL」)
背景
----
`analysis_results` 只有 2 行(`id=1 period=2026-06 kpi_code=TEST001` /
`id=2 period=2026-07 kpi_code=NULL`,均 2026-07-29 创建),**无账套列**;
`GET/POST/DELETE /api/cma/analysis/result*` 三端点原无 `get_entity_id`
⇒ 双 token 返回体**逐字节相同**(跨账套读,页面 `AnalysisConfidence.vue` 已上线)。
本脚本只做 DDL(加列 + 索引),不做任何数据订正:
· 存量 2 行**保持 `entity_id = NULL`** —— 归属存疑,按裁定件交 DAMA 取证 / 财务确认后另办;
· 代码侧口径(`app/api/analysis_results.py`):`NULL` 行 = 无归属,进 `unattributed` 登记池,
不参与任何账套过滤;列落地后读侧自动从 `kpi_code JOIN` 止血口径切到列口径。
用法
----
venv/bin/python3 scripts/migrate_analysis_results_entity_id_20260923.py --check
CMA_DDL_APPROVED=1 venv/bin/python3 scripts/migrate_analysis_results_entity_id_20260923.py \\
--apply --allow-prod
CMA_DDL_APPROVED=1 venv/bin/python3 scripts/migrate_analysis_results_entity_id_20260923.py \\
--down --allow-prod --yes
venv/bin/python3 scripts/migrate_analysis_results_entity_id_20260923.py --show-create
生产保护(生产资产派单纪律):目标库名 == 'cma' 时 `--apply/--down` 一律拒绝;
须 `--allow-prod` **且** `CMA_DDL_APPROVED=1`(= 已拿到 ops 审批单)才放行;
`--check/--show-create` 为只读,不受限但会打标。
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, inspect, text # noqa: E402
TABLE = "analysis_results"
COLUMN = "entity_id"
NEW_INDEX = "ix_analysis_results_entity_id"
PROD_DB_NAMES = ("cma",)
DDL_ADD_COLUMN = (
f"ALTER TABLE {TABLE} ADD COLUMN {COLUMN} INT NULL "
"COMMENT '企业ID(NULL=历史未归属行,不参与账套过滤)' AFTER id"
)
DDL_DROP_COLUMN = f"ALTER TABLE {TABLE} DROP COLUMN {COLUMN}"
def is_sqlite(engine) -> bool:
"""沙箱演练(SQLite)与生产(MySQL)的索引/列 DDL 方言差异"""
return engine.dialect.name == "sqlite"
def stmt_add_column(engine) -> str:
"""生产形态 = MySQL(带 COMMENT / AFTER);沙箱 = SQLite 精简形态(语法不支持二者)"""
if is_sqlite(engine):
return f"ALTER TABLE {TABLE} ADD COLUMN {COLUMN} INT NULL"
return DDL_ADD_COLUMN
def stmt_add_index(engine, name: str, cols: list) -> str:
cols_s = ", ".join(cols)
if is_sqlite(engine):
return f"CREATE INDEX {name} ON {TABLE} ({cols_s})"
return f"ALTER TABLE {TABLE} ADD INDEX {name} ({cols_s})"
def stmt_drop_index(engine, name: str) -> str:
return f"DROP INDEX {name}" if is_sqlite(engine) else f"ALTER TABLE {TABLE} DROP INDEX {name}"
def db_name_of(engine) -> str:
try:
return engine.url.database or ""
except Exception:
return ""
def state(engine) -> dict:
insp = inspect(engine)
tables = set(insp.get_table_names())
out = {"table_exists": TABLE in tables, "has_column": False, "indexes": [], "rows": []}
if not out["table_exists"]:
return out
out["has_column"] = COLUMN in {c["name"] for c in insp.get_columns(TABLE)}
out["indexes"] = sorted({i["name"] for i in insp.get_indexes(TABLE)})
with engine.connect() as conn:
out["total"] = conn.execute(text(f"SELECT COUNT(*) FROM {TABLE}")).scalar()
if out["has_column"]:
rows = conn.execute(text(
f"SELECT id, period, kpi_code, IFNULL({COLUMN},'NULL') FROM {TABLE} ORDER BY id")).fetchall()
else:
rows = conn.execute(text(
f"SELECT id, period, kpi_code, 'NO_COLUMN' FROM {TABLE} ORDER BY id")).fetchall()
out["rows"] = [[r[0], r[1], r[2], str(r[3])] for r in rows]
return out
def print_check(st: dict) -> None:
print("== 只读预检(inspector / SELECT,零写入)==")
print(f" table_exists={st['table_exists']} has_column={st['has_column']} total={st.get('total')}")
print(f" indexes={st['indexes']}")
for r in st.get("rows", []):
print(f" id={r[0]} period={r[1]} kpi_code={r[2]} entity_id={r[3]}")
def do_apply(engine) -> int:
st = state(engine)
if not st["table_exists"]:
print("!!! 目标库没有 analysis_results 表,拒绝 --apply")
return 3
done = []
with engine.begin() as conn:
if not st["has_column"]:
add_col = stmt_add_column(engine)
conn.execute(text(add_col))
done.append(add_col)
cur = state(engine)
if NEW_INDEX not in cur["indexes"]:
add_idx = stmt_add_index(engine, NEW_INDEX, [COLUMN])
conn.execute(text(add_idx))
done.append(add_idx)
print("== APPLY 完成,执行语句:")
for s in done:
print(" " + s)
if not done:
print(" (无语句执行:已是目标形态,幂等)")
print_check(state(engine))
return 0
def do_down(engine, yes: bool) -> int:
st = state(engine)
if not st["has_column"]:
print("== DOWN 跳过:entity_id 列不存在(已是回滚态)")
return 0
assigned = [r for r in st.get("rows", []) if r[3] not in ("NULL", "NO_COLUMN")]
if assigned and not yes:
print(f"!!! 已有 {len(assigned)} 行带账套归属 {assigned}:回滚会丢失归属,须显式 --yes")
return 3
with engine.begin() as conn:
if NEW_INDEX in st["indexes"]:
conn.execute(text(stmt_drop_index(engine, NEW_INDEX)))
conn.execute(text(DDL_DROP_COLUMN))
print("== DOWN 完成:删除 entity_id 索引 + 列")
print_check(state(engine))
return 0
def main():
ap = argparse.ArgumentParser(description="analysis_results 账套化 DDL")
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true", help="执行 DDL(幂等)")
g.add_argument("--down", action="store_true", help="回滚:删索引 + 删列")
ap.add_argument("--show-create", action="store_true", help="打印 SHOW CREATE TABLE")
ap.add_argument("--url", default=None, help="目标库 SQLAlchemy URL(默认 backend/.env)")
ap.add_argument("--allow-prod", action="store_true", help="生产库放行开关(须 CMA_DDL_APPROVED=1)")
ap.add_argument("--yes", action="store_true", help="--down 有账套归属行时的确认开关")
args = ap.parse_args()
if args.url:
engine = create_engine(args.url, echo=False)
else:
from app.database import get_engine
engine = get_engine()
dbname = db_name_of(engine)
print(f"目标库: {dbname or '(未知)'} | dialect={engine.dialect.name} | host={engine.url.host or 'socket'}")
is_prod = dbname in PROD_DB_NAMES
if (args.apply or args.down) and is_prod:
if not args.allow_prod or os.getenv("CMA_DDL_APPROVED") != "1":
print(f"!!! 拒绝执行:目标库是生产库 {dbname}。")
print("!!! 生产 DDL 须 ops 审批后另派执行(本单边界:不得直连生产写入)。")
print("!!! 放行方式:--allow-prod 且 export CMA_DDL_APPROVED=1(持有审批单)。")
return 3
if args.apply:
return do_apply(engine)
if args.down:
return do_down(engine, args.yes)
st = state(engine)
print_check(st)
if is_prod:
print(" [注意] 目标是生产库:以上仅为只读预检,未做任何写入。")
if args.show_create:
if engine.dialect.name != "mysql":
print("== SHOW CREATE TABLE 跳过(非 MySQL 目标)==")
else:
with engine.connect() as conn:
print(conn.execute(text(f"SHOW CREATE TABLE {TABLE}")).fetchone()[1])
return 0
if __name__ == "__main__":
sys.exit(main())