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())
@@ -0,0 +1,77 @@
-- ============================================================================
-- 外部输入台账 DDL(能力评价 M2 准入3 派生)
-- 任务: 0-P0-project-20260915-cma-eval-m2-ext-input-tables
-- 日期: 2026-09-15
--
-- 三张**外部输入**台账 = 能力评价 3 项缺口值的唯一合法取证来源(PRD v1.3 §4.0):
-- tax_filing_log 申报行为台账 → P_TAX_TIMELY (A4 税务申报及时率, 月频)
-- hr_monthly_ledger 人事月度台账 → L_EMPLOYEE_TURNOVER (B5 员工流失率, 月频)
-- licence_ledger 证照台账 → P_LICENCE_OK (B2 经营许可合规率, 季频)
--
-- 执行方式(**勿直接 mysql < 本文件**,列级/表级幂等由脚本保证):
-- backend/scripts/migrate_ext_input_ledgers_20260915.py --check # 只读预检(默认)
-- backend/scripts/migrate_ext_input_ledgers_20260915.py --apply # 建表(幂等)
-- backend/scripts/migrate_ext_input_ledgers_20260915.py --show-create # 验收证据
-- 生产库(cma)执行须审批后另派:脚本对 DB_NAME=='cma' 默认拒绝 --apply/--down
--
-- 设计纪律:
-- * 只新增表,不改既有表;不改任何 KPI 定义(kpi_definitions 属准入2/4 同表串行)
-- * 每表 entity_id NOT NULL,唯一键一律以 entity_id 打头(多租户隔离 + 索引可用性)
-- * on_time 为服务端派生字段(app/services/ext_input_derive.derive_on_time),录入方不得手填
-- * 不建物理外键(业务关系列走软链名),保持 ORM 声明 == DDL 实况
-- ============================================================================
CREATE TABLE IF NOT EXISTS `tax_filing_log` (
`id` INT NOT NULL AUTO_INCREMENT,
`entity_id` INT NOT NULL COMMENT '企业ID(账套)',
`period` VARCHAR(20) NOT NULL COMMENT '属期 YYYY-MM(申报所属月份)',
`tax_type` VARCHAR(50) NOT NULL COMMENT '税种: 增值税/附加税/企业所得税/印花税…',
`legal_deadline` DATE NULL COMMENT '法定申报期限',
`filed_date` DATE NULL COMMENT '实际申报日(未申报为 NULL)',
`on_time` INT NULL COMMENT '是否按时申报 1/0(服务端派生,录入方不得手填)',
`evidence_no` VARCHAR(100) NULL COMMENT '申报回执/截图编号',
`registered_by` VARCHAR(100) NULL COMMENT '登记人(职能岗位)',
`remark` VARCHAR(500) NULL COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_tax_filing_log_entity_period_type` (`entity_id`,`period`,`tax_type`),
KEY `ix_tax_filing_log_entity_id` (`entity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='申报行为台账(P_TAX_TIMELY 取证来源)';
CREATE TABLE IF NOT EXISTS `hr_monthly_ledger` (
`id` INT NOT NULL AUTO_INCREMENT,
`entity_id` INT NOT NULL COMMENT '企业ID(账套)',
`period` VARCHAR(20) NOT NULL COMMENT '期间 YYYY-MM',
`headcount_begin` INT NOT NULL COMMENT '期初在职人数',
`hires` INT NOT NULL DEFAULT 0 COMMENT '入职人数',
`separations` INT NOT NULL DEFAULT 0 COMMENT '离职人数',
`headcount_end` INT NOT NULL COMMENT '期末在职人数(须= 期初+入职-离职)',
`registered_by` VARCHAR(100) NULL COMMENT '登记人(职能岗位)',
`remark` VARCHAR(500) NULL COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_hr_monthly_ledger_entity_period` (`entity_id`,`period`),
KEY `ix_hr_monthly_ledger_entity_id` (`entity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='人事月度台账(L_EMPLOYEE_TURNOVER 取证来源)';
CREATE TABLE IF NOT EXISTS `licence_ledger` (
`id` INT NOT NULL AUTO_INCREMENT,
`entity_id` INT NOT NULL COMMENT '企业ID(账套)',
`licence_name` VARCHAR(200) NOT NULL COMMENT '证照名称',
`licence_no` VARCHAR(100) NOT NULL COMMENT '证照编号',
`valid_from` DATE NULL COMMENT '有效期起',
`valid_to` DATE NULL COMMENT '有效期止(空=长期有效)',
`annual_check_status` VARCHAR(20) NOT NULL DEFAULT '未到期' COMMENT '年检状态: 未到期/已年检/待年检/过期',
`issuer` VARCHAR(200) NULL COMMENT '发证/主管机关',
`owner_role` VARCHAR(100) NULL COMMENT '责任岗位(职能,不指定自然人)',
`attachment_no` VARCHAR(100) NULL COMMENT '附件/扫描件编号',
`registered_by` VARCHAR(100) NULL COMMENT '登记人(职能岗位)',
`remark` VARCHAR(500) NULL COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_licence_ledger_entity_name_no` (`entity_id`,`licence_name`,`licence_no`),
KEY `ix_licence_ledger_entity_id` (`entity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证照台账(P_LICENCE_OK 取证来源)';
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""外部输入台账 端到端验收电池(**试验田 = scratch 库**,零生产写入)
任务: 0-P0-project-20260915-cma-eval-m2-ext-input-tables(2026-09-15)
做什么:
1. root@unix_socket 建库 cma_scratch_ext_<ts>(先 DROP IF EXISTS,保证可复跑)
2. 跑真实迁移脚本(--check / --apply / 二次 --apply 幂等 / --show-create 证据),目标 = scratch
3. scratch 上建其余表 + 最小夹具(entities / users / kpi_definitions),不复制生产数据
4. 真实 HTTP(TestClient)跑:新增 / CSV 批量导入 ×2 / 按 (entity_id, period) 查询 /
派生 ×2(幂等)/ 分母 0 不写值(税务·人事·证照三口径)/ 定义缺失跳过 / 跨账套 403 / 未认证 401
—— **原始请求与响应 JSON 全文打印**
5. 收尾 DROP scratch
边界自证(本单「生产库未写入」):
* 全程只创建 1 个 engine,URL 指向 scratch 库;脚本不读也不写 cma 库
* 迁移脚本对 DB_NAME=='cma' 的 --apply/--down 默认拒绝(下方单独打印该守卫的实测输出)
* 不复制生产数据(未使用 mysqldump):夹具全部脚本内合成;token 走进程内存储(不写生产 Redis)
用法: cd /root/cma-management/backend && ./venv/bin/python3 scripts/verify_ext_input_ledgers_e2e.py
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
BACKEND = os.path.dirname(HERE)
sys.path.insert(0, BACKEND)
import pymysql # noqa: E402
from sqlalchemy import create_engine # noqa: E402
SOCKET = "/var/run/mysqld/mysqld.sock"
SCRATCH = f"cma_scratch_ext_{time.strftime('%Y%m%d%H%M%S')}"
SCRATCH_URL = f"mysql+pymysql://root@localhost/{SCRATCH}?unix_socket={SOCKET}&charset=utf8mb4"
MIGRATE = os.path.join(HERE, "migrate_ext_input_ledgers_20260915.py")
VENV_PY = os.path.join(BACKEND, "venv", "bin", "python3")
FAILURES = []
def say(title: str, body: str = "") -> None:
print(f"\n===== {title} =====")
if body:
print(body)
def check(name: str, cond: bool, detail: str = "") -> None:
print(f" [{'PASS' if cond else 'FAIL'}] {name}{(' | ' + detail) if detail else ''}")
if not cond:
FAILURES.append(f"{name} | {detail}")
def raw_req(label: str, resp) -> None:
"""原始请求/响应全文(验收要求「真实请求与响应原文贴出」)"""
body = resp.text
if len(body) > 3000:
body = body[:3000] + "...(truncated)"
print(f" -> {label}: HTTP {resp.status_code}\n {body}")
def root_conn(db=None):
return pymysql.connect(unix_socket=SOCKET, user="root", database=db, charset="utf8mb4")
def count(db, model) -> int:
"""回查行数:先 rollback 结束当前事务 —— MySQL REPEATABLE READ 下 session 的长事务
会固定快照,看不到 API 请求(另一连接)刚提交的行,会误判「写入 0 行」。"""
db.rollback()
return db.query(model).count()
def main() -> int:
con = root_conn()
with con.cursor() as cur:
cur.execute(f"DROP DATABASE IF EXISTS {SCRATCH}")
cur.execute(f"CREATE DATABASE {SCRATCH} DEFAULT CHARSET utf8mb4")
con.close()
print(f"scratch 库已建: {SCRATCH}")
# ── 1. 迁移脚本:check → apply → 二次 apply(幂等)→ show-create ──────────
def run_migrate(*args):
p = subprocess.run([VENV_PY, MIGRATE, "--url", SCRATCH_URL] + list(args),
capture_output=True, text=True, cwd=BACKEND)
return p.returncode, (p.stdout or "") + (p.stderr or "")
rc, out = run_migrate("--check")
say("迁移 --check(建表前,期望三表 exists=False)", out)
check("apply 前 --check rc=0 且三表不存在", rc == 0 and out.count("exists=False") == 3)
rc, out = run_migrate("--apply")
say("迁移 --apply(建 3 张外部输入台账)", out)
check("apply rc=0 且仍缺表为 0", rc == 0 and "仍缺表: []" in out)
rc, out2 = run_migrate("--apply")
say("迁移 --apply 二次(幂等复核)", out2)
check("二次 apply 幂等(仍缺表: [],新增 0)", "仍缺表: []" in out2)
rc, out = run_migrate("--check")
table_lines = [ln for ln in out.splitlines()
if ln.strip().startswith(("tax_filing_log", "hr_monthly_ledger", "licence_ledger"))]
check("--check 三表存在且缺列=[]",
len(table_lines) == 3 and all("exists=True" in ln and "missing_cols=[]" in ln for ln in table_lines))
rc, out = run_migrate("--show-create")
say("SHOW CREATE TABLE 原文(验收证据)", out)
# ── 1b. 生产守卫实测(目标库名=cma 时 --apply 必须被拒)──────────────────
guard_env = dict(os.environ)
guard_env.pop("CMA_DDL_APPROVED", None)
p = subprocess.run([VENV_PY, MIGRATE, "--apply", "--url",
"mysql+pymysql://root@localhost/cma?charset=utf8mb4"],
capture_output=True, text=True, cwd=BACKEND, env=guard_env)
say("生产守卫实测(--apply 目标=cma,期望拒绝且零执行)", (p.stdout or "") + (p.stderr or ""))
check("生产库 --apply 被守卫拒绝(exit=3)",
p.returncode == 3 and "拒绝执行" in (p.stdout or ""), f"exit={p.returncode}")
# ── 2. 指向 scratch 的 engine + 建其余表 + 夹具 ─────────────────────────
import app.database as dbmod
dbmod.DATABASE_URL = SCRATCH_URL
dbmod._engine = None
dbmod._SessionLocal = None
engine = dbmod.get_engine()
check("app.database 指向 scratch(非生产)", engine.url.database == SCRATCH, str(engine.url.database))
import app.auth_middleware as am
am._redis_available = False # token 走进程内内存,不碰生产 Redis
am._token_store.clear()
from app.database import Base
import app.models # noqa: F401 注册全部模型
from app.models import (Entity, KPIDefinition, KPIValue, LicenceLedger, TaxFilingLog,
HrMonthlyLedger, User)
Base.metadata.create_all(bind=engine) # 三张新表已由迁移脚本建好 → checkfirst 跳过
Session = dbmod.get_session_local()
db = Session()
db.add_all([Entity(id=1, name="测试酣客", short_name="酣客", status="active"),
Entity(id=2, name="测试博海", short_name="博海", status="active")])
db.add(User(id=1, username="vfy_admin", password_hash="x", name="验收管理员", role="ceo"))
for code, name in (("P_TAX_TIMELY", "税务申报及时率"), ("L_EMPLOYEE_TURNOVER", "员工流失率"),
("P_LICENCE_OK", "经营许可合规率")):
db.add(KPIDefinition(entity_id=1, kpi_code=code, kpi_name=name, unit="%",
dimension="process", status="active"))
db.commit()
H1 = {"Authorization": f"Bearer {am.create_token(1, 1)}"} # user 1 / entity 1
H2 = {"Authorization": f"Bearer {am.create_token(1, 2)}"} # user 1 / entity 2
say("夹具", "entity 1/2;user id=1(ceo);entity 1 有 3 个 KPI 定义、entity 2 无定义;"
"token 走进程内存储(未写生产 Redis)")
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
# ── 3. 真实 HTTP:新增 / on_time 服务端派生 ───────────────────────────
r = client.post("/api/cma/ext-ledger/tax-filings", headers=H1, json={
"entity_id": 1, "period": "2026-08", "tax_type": "个人所得税",
"legal_deadline": "2026-08-15", "filed_date": "2026-08-20",
"on_time": 1, "evidence_no": "HZ-000", "registered_by": "财务部"})
raw_req("POST /tax-filings(请求带 on_time=1,实际逾期)", r)
b = r.json()
check("on_time 服务端派生 = 0(手填值被忽略并回报)",
r.status_code == 201 and b["data"]["on_time"] == 0 and b["on_time_ignored"] is True,
json.dumps(b, ensure_ascii=False))
r = client.post("/api/cma/ext-ledger/tax-filings", headers=H1, json={
"period": "2026-08", "tax_type": "个人所得税", "legal_deadline": "2026-08-15",
"filed_date": "2026-08-12"})
raw_req("POST /tax-filings 重复(唯一键冲突)", r)
check("唯一键冲突 → 409 且不新增", r.status_code == 409)
# ── 4. CSV 批量导入 ×2(幂等)────────────────────────────────────────
tax_csv = ("period,tax_type,legal_deadline,filed_date,evidence_no\n"
"2026-08,增值税,2026-08-15,2026-08-12,HZ-001\n"
"2026-08,附加税,2026-08-15,2026-08-12,HZ-002\n"
"2026-08,印花税,2026-08-15,2026-08-20,HZ-003\n"
"2026-07,增值税,2026-07-15,2026-07-10,HZ-004\n"
"2026-07,附加税,2026-07-15,2026-07-10,HZ-005\n")
r1 = client.post("/api/cma/ext-ledger/tax-filings/import", headers=H1,
json={"csv": tax_csv, "on_conflict": "skip"})
raw_req("POST /tax-filings/import #1(CSV 5 行)", r1)
r2 = client.post("/api/cma/ext-ledger/tax-filings/import", headers=H1,
json={"csv": tax_csv, "on_conflict": "skip"})
raw_req("POST /tax-filings/import #2(同 CSV 复跑)", r2)
check("CSV 导入幂等(#1 inserted=5;#2 inserted=0 skipped=5)",
r1.json()["inserted"] == 5 and r2.json()["inserted"] == 0 and r2.json()["skipped"] == 5)
n_tax = count(db, TaxFilingLog)
check("台账行数 = 6(1 新增 + 5 导入,无重复行)", n_tax == 6, f"rows={n_tax}")
r = client.post("/api/cma/ext-ledger/hr-monthly", headers=H1, json={
"period": "2026-08", "headcount_begin": 100, "hires": 2, "separations": 1,
"headcount_end": 99})
raw_req("POST /hr-monthly(等式不成立)", r)
check("人事等式不成立 → 400 拒绝写入", r.status_code == 400 and "等式不成立" in r.json()["detail"])
check("被拒行未落库", count(db, HrMonthlyLedger) == 0)
r = client.post("/api/cma/ext-ledger/hr-monthly/import", headers=H1, json={
"csv": ("period,headcount_begin,hires,separations,headcount_end\n"
"2026-08,120,2,4,118\n2026-07,118,3,1,120\n")})
raw_req("POST /hr-monthly/import(2 行合法)", r)
check("人事台账导入 2 行(2026-08: 120-4+2=118)", r.json()["inserted"] == 2)
r = client.post("/api/cma/ext-ledger/licences/import", headers=H1, json={
"csv": ("licence_name,licence_no,valid_from,valid_to,annual_check_status,issuer\n"
"营业执照,91610100MA0001,2020-01-01,2030-01-01,已年检,市场监管局\n"
"食品经营许可证,JY1610100001,2024-01-01,2026-03-31,过期,市场监管局\n"
"酒类流通备案,HZ-2024-01,,2030-12-31,未到期,商务局\n")})
raw_req("POST /licences/import(3 行,其中 1 张已过期)", r)
check("证照台账导入 3 行", r.json()["inserted"] == 3)
# ── 5. 按 (entity_id, period) 查询 ────────────────────────────────────
r = client.get("/api/cma/ext-ledger/tax-filings?period=2026-08", headers=H1)
raw_req("GET /tax-filings?period=2026-08", r)
check("查询按期间过滤;及时率 2/4 = 50.0",
r.json()["total"] == 4 and r.json()["summary"]["tax_timely_rate"] == 50.0,
json.dumps(r.json()["summary"], ensure_ascii=False))
r = client.get("/api/cma/ext-ledger/hr-monthly?period=2026-08", headers=H1)
raw_req("GET /hr-monthly?period=2026-08", r)
check("人事查询含流失率派生(4/120 = 3.33%)", r.json()["data"][0]["turnover_rate"] == 3.33)
r = client.get("/api/cma/ext-ledger/licences?as_of=2026-06-30", headers=H1)
raw_req("GET /licences?as_of=2026-06-30", r)
check("证照合规率 2/3 = 66.7(过期证照不计入有效数)",
r.json()["summary"]["licence_ok"] == 66.7 and r.json()["summary"]["valid"] == 2,
json.dumps(r.json()["summary"], ensure_ascii=False))
# ── 6. 派生 ×2(幂等)────────────────────────────────────────────────
d1 = client.post("/api/cma/ext-ledger/derive", headers=H1, json={"period": "2026-08"})
raw_req("POST /derive #1(2026-08)", d1)
v1 = {k: v["actual_value"] for k, v in d1.json()["items"].items()}
a1 = {k: v["action"] for k, v in d1.json()["items"].items()}
check("派生值 = 税务 50.0 / 流失率 3.33 / 合规率 66.7(2026-Q3)",
v1 == {"P_TAX_TIMELY": 50.0, "L_EMPLOYEE_TURNOVER": 3.33, "P_LICENCE_OK": 66.7},
json.dumps(v1, ensure_ascii=False))
check("首次派生全 insert(含季频期间归集 2026-08 → 2026-Q3)",
set(a1.values()) == {"insert"} and d1.json()["items"]["P_LICENCE_OK"]["period"] == "2026-Q3",
json.dumps(a1, ensure_ascii=False))
kv1 = count(db, KPIValue)
check("kpi_values 写入 3 行", kv1 == 3, f"rows={kv1}")
d2 = client.post("/api/cma/ext-ledger/derive", headers=H1, json={"period": "2026-08"})
raw_req("POST /derive #2(同期间复跑 → 幂等)", d2)
a2 = {k: v["action"] for k, v in d2.json()["items"].items()}
kv2 = count(db, KPIValue)
check("派生幂等(第二次全 upsert;kpi_values 行数不变)",
set(a2.values()) == {"upsert"} and kv1 == kv2, f"kv#1={kv1} kv#2={kv2} actions={a2}")
# 分母 0(口径一/二):2026-09 无任何税务/人事台账行 → None 且不写值
d3 = client.post("/api/cma/ext-ledger/derive", headers=H1, json={
"period": "2026-09", "kpi_codes": ["P_TAX_TIMELY", "L_EMPLOYEE_TURNOVER"]})
raw_req("POST /derive(2026-09 台账空 → 分母 0)", d3)
check("分母为 0 → actual_value=None 且 action=skipped_denominator_zero",
all(v["actual_value"] is None and v["action"] == "skipped_denominator_zero"
for v in d3.json()["items"].values()),
json.dumps({k: v["action"] for k, v in d3.json()["items"].items()}, ensure_ascii=False))
check("分母 0 未新增 kpi_values 行(无 0、无大数)", count(db, KPIValue) == kv2)
# 分母 0(口径三):证照台账清空后 P_LICENCE_OK 亦为 None(且回报旧行 stale)
db.query(LicenceLedger).delete()
db.commit()
d4 = client.post("/api/cma/ext-ledger/derive", headers=H1, json={
"period": "2026-08", "kpi_codes": ["P_LICENCE_OK"]})
raw_req("POST /derive(证照台账已清空 → 应持证照数 0)", d4)
it4 = d4.json()["items"]["P_LICENCE_OK"]
check("证照分母 0 → None 且不覆盖/不新增值",
it4["actual_value"] is None and it4["action"] == "skipped_denominator_zero"
and it4["stale_row_present"] is True,
json.dumps(it4, ensure_ascii=False)[:300])
# 定义缺失:不新建定义
d5 = client.post("/api/cma/ext-ledger/derive", headers=H2, json={"period": "2026-08"})
raw_req("POST /derive(entity=2 无 KPI 定义)", d5)
check("定义缺失 → skipped_no_definition(本模块不新建/不改动 KPI 定义)",
all(v["action"] == "skipped_no_definition" for v in d5.json()["items"].values()))
check("KPI 定义总数未变", count(db, KPIDefinition) == 3,
f"defs={count(db, KPIDefinition)}")
# ── 7. 边界:跨账套 403 / 未认证 401 ─────────────────────────────────
r = client.post("/api/cma/ext-ledger/hr-monthly?entity_id=2", headers=H1, json={
"period": "2026-08", "headcount_begin": 10, "hires": 0, "separations": 0, "headcount_end": 10})
raw_req("POST /hr-monthly?entity_id=2(会话账套=1)", r)
check("跨账套 → 403", r.status_code == 403 and "无权访问企业 entity_id=2" in r.json()["detail"])
r = client.get("/api/cma/ext-ledger/options")
raw_req("GET /options(无 token)", r)
check("未认证 → 401", r.status_code == 401)
# ── 8. 收尾 ─────────────────────────────────────────────────────────
say("汇总断言", f"FAIL 数 = {len(FAILURES)}" + ("" if not FAILURES else "\n " + "\n ".join(FAILURES)))
db.close()
engine.dispose()
con = root_conn()
with con.cursor() as cur:
cur.execute("SET SESSION lock_wait_timeout=8")
for _ in range(3):
try:
cur.execute(f"DROP DATABASE IF EXISTS {SCRATCH}")
break
except Exception:
time.sleep(1)
con.close()
print(f"\nscratch 库已删除: {SCRATCH}")
print("边界自证: 全程 engine 数=1(URL 指向 scratch);未连接/未读写生产库 cma;"
"未使用 mysqldump 复制生产数据;token 未写生产 Redis")
return 1 if FAILURES else 0
if __name__ == "__main__":
sys.exit(main())