feat(cma): external input ledgers for M2 capability eval (tax/hr/licence)

3 new tables + idempotent DDL migration (prod guard: refuses DB name cma without approval): tax_filing_log (P_TAX_TIMELY), hr_monthly_ledger (L_EMPLOYEE_TURNOVER), licence_ledger (P_LICENCE_OK).

Entry API /api/cma/ext-ledger: create / CSV batch import / query by entity+period / derive trigger. on_time is server-derived only; HR rows rejected when end != begin + hires - separations.

Deriver writes kpi_values only (source_type=derived, idempotent upsert); denominator 0 returns None and writes nothing; never creates or changes kpi_definitions.

Tests: 22 new pytest cases (22 passed); scratch-DB e2e battery 28 assertions PASS with zero production write.
This commit is contained in:
Hermes CI Fix
2026-09-15 18:12:05 +08:00
parent 5d0884cc3b
commit 9d9a79545a
9 changed files with 1956 additions and 1 deletions
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""外部输入台账 DDL 迁移 — tax_filing_log / hr_monthly_ledger / licence_ledger
(任务 0-P0-project-20260915-cma-eval-m2-ext-input-tables,2026-09-15)
DDL 真源 = scripts/sql/ext_input_ledgers_20260915.sql(单一来源,本脚本读取执行,勿复制 DDL)
动作:
--check 只读预检(默认):三表存在性 + 缺列 + 唯一键,零写入
--apply 执行 CREATE TABLE IF NOT EXISTS(幂等:二次运行新增=0)
--down 回滚:DROP 三表(表内已有数据须显式 --yes)
--show-create 打印 SHOW CREATE TABLE 原文(验收证据;非 MySQL 目标自动跳过)
--url URL 目标库 SQLAlchemy URL 覆盖(默认 app.database.get_engine() = backend/.env)
生产保护(生产资产派单纪律 + 本单「不得直连生产写入」):
目标库名 == 'cma' 时,--apply/--down **一律拒绝**;须 --allow-prod 且环境变量
CMA_DDL_APPROVED=1(= 已拿到审批单)才放行。--check/--show-create 为只读,不受限但会打标。
用法:
venv/bin/python3 scripts/migrate_ext_input_ledgers_20260915.py --check
venv/bin/python3 scripts/migrate_ext_input_ledgers_20260915.py --apply \
--url "mysql+pymysql://root@localhost/cma_scratch_x?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4"
"""
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
TABLES = ("tax_filing_log", "hr_monthly_ledger", "licence_ledger")
SQL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sql",
"ext_input_ledgers_20260915.sql")
PROD_DB_NAMES = ("cma",)
def load_statements() -> list:
"""读取 .sql 真源 → 逐条语句(去注释/空行),返回可执行 DDL 列表"""
with open(SQL_FILE, "r", encoding="utf-8") as f:
raw = f.read()
lines = [ln for ln in raw.splitlines() if not ln.strip().startswith("--")]
cleaned = "\n".join(lines)
stmts = [s.strip() for s in cleaned.split(";") if s.strip()]
creates = [s for s in stmts if s.upper().startswith("CREATE TABLE")]
if len(creates) != len(TABLES):
raise RuntimeError(f"DDL 真源语句数异常: 期望 {len(TABLES)} 条 CREATE TABLE,"
f"实际 {len(creates)} 条({SQL_FILE})")
return creates
def db_name_of(engine) -> str:
try:
return engine.url.database or ""
except Exception:
return ""
def describe(engine) -> dict:
"""只读预检:返回 {table: {'exists': bool, 'missing_cols': [...], 'unique_keys': [...]}}"""
insp = inspect(engine)
existing_tables = set(insp.get_table_names())
out = {}
for t in TABLES:
info = {"exists": t in existing_tables, "missing_cols": [], "unique_keys": []}
if info["exists"]:
cols = {c["name"] for c in insp.get_columns(t)}
expected = EXPECTED_COLUMNS[t]
info["missing_cols"] = [c for c in expected if c not in cols]
info["unique_keys"] = [tuple(uc["column_names"]) for uc in insp.get_unique_constraints(t)]
out[t] = info
return out
EXPECTED_COLUMNS = {
"tax_filing_log": ("id", "entity_id", "period", "tax_type", "legal_deadline", "filed_date",
"on_time", "evidence_no", "registered_by", "remark", "created_at", "updated_at"),
"hr_monthly_ledger": ("id", "entity_id", "period", "headcount_begin", "hires", "separations",
"headcount_end", "registered_by", "remark", "created_at", "updated_at"),
"licence_ledger": ("id", "entity_id", "licence_name", "licence_no", "valid_from", "valid_to",
"annual_check_status", "issuer", "owner_role", "attachment_no",
"registered_by", "remark", "created_at", "updated_at"),
}
def print_check(state: dict) -> None:
print("== 只读预检(information_schema / inspector,零写入)==")
for t in TABLES:
s = state[t]
print(f" {t}: exists={s['exists']} missing_cols={s['missing_cols']} unique_keys={s['unique_keys']}")
def main():
ap = argparse.ArgumentParser(description="外部输入台账 DDL 迁移")
g = ap.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="回滚:DROP 三表")
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} | url_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("!!! 拒绝执行:目标库是生产库 %s。" % dbname)
print("!!! 生产 DDL 须审批后另派执行(本单边界:不得直连生产写入)。")
print("!!! 如需放行:--allow-prod 且 export CMA_DDL_APPROVED=1(持有审批单)。")
return 3
if args.apply:
stmts = load_statements()
with engine.begin() as conn:
for s in stmts:
conn.execute(text(s))
state = describe(engine)
missing = [t for t in TABLES if not state[t]["exists"]]
print(f"== APPLY 完成:DDL 真源 {os.path.basename(SQL_FILE)},语句 {len(stmts)} 条 ==")
print_check(state)
print(f" 仍缺表: {missing}(二次运行应为 0 新增表)")
return 1 if missing else 0
if args.down:
counts = {}
with engine.connect() as conn:
for t in TABLES:
if t in set(inspect(engine).get_table_names()):
counts[t] = conn.execute(text(f"SELECT COUNT(*) FROM {t}")).scalar()
if any(counts.values()) and not args.yes:
print(f"!!! 表内已有数据 {counts}:回滚须显式 --yes(防误删业务数据)")
return 3
with engine.begin() as conn:
for t in TABLES:
conn.execute(text(f"DROP TABLE IF EXISTS {t}"))
print(f"== DOWN 完成:DROP {list(TABLES)}(回滚前行数 {counts})==")
print_check(describe(engine))
return 0
# --check(默认)或 --show-create
state = describe(engine)
print_check(state)
if is_prod:
print(" [注意] 目标是生产库:以上仅为只读预检,未做任何写入。")
if args.show_create:
if engine.dialect.name != "mysql":
print("== SHOW CREATE TABLE 跳过(非 MySQL 目标)==")
else:
with engine.connect() as conn:
for t in TABLES:
if not state[t]["exists"]:
print(f"== {t}: 表不存在,无 SHOW CREATE 输出 ==")
continue
row = conn.execute(text(f"SHOW CREATE TABLE {t}")).fetchone()
print(f"== SHOW CREATE TABLE {t} ==")
print(row[1])
return 0
if __name__ == "__main__":
sys.exit(main())