fix(cma): 工作台跨实体污染治本 — 全局键拆账套 + 缺列收口 + 预警归属兜底
任务 dama-workbench-entity-isolation-fullstack-20260922(DAMA 诊断 / 项目Bot batch5 §B / 任总 2026-09-22「都批」) B1 现金基线拆账套(零 DDL) - cash_forecast_engine: 键 cash.current_balance → cash.current_balance.<entity_id>; 读只读本账套键、不做旧键回退;set_* 签名 entity_id 必传(漏传 TypeError) - cash.py: POST /balance 挂 get_entity_id;网银流水联动写回带账套 B3(a) alignment_config 拆账套(零 DDL) - alignment.py: GET/POST /config、GET /tree 只读 alignment_config.<entity_id>;未配置=configured:false B3(b) 预警 KPI 归属兜底 - bot_bridge_v2: 去掉 kpi_id or 1 兜底;本账套无 KPI 定义 → 跳过建预警 + OperationLog 留痕 (kpi_alerts.kpi_id NOT NULL ⇒ 方案A 置 NULL 不可行,走方案B 跳过+审计) B2 缺列五表分表处置 - notification_logs: 经 alert_id → kpi_alerts.entity_id join 归属(无父键行不返回) - notification_channels / okr_templates: 集团共享口径写入 docstring(有意不过滤) - system_configs: 由 B1/B3(a) 拆键覆盖;kpi_data_quality_logs 沿用 batch5 的 join 收口 P0 语义收敛 - tenant.py: current_tenant 收敛为项目Bot A2A 状态记录,GET 仅 it/ceo;POST 补 operation_logs 审计 证据:新增/改写用例 22 条;全量 1370 passed(基线 1348)
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全局键拆账套 — 一次性搬运(数据迁移,非 DDL)
|
||||
|
||||
任务:`dama-workbench-entity-isolation-fullstack-20260922` §三 B1/B3(a)
|
||||
(DAMA 诊断 / 项目Bot batch5 §B 方案段 / 任总 2026-09-22「都批」)
|
||||
|
||||
背景
|
||||
----
|
||||
`system_configs.config_key` 与 `role_permissions.key` 均为 **unique 单行键** ⇒
|
||||
下列两个「全局键」全库只有一行,被所有账套共用(本次「酣客工作台显示博海数据」
|
||||
污染的同源结构性缺陷):
|
||||
|
||||
键 处置
|
||||
------------------------------- ------------------------------------------
|
||||
system_configs.cash.current_balance → cash.current_balance.<entity_id>
|
||||
role_permissions.alignment_config → alignment_config.<entity_id>
|
||||
|
||||
代码侧(读写)已改为**只认带账套后缀的新键**(方案 A,零 DDL):
|
||||
backend/app/utils/cash_forecast_engine.py (get_/set_current_cash_balance)
|
||||
backend/app/api/cash.py (POST /balance、网银流水联动)
|
||||
backend/app/api/alignment.py (GET/POST /config、GET /tree)
|
||||
|
||||
本脚本 = **旧值一次性搬运**(把原来唯一的那一行值落到「当前生产账套」名下)。
|
||||
「当前生产账套」判定:默认 `--target-entity 1`(entity 1 = 陕西酣客,生产账套),
|
||||
`--check` 会打印**逐账套凭证推导的货币资金期末余额**与旧键值的比对,供人工确认
|
||||
(哪个账套的 1001/1002 期末余额 == 旧键值 ⇒ 该值本来就是它写的)。
|
||||
|
||||
口径(须与代码注释一致)
|
||||
------------------------
|
||||
· 新键值 = 旧键值(一次性搬运)**只落到 target-entity**;**其余账套留空**
|
||||
⇒ 留空语义 = 该账套现金基线走 KPI 实际值兜底 / 预测引擎默认值,
|
||||
**不跨账套借用**(正是本次污染的成因)。
|
||||
· 旧键**原样保留不删**(回退杠杆:回退老代码即可继续读旧键)。
|
||||
· alignment_config 同理:旧全局值落到 target-entity;其余账套 configured=false。
|
||||
· 幂等:新键已存在 → 不覆盖(除非 `--force`);二跑 `--apply` 零副作用。
|
||||
|
||||
用法
|
||||
----
|
||||
# 只读预检(默认动作,零写入)
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --check
|
||||
|
||||
# 写库(**需审批 / 生产写**;--apply 才写)
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --target-entity 2
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --force # 覆盖已存在的新键
|
||||
|
||||
# 回退(删新键 + 把旧全局键写回原值)
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --down
|
||||
|
||||
选项
|
||||
----
|
||||
--target-entity N 旧值落哪个账套(默认 1;--check 会给出证据建议)
|
||||
--force 新键已存在时允许覆盖(默认拒绝,保留既有值)
|
||||
--json 机器可读输出
|
||||
--allow-db NAME 生产库名保护豁免(默认只允许库名 `cma`)
|
||||
|
||||
退出码
|
||||
------
|
||||
0 成功 | 1 执行失败 | 2 用法/保护拒绝(库名不符、目标账套不存在)
|
||||
|
||||
⚠️ 本脚本写的是**生产业务库**:不得在开发级会话直接 `--apply`。
|
||||
依据《生产资产派单纪律》(skills-shared/operations/prod-asset-dispatch-discipline):
|
||||
碰生产资产一律走派单 + 审批;`--check` 同样是生产读,须留单痕。
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import text # noqa: E402
|
||||
|
||||
CASH_KEY = "cash.current_balance" # 旧全局键(保留不删)
|
||||
ALIGN_KEY = "alignment_config" # 旧全局键(保留不删)
|
||||
PROD_DB_NAME = "cma" # 生产库名(保护口径)
|
||||
|
||||
CASH_DESC = "当前现金余额(万元),资金预测基线 — 账套 {eid}"
|
||||
ALIGN_DESC = "对齐模式配置 — 账套 {eid}(B3(a) 拆账套 2026-09-22)"
|
||||
|
||||
|
||||
def _engine():
|
||||
from app.database import get_engine
|
||||
return get_engine()
|
||||
|
||||
|
||||
def _scalar(conn, sql, **params):
|
||||
row = conn.execute(text(sql), params).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _detect_db_name(conn) -> str:
|
||||
try:
|
||||
return _scalar(conn, "SELECT DATABASE()") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _cash_ledger_by_entity(conn):
|
||||
"""逐账套:货币资金(1001/1002, carry_forward=0) 期末净额(元 → 万元)
|
||||
|
||||
与 `cash.py::_sync_cash_balance_from_ledger` 同口径(该函数正是写旧键的调用方)
|
||||
——用于回答「-9.3 这个值原本是哪家写的」。
|
||||
"""
|
||||
out = {}
|
||||
try:
|
||||
rows = conn.execute(text(
|
||||
"SELECT entity_id, "
|
||||
" ROUND(SUM(COALESCE(debit_amount,0) - COALESCE(credit_amount,0))/10000, 4) AS wan, "
|
||||
" COUNT(*) AS n "
|
||||
"FROM voucher_details "
|
||||
"WHERE carry_forward = 0 AND subject_code IS NOT NULL "
|
||||
" AND (subject_code LIKE '1001%' OR subject_code LIKE '1002%') "
|
||||
"GROUP BY entity_id ORDER BY entity_id"
|
||||
)).fetchall()
|
||||
for r in rows:
|
||||
out[int(r[0])] = {"cash_wan": float(r[1] or 0), "voucher_rows": int(r[2] or 0)}
|
||||
except Exception as e: # 表缺失/权限 → 只报告,不阻断
|
||||
out["_error"] = str(e)[:300]
|
||||
return out
|
||||
|
||||
|
||||
def _snapshot(conn):
|
||||
"""只读快照:新旧键现状 + 逐账套凭证推导余额"""
|
||||
snap = {
|
||||
"db": _detect_db_name(conn),
|
||||
"legacy": {
|
||||
"cash.current_balance": None,
|
||||
"alignment_config": None,
|
||||
},
|
||||
"new_keys": {"cash": {}, "alignment": {}},
|
||||
"entities": [],
|
||||
"cash_ledger_by_entity": _cash_ledger_by_entity(conn),
|
||||
}
|
||||
|
||||
row = conn.execute(text(
|
||||
"SELECT config_value, description FROM system_configs WHERE config_key = :k"
|
||||
), {"k": CASH_KEY}).fetchone()
|
||||
if row:
|
||||
snap["legacy"]["cash.current_balance"] = {"config_value": row[0], "description": row[1]}
|
||||
|
||||
row = conn.execute(text(
|
||||
"SELECT value FROM role_permissions WHERE `key` = :k"
|
||||
), {"k": ALIGN_KEY}).fetchone()
|
||||
if row:
|
||||
snap["legacy"]["alignment_config"] = {"value": row[0]}
|
||||
|
||||
for r in conn.execute(text(
|
||||
"SELECT id, name, short_name, status FROM entities ORDER BY id"
|
||||
)).fetchall():
|
||||
snap["entities"].append({"id": int(r[0]), "name": r[1], "short_name": r[2], "status": r[3]})
|
||||
|
||||
for r in conn.execute(text(
|
||||
"SELECT config_key, config_value FROM system_configs "
|
||||
"WHERE config_key LIKE :p ORDER BY config_key"
|
||||
), {"p": CASH_KEY + ".%"}).fetchall():
|
||||
snap["new_keys"]["cash"][r[0]] = r[1]
|
||||
|
||||
for r in conn.execute(text(
|
||||
"SELECT `key`, value FROM role_permissions WHERE `key` LIKE :p ORDER BY `key`"
|
||||
), {"p": ALIGN_KEY + ".%"}).fetchall():
|
||||
snap["new_keys"]["alignment"][r[0]] = r[1]
|
||||
|
||||
# 建议目标账套(只读推断):旧值 == 某账套凭证推导余额
|
||||
suggestion = None
|
||||
legacy_cash = (snap["legacy"]["cash.current_balance"] or {}).get("config_value")
|
||||
if legacy_cash is not None:
|
||||
try:
|
||||
want = float(legacy_cash)
|
||||
for eid, info in snap["cash_ledger_by_entity"].items():
|
||||
if isinstance(eid, int) and abs(float(info["cash_wan"]) - want) < 1e-6:
|
||||
suggestion = eid
|
||||
break
|
||||
except Exception:
|
||||
suggestion = None
|
||||
snap["suggested_target_entity"] = suggestion
|
||||
return snap
|
||||
|
||||
|
||||
def cmd_check(args):
|
||||
eng = _engine()
|
||||
with eng.connect() as conn:
|
||||
snap = _snapshot(conn)
|
||||
if args.json:
|
||||
print(json.dumps(snap, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
print(f"目标库: {snap['db']}")
|
||||
print(f"旧全局键 cash.current_balance : {snap['legacy']['cash.current_balance']}")
|
||||
print(f"旧全局键 alignment_config : {snap['legacy']['alignment_config']}")
|
||||
print(f"已存在新键(cash) : {snap['new_keys']['cash']}")
|
||||
print(f"已存在新键(alignment) : {snap['new_keys']['alignment']}")
|
||||
print("逐账套凭证推导货币资金期末余额(万元):")
|
||||
for eid, info in snap["cash_ledger_by_entity"].items():
|
||||
print(f" entity {eid}: {info}")
|
||||
print(f"建议 --target-entity : {snap['suggested_target_entity']}"
|
||||
f"(旧值与他家凭证推导余额一致时给出;None = 无匹配,按默认 1 或人工裁定)")
|
||||
print("【只读预检,零写入】")
|
||||
return 0
|
||||
|
||||
|
||||
def _apply_cash(conn, target: int, force: bool, dry: bool):
|
||||
new_key = f"{CASH_KEY}.{target}"
|
||||
row = conn.execute(text(
|
||||
"SELECT config_value FROM system_configs WHERE config_key = :k"
|
||||
), {"k": CASH_KEY}).fetchone()
|
||||
if not row:
|
||||
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
|
||||
legacy_value = row[0]
|
||||
|
||||
exist = conn.execute(text(
|
||||
"SELECT config_value FROM system_configs WHERE config_key = :k"
|
||||
), {"k": new_key}).fetchone()
|
||||
if exist and not force:
|
||||
return {"key": new_key, "action": "noop",
|
||||
"reason": "新键已存在且未 --force(保留既有值,幂等)",
|
||||
"existing_value": exist[0]}
|
||||
if dry:
|
||||
return {"key": new_key, "action": "would-insert-or-update",
|
||||
"value": legacy_value, "existing_value": exist[0] if exist else None}
|
||||
|
||||
if exist:
|
||||
conn.execute(text(
|
||||
"UPDATE system_configs SET config_value = :v, description = :d WHERE config_key = :k"
|
||||
), {"v": legacy_value, "d": CASH_DESC.format(eid=target), "k": new_key})
|
||||
action = "updated"
|
||||
else:
|
||||
conn.execute(text(
|
||||
"INSERT INTO system_configs (config_key, config_value, description) "
|
||||
"VALUES (:k, :v, :d)"
|
||||
), {"k": new_key, "v": legacy_value, "d": CASH_DESC.format(eid=target)})
|
||||
action = "inserted"
|
||||
return {"key": new_key, "action": action, "value": legacy_value}
|
||||
|
||||
|
||||
def _apply_alignment(conn, target: int, force: bool, dry: bool):
|
||||
new_key = f"{ALIGN_KEY}.{target}"
|
||||
row = conn.execute(text(
|
||||
"SELECT value FROM role_permissions WHERE `key` = :k"
|
||||
), {"k": ALIGN_KEY}).fetchone()
|
||||
if not row:
|
||||
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
|
||||
|
||||
exist = conn.execute(text(
|
||||
"SELECT value FROM role_permissions WHERE `key` = :k"
|
||||
), {"k": new_key}).fetchone()
|
||||
if exist and not force:
|
||||
return {"key": new_key, "action": "noop",
|
||||
"reason": "新键已存在且未 --force(保留既有值,幂等)",
|
||||
"existing_value": exist[0]}
|
||||
if dry:
|
||||
return {"key": new_key, "action": "would-insert", "value": row[0]}
|
||||
|
||||
conn.execute(text(
|
||||
"INSERT INTO role_permissions (`key`, value) VALUES (:k, :v)"
|
||||
), {"k": new_key, "v": row[0]})
|
||||
return {"key": new_key, "action": "inserted", "value": row[0]}
|
||||
|
||||
|
||||
def _guard_db(args, conn):
|
||||
db = _detect_db_name(conn)
|
||||
if args.allow_db:
|
||||
return db
|
||||
if db != PROD_DB_NAME:
|
||||
print(f"[拒绝] 库名 {db!r} ≠ 生产库 {PROD_DB_NAME!r};"
|
||||
f"如确为预期,请显式加 --allow-db {db}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return db
|
||||
|
||||
|
||||
def cmd_apply(args):
|
||||
eng = _engine()
|
||||
results = []
|
||||
with eng.begin() as conn:
|
||||
db = _guard_db(args, conn)
|
||||
# 目标账套存在性校验(防写出孤儿键)
|
||||
ent = conn.execute(text("SELECT id FROM entities WHERE id = :i"),
|
||||
{"i": args.target_entity}).fetchone()
|
||||
if not ent:
|
||||
print(f"[拒绝] 目标账套 entity_id={args.target_entity} 不存在", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
results.append(_apply_cash(conn, args.target_entity, args.force, dry=False))
|
||||
results.append(_apply_alignment(conn, args.target_entity, args.force, dry=False))
|
||||
out = {"mode": "apply", "db": db, "target_entity": args.target_entity, "results": results}
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
|
||||
"\n".join(f" {r}" for r in results))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_down(args):
|
||||
"""回退:删除新键(旧全局键原样保留,老代码即可继续读)"""
|
||||
eng = _engine()
|
||||
removed = []
|
||||
with eng.begin() as conn:
|
||||
db = _guard_db(args, conn)
|
||||
r1 = conn.execute(text(
|
||||
"DELETE FROM system_configs WHERE config_key LIKE :p"), {"p": CASH_KEY + ".%"})
|
||||
r2 = conn.execute(text(
|
||||
"DELETE FROM role_permissions WHERE `key` LIKE :p"), {"p": ALIGN_KEY + ".%"})
|
||||
removed = [f"system_configs(+{CASH_KEY}.*) deleted={r1.rowcount}",
|
||||
f"role_permissions({ALIGN_KEY}.*) deleted={r2.rowcount}"]
|
||||
print(json.dumps({"mode": "down", "db": db, "removed": removed},
|
||||
ensure_ascii=False, indent=2) if args.json else "\n".join(removed))
|
||||
print("旧全局键未改动 → 代码回退到老版本即可继续读旧键。")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="全局键拆账套 · 一次性搬运(cash.current_balance / alignment_config)")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--check", action="store_true", help="只读预检(默认)")
|
||||
g.add_argument("--apply", action="store_true", help="写库(需审批)")
|
||||
g.add_argument("--down", action="store_true", help="回退:删除新键")
|
||||
p.add_argument("--target-entity", type=int, default=1,
|
||||
help="旧值落到哪个账套(默认 1 = 陕西酣客,生产账套)")
|
||||
p.add_argument("--force", action="store_true", help="允许覆盖已存在的新键")
|
||||
p.add_argument("--json", action="store_true", help="机器可读输出")
|
||||
p.add_argument("--allow-db", default=None, help="生产库名保护豁免(默认仅允许 cma)")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.apply:
|
||||
return cmd_apply(args)
|
||||
if args.down:
|
||||
return cmd_down(args)
|
||||
return cmd_check(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user