Files
cma-management/backend/tests/test_entity_dimension_convergence_20260923.py
T
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

613 lines
31 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.
"""账套维度收口批 · 2026-09-23 — 四段验收用例
任务:`0-P1-project-20260923-entity-dimension-convergence-fullstack`
(裁定件 `project-entity-dimension-ruling-20260923`)
A 段(零 DDL)
① `kpi_templates` instantiate 构造点显式挂会话账套 + `kpi_code` 判重按账套收紧
② `kpi_subject_map` 3 个读取点护栏注释(静态判据:`grep -n "账套维度经"` ≥3 命中)
③ `analysis_results` 读侧止血(`entity_id` 列未落地时按 `kpi_code` JOIN 判归属)
B 段(需 DDL,代码侧先落)
③ `subjects` 分层读取口径(全局 `entity_id IS NULL` ∪ 本账套私有)+ UPSERT 按账套判重
④ `analysis_results` 加列后的读/写/删三端点账套化
D 段:两个迁移脚本的旧形态/新形态/回填闸门/回滚/生产库守卫(SQLite 沙箱演练)
全部用例只跑内存 SQLite / 临时文件 SQLite(`tests/conftest.py` 机制),
**零生产库 / 零生产 Redis 连接**;迁移脚本用 `--url sqlite:///…` 沙箱,不碰 `cma` 库。
"""
import importlib
import json
import os
import sys
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
from app import models as models_pkg
from app.api import analysis_results as ar_mod
from app.models import (
AnalysisResult, Entity, KPIDefinition, KPISubjectMap, KPITemplate, OperationLog, Subject,
)
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES = "/api/cma/templates"
ANALYSIS = "/api/cma/analysis"
SUBJECTS = "/api/cma/subjects"
BOT_SUBJECTS = "/api/cma/bot/subjects"
DATA_CLS = "/api/cma/data-classification"
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
MIG_SUBJECTS = "scripts.migrate_subjects_entity_id_20260923"
MIG_ANALYSIS = "scripts.migrate_analysis_results_entity_id_20260923"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str, status: str = "active") -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status=status)
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "陕西酣客文化传媒")
_ensure_entity(db, 2, "陕西博海网络科技")
def _login_as(client: TestClient, username: str, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": username, "password": TEST_USER_PASSWORD, "entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 {username}@entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _two_tokens(client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
return _login_as(client, "testadmin", 1), _login_as(client, "testadmin", 2)
def _seed_kpi(db: Session, code: str, entity_id: int, name: str = "测试KPI") -> KPIDefinition:
kpi = KPIDefinition(kpi_code=code, kpi_name=name, dimension="finance",
unit="元", target_value=100.0, status="active", entity_id=entity_id)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _seed_analysis(db: Session, kpi_code, entity_id, conclusion: str, period: str = "2026-06"):
row = AnalysisResult(period=period, conclusion=conclusion, confidence=70,
kpi_code=kpi_code, entity_id=entity_id)
db.add(row)
db.commit()
db.refresh(row)
return row
def _seed_subject(db: Session, code: str, name: str, entity_id=None, remark=None) -> Subject:
s = Subject(subject_code=code, subject_name=name, entity_id=entity_id, remark=remark,
is_active=1, level=1)
db.add(s)
db.commit()
db.refresh(s)
return s
# ══════════════════════════════════════════════
# ① kpi_templates instantiate 构造点
# ══════════════════════════════════════════════
class TestTemplateInstantiateEntityScope:
def _seed_template(self, db: Session, code: str = "F_TPL_REV") -> KPITemplate:
t = KPITemplate(kpi_code=code, kpi_name="模板_营业收入", dimension="finance",
unit="万元", formula="SUM(收入)", formula_desc="收入合计",
target_value=100.0, is_system=1, usage_count=0)
db.add(t)
db.commit()
db.refresh(t)
return t
def _instantiate(self, client: TestClient, token: str, tid: int, code: str):
return client.post(f"{TEMPLATES}/{tid}/instantiate", headers=auth_header(token),
json={"kpi_code": code, "kpi_name": "实例KPI", "dimension": "finance",
"unit": "万元", "target_value": 100.0})
def test_each_entity_gets_its_own_row(self, client: TestClient, db: Session):
"""e1 / e2 各实例化一次(同码)→ 回库各 1 行且归属正确"""
t1, t2 = _two_tokens(client, db)
tpl = self._seed_template(db, "F_TPL_REV")
r1 = self._instantiate(client, t1, tpl.id, "F_TPL_REV")
assert r1.status_code == 200, r1.text
assert r1.json()["entity_id"] == 1
# e2 允许建同码(uk_entity_kpi 复合唯一;全表判重会让博海建不了同码 KPI)
r2 = self._instantiate(client, t2, tpl.id, "F_TPL_REV")
assert r2.status_code == 200, r2.text
assert r2.json()["entity_id"] == 2
db.expire_all()
rows = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_TPL_REV").all()
assert len(rows) == 2, [r.id for r in rows]
assert sorted(r.entity_id for r in rows) == [1, 2]
def test_same_entity_duplicate_code_rejected(self, client: TestClient, db: Session):
"""同账套内同码 → 400(判重按账套收紧,不放过本账套重复)"""
t1, _ = _two_tokens(client, db)
tpl = self._seed_template(db, "F_TPL_DUP")
assert self._instantiate(client, t1, tpl.id, "F_TPL_DUP").status_code == 200
dup = self._instantiate(client, t1, tpl.id, "F_TPL_DUP")
assert dup.status_code == 400, dup.text
assert "在本账套已存在" in dup.text
db.expire_all()
assert db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == "F_TPL_DUP", KPIDefinition.entity_id == 1).count() == 1
def test_constructor_carries_entity_id_in_source(self):
"""构造点显式传账套(防再次退回模型 default=1 的静默兜底)"""
src = open(os.path.join(BACKEND, "app/api/templates.py"), encoding="utf-8").read()
assert "entity_id=entity_id" in src
assert "KPIDefinition.kpi_code == kpi_code,\n KPIDefinition.entity_id == entity_id," in src
# ══════════════════════════════════════════════
# ② kpi_subject_map 护栏(静态判据)
# ══════════════════════════════════════════════
class TestKpiSubjectMapGuardrail:
GUARD_FILES = ("app/api/ontology.py", "app/utils/alert_attribution.py",
"scripts/kpi_value_source_derive.py")
def test_guard_comment_present_in_three_read_points(self):
hits = {}
for rel in self.GUARD_FILES:
src = open(os.path.join(BACKEND, rel), encoding="utf-8").read()
hits[rel] = src.count("账套维度经")
assert all(v >= 1 for v in hits.values()), hits
assert len(hits) == 3
def test_no_subject_code_reverse_lookup_introduced(self):
"""护栏语义:三个读取点都仍按 kpi_id 取数(不得出现按 subject_code 过滤的 kpi_subject_map 查询)"""
for rel in self.GUARD_FILES:
src = open(os.path.join(BACKEND, rel), encoding="utf-8").read()
assert "KPISubjectMap.subject_code ==" not in src, rel
# ══════════════════════════════════════════════
# ③④ analysis_results 账套化(列口径 + join 止血口径)
# ══════════════════════════════════════════════
class TestAnalysisResultEntityScope:
def _seed(self, db: Session):
_seed_kpi(db, "F_AN_1", 1, "酣客指标")
_seed_kpi(db, "F_AN_2", 2, "博海指标")
_seed_kpi(db, "F_AN_SHARED", 1, "同码A")
_seed_kpi(db, "F_AN_SHARED", 2, "同码B")
r1 = _seed_analysis(db, "F_AN_1", 1, "酣客结论")
r2 = _seed_analysis(db, "F_AN_2", 2, "博海结论")
r3 = _seed_analysis(db, None, None, "无归属(kpi_code 为空)", period="2026-07")
r4 = _seed_analysis(db, "F_AN_SHARED", None, "归属二义(同码跨账套)", period="2026-08")
r5 = _seed_analysis(db, "F_AN_NOPE", None, "库内无该码", period="2026-09")
return r1, r2, r3, r4, r5
def _ids(self, payload, key="results"):
return sorted(x["id"] for x in payload[key])
# ── 列口径(DDL 已落地) ──────────────────────────────
def test_get_two_tokens_intersection_empty(self, client: TestClient, db: Session):
r1, r2, r3, r4, r5 = self._seed(db)
t1, t2 = _two_tokens(client, db)
v1 = client.get(f"{ANALYSIS}/result", headers=auth_header(t1)).json()
v2 = client.get(f"{ANALYSIS}/result", headers=auth_header(t2)).json()
assert self._ids(v1) == [r1.id], v1
assert self._ids(v2) == [r2.id], v2
assert set(self._ids(v1)) & set(self._ids(v2)) == set()
assert v1["entity_id"] == 1 and v2["entity_id"] == 2
assert v1["entity_scope"] == "column"
# 无归属行(kpi_code 空 / 无定义 / 二义)对两账套都不进 results,只在登记池
assert sorted(x["id"] for x in v1["unattributed"]) == sorted([r3.id, r4.id, r5.id])
assert v1["unattributed"] == v2["unattributed"]
assert v1["unattributed_total"] == 3
assert all("结论" not in x for x in v1["unattributed"])
def test_get_join_stopgap_before_ddl(self, client: TestClient, db: Session, monkeypatch):
"""entity_id 列未落地时(DDL 前置)按 kpi_code JOIN 判归属,止血口径与列口径一致"""
r1, r2, r3, r4, r5 = self._seed(db)
t1, t2 = _two_tokens(client, db)
monkeypatch.setattr(ar_mod, "_has_entity_column", lambda db_: False)
v1 = client.get(f"{ANALYSIS}/result", headers=auth_header(t1)).json()
v2 = client.get(f"{ANALYSIS}/result", headers=auth_header(t2)).json()
assert v1["entity_scope"] == "kpi_join"
assert self._ids(v1) == [r1.id]
assert self._ids(v2) == [r2.id]
assert set(self._ids(v1)) & set(self._ids(v2)) == set()
# 二义码(F_AN_SHARED 属两账套)在 join 口径下不猜账套 → 登记池
assert sorted(x["id"] for x in v1["unattributed"]) == sorted([r3.id, r4.id, r5.id])
def test_delete_cross_entity_is_404_and_row_survives(self, client: TestClient, db: Session):
r1, r2, _, _, _ = self._seed(db)
t1, t2 = _two_tokens(client, db)
bad = client.delete(f"{ANALYSIS}/result/{r1.id}", headers=auth_header(t2))
assert bad.status_code == 404, bad.text
db.expire_all()
assert db.query(AnalysisResult).filter(AnalysisResult.id == r1.id).count() == 1, "对端行被删"
# 反向:e1 删 e2 的行同样 404
bad2 = client.delete(f"{ANALYSIS}/result/{r2.id}", headers=auth_header(t1))
assert bad2.status_code == 404
# 正对照:本账套删自己的行 → 200
ok = client.delete(f"{ANALYSIS}/result/{r1.id}", headers=auth_header(t1))
assert ok.status_code == 200, ok.text
db.expire_all()
assert db.query(AnalysisResult).filter(AnalysisResult.id == r1.id).count() == 0
def test_delete_unattributed_is_404(self, client: TestClient, db: Session):
_, _, r3, _, _ = self._seed(db)
t1, _ = _two_tokens(client, db)
resp = client.delete(f"{ANALYSIS}/result/{r3.id}", headers=auth_header(t1))
assert resp.status_code == 404
db.expire_all()
assert db.query(AnalysisResult).filter(AnalysisResult.id == r3.id).count() == 1
def test_post_writes_session_entity(self, client: TestClient, db: Session):
_two_tokens(client, db)
_seed_kpi(db, "F_AN_1", 1)
_seed_kpi(db, "F_AN_2", 2)
t1, t2 = _login_as(client, "testadmin", 1), _login_as(client, "testadmin", 2)
p2 = client.post(f"{ANALYSIS}/result", headers=auth_header(t2),
json={"period": "2026-09", "conclusion": "博海写入", "kpi_code": "F_AN_2"})
assert p2.status_code == 200, p2.text
assert p2.json()["entity_id"] == 2
assert p2.json()["entity_persisted"] == "column"
p1 = client.post(f"{ANALYSIS}/result", headers=auth_header(t1),
json={"period": "2026-09", "conclusion": "酣客写入", "kpi_code": "F_AN_1"})
assert p1.status_code == 200
db.expire_all()
new2 = db.query(AnalysisResult).filter(AnalysisResult.id == p2.json()["id"]).first()
assert new2.entity_id == 2, "create 未写会话账套"
# 交叉可见性:各自的 GET 只看到自己那一行
v1 = client.get(f"{ANALYSIS}/result", headers=auth_header(t1)).json()
v2 = client.get(f"{ANALYSIS}/result", headers=auth_header(t2)).json()
assert self._ids(v1) == [p1.json()["id"]]
assert self._ids(v2) == [p2.json()["id"]]
def test_post_pre_ddl_logs_entity_and_does_not_guess_column(self, client: TestClient, db: Session,
monkeypatch):
"""列未落地时:账套只落 OperationLog.detail,行内 entity_id 保持 NULL(不猜列)"""
_two_tokens(client, db)
_seed_kpi(db, "F_AN_1", 1)
t1 = _login_as(client, "testadmin", 1)
monkeypatch.setattr(ar_mod, "_has_entity_column", lambda db_: False)
resp = client.post(f"{ANALYSIS}/result", headers=auth_header(t1),
json={"period": "2026-09", "conclusion": "过渡期写入", "kpi_code": "F_AN_1"})
assert resp.status_code == 200, resp.text
assert resp.json()["entity_persisted"] == "operation_log"
db.expire_all()
row = db.query(AnalysisResult).filter(AnalysisResult.id == resp.json()["id"]).first()
assert row.entity_id is None
logs = db.query(OperationLog).filter(OperationLog.target_type == "analysis_result").all()
assert len(logs) == 1, [l.id for l in logs]
detail = logs[0].detail
detail = json.loads(detail) if isinstance(detail, str) else detail
assert detail["entity_id"] == 1
# ══════════════════════════════════════════════
# ③ subjects 分层(读取口径 + UPSERT 判重)
# ══════════════════════════════════════════════
class TestSubjectsLayering:
def _seed(self, db: Session):
_seed_subject(db, "1001", "库存现金", None) # 全局标准
_seed_subject(db, "1002", "银行存款", None) # 全局标准
_seed_subject(db, "100106", "酒窖备用金", 1, remark="zxt-sync") # 酣客私有
_seed_subject(db, "100107", "现金备用卡(唐)", 1, remark="zxt-sync")
_seed_subject(db, "200201", "博海专户", 2) # 博海私有
_seed_subject(db, "100199", "酣客同码科目", 1) # 同码不同账套
_seed_subject(db, "100199", "博海同码科目", 2)
def test_get_subjects_union_scope(self, client: TestClient, db: Session):
self._seed(db)
t1, t2 = _two_tokens(client, db)
v1 = client.get(SUBJECTS, headers=auth_header(t1)).json()
v2 = client.get(SUBJECTS, headers=auth_header(t2)).json()
c1 = {d["subject_code"] for d in v1["data"]}
c2 = {d["subject_code"] for d in v2["data"]}
assert {"1001", "1002", "100106", "100107", "100199"} <= c1
assert "200201" not in c1, "博海私有明细泄漏给酣客"
assert {"1001", "1002", "200201", "100199"} <= c2
assert "100106" not in c2 and "100107" not in c2, "酣客明细泄漏给博海"
# 同码不同账套 → 各看自己的名字
n1 = {d["subject_code"]: d["subject_name"] for d in v1["data"]}
n2 = {d["subject_code"]: d["subject_name"] for d in v2["data"]}
assert n1["100199"] == "酣客同码科目"
assert n2["100199"] == "博海同码科目"
# 全局标准科目两账套都在
assert {"1001", "1002"} <= c1 and {"1001", "1002"} <= c2
def test_data_classification_inventory_and_stats_scoped(self, client: TestClient, db: Session):
self._seed(db)
t1, t2 = _two_tokens(client, db)
inv1 = client.get(f"{DATA_CLS}/inventory", params={"data_type": "subject"},
headers=auth_header(t1)).json()
inv2 = client.get(f"{DATA_CLS}/inventory", params={"data_type": "subject"},
headers=auth_header(t2)).json()
codes = lambda v: {(i.get("subject_code") or i.get("code")) for i in (v.get("items") or v.get("data") or [])}
assert "100106" in codes(inv1) and "100106" not in codes(inv2)
assert "200201" in codes(inv2) and "200201" not in codes(inv1)
s1 = client.get(f"{DATA_CLS}/stats", headers=auth_header(t1)).json()
s2 = client.get(f"{DATA_CLS}/stats", headers=auth_header(t2)).json()
assert s1["subjects"]["total"] == 5, s1 # 2 全局 + 3 酣客私有
assert s2["subjects"]["total"] == 4, s2 # 2 全局 + 2 博海私有
assert s1["total"] == 5 and s2["total"] == 4 # 无 KPI 数据时总数为科目数
def test_bot_upsert_per_entity_judgement(self, client: TestClient, db: Session):
"""UPSERT 判重按 (entity_id, subject_code):不同账套可各有同码,同账套内才 skip"""
self._seed(db)
_seed_two_entities(db)
body = {"subjects": [{"subject_code": "300301", "subject_name": "博海新专户",
"parent_code": None, "level": 1, "category": "asset", "is_active": 1}],
"update_existing": False}
h2 = dict(BOT_KEY); h2["X-Entity-Id"] = "2"
h1 = dict(BOT_KEY); h1["X-Entity-Id"] = "1"
r2 = client.post(BOT_SUBJECTS, headers=h2, json=body)
assert r2.status_code == 200, r2.text
assert r2.json()["inserted"] == 1 and r2.json()["entity_id"] == 2
r1 = client.post(BOT_SUBJECTS, headers=h1, json=body)
assert r1.status_code == 200, r1.text
assert r1.json()["inserted"] == 1, "同码但不同账套应可插入(按账套判重)"
# 同账套重复 → skip
again = client.post(BOT_SUBJECTS, headers=h1, json=body)
assert again.json()["inserted"] == 0 and again.json()["skipped"] == 1
db.expire_all()
rows = db.query(Subject).filter(Subject.subject_code == "300301").all()
assert sorted(r.entity_id for r in rows) == [1, 2]
# 全局科目(entity_id IS NULL)不被他账套 update 改写
s = client.post(BOT_SUBJECTS, headers=h1,
json={"subjects": [{"subject_code": "1001", "subject_name": "库存现金改名",
"parent_code": None, "level": 1,
"category": "asset", "is_active": 1}],
"update_existing": True})
assert s.json()["updated"] == 0, "全局标准科目不该被计入 updated(分层后只更新本账套私有行)"
assert s.json()["skipped_global"] == 1
db.expire_all()
glob = db.query(Subject).filter(Subject.subject_code == "1001",
Subject.entity_id.is_(None)).first()
assert glob.subject_name == "库存现金", "全局标准科目被账套 UPSERT 改写"
def test_bot_upsert_global_row_is_never_rewritten_nor_duplicated(self, client: TestClient, db: Session):
"""全局标准科目(entity_id IS NULL):同码既不被账套 UPSERT 改写,也不插本账套副本
回归保护(本单 A③ 修过的一处缺陷):原先 `existing` 把全局行也算「已存在」,
但 UPDATE 只匹配 `entity_id == 会话账套` ⇒ 对全局行「报 updated=1 却零改动」(响应失真)。
分层口径:全局行 → 计入 `skipped_global`,永不改写、永不重复插入。
"""
self._seed(db)
_seed_two_entities(db)
h1 = dict(BOT_KEY); h1["X-Entity-Id"] = "1"
need = {"subject_code": "1001", "subject_name": "库存现金(改名)", "parent_code": None,
"level": 1, "category": "asset", "is_active": 1}
for update_existing in (False, True):
r = client.post(BOT_SUBJECTS, headers=h1,
json={"subjects": [need], "update_existing": update_existing})
assert r.status_code == 200, r.text
d = r.json()
assert (d["inserted"], d["updated"], d["skipped_global"]) == (0, 0, 1), d
db.expire_all()
rows = db.query(Subject).filter(Subject.subject_code == "1001").all()
assert len(rows) == 1, "全局标准科目被插入了账套副本(同码重复)"
assert rows[0].entity_id is None and rows[0].subject_name == "库存现金", "全局标准科目被改写"
# ── dry_run 口径与真实执行一致 ──
def test_bot_upsert_dry_run_reports_global_skip(self, client: TestClient, db: Session):
self._seed(db)
_seed_two_entities(db)
h1 = dict(BOT_KEY); h1["X-Entity-Id"] = "1"
r = client.post(BOT_SUBJECTS, headers=h1,
json={"subjects": [{"subject_code": "1001", "subject_name": "库存现金",
"parent_code": None, "level": 1,
"category": "asset", "is_active": 1}],
"update_existing": True, "dry_run": True})
assert r.status_code == 200, r.text
d = r.json()
assert d["dry_run"] is True and d["would_update"] == 0 and d["would_skip_global"] == 1, d
def test_read_sites_carry_layer_filter(self):
"""四个读取口都必须带分层口径(防回退成整表读)"""
for rel in ("app/api/subjects.py", "app/api/data_classification.py",
"app/services/report_reclass.py", "app/api/ontology.py"):
src = open(os.path.join(BACKEND, rel), encoding="utf-8").read()
assert "Subject.entity_id.is_(None)" in src, rel
# ══════════════════════════════════════════════
# 迁移脚本:沙箱演练(SQLite 旧形态 → apply → backfill → down)
# ══════════════════════════════════════════════
OLD_SUBJECTS_DDL = """
CREATE TABLE subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_code VARCHAR(20) NOT NULL UNIQUE,
subject_name VARCHAR(200) NOT NULL,
parent_code VARCHAR(20),
level INTEGER DEFAULT 1,
category VARCHAR(50),
new_standard_category VARCHAR(20),
important_flag INTEGER DEFAULT 0,
data_level VARCHAR(20),
data_category VARCHAR(50),
is_active INTEGER DEFAULT 1,
remark VARCHAR(500),
created_at DATETIME,
updated_at DATETIME
)
"""
OLD_ANALYSIS_DDL = """
CREATE TABLE analysis_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period VARCHAR(20) NOT NULL,
conclusion VARCHAR(1000) NOT NULL,
confidence INTEGER NOT NULL,
data_source VARCHAR(500),
calculation_logic VARCHAR(1000),
comparable_benchmark VARCHAR(500),
limitations VARCHAR(1000),
has_actual INTEGER DEFAULT 0,
has_target INTEGER DEFAULT 0,
has_trend INTEGER DEFAULT 0,
has_review INTEGER DEFAULT 0,
kpi_code VARCHAR(50),
kpi_name VARCHAR(200),
created_at DATETIME
)
"""
class TestMigrationScriptsSandbox:
def _run(self, mod, argv):
old = sys.argv
sys.argv = ["migrate"] + argv
try:
return mod.main()
finally:
sys.argv = old
@pytest.fixture
def subj_env(self, tmp_path):
sys.path.insert(0, BACKEND)
mod = importlib.import_module(MIG_SUBJECTS)
eng = create_engine(f"sqlite:///{tmp_path/'subj.db'}")
with eng.begin() as conn:
conn.execute(text(OLD_SUBJECTS_DDL))
for i in range(164):
conn.execute(text("INSERT INTO subjects (subject_code, subject_name, remark) "
f"VALUES ('9{i:05d}', '酣客明细{i}', 'zxt-sync')"))
for i in range(43):
conn.execute(text("INSERT INTO subjects (subject_code, subject_name) "
f"VALUES ('1{i:03d}', '标准科目{i}')"))
return mod, eng, tmp_path
@pytest.fixture
def analysis_env(self, tmp_path):
sys.path.insert(0, BACKEND)
mod = importlib.import_module(MIG_ANALYSIS)
eng = create_engine(f"sqlite:///{tmp_path/'ar.db'}")
with eng.begin() as conn:
conn.execute(text(OLD_ANALYSIS_DDL))
conn.execute(text("INSERT INTO analysis_results (period, conclusion, confidence, kpi_code) "
"VALUES ('2026-06','测试',70,'TEST001')"))
conn.execute(text("INSERT INTO analysis_results (period, conclusion, confidence, kpi_code) "
"VALUES ('2026-07','测试2',70,NULL)"))
return mod, eng, tmp_path
def _cols(self, eng, table):
return {r[1] for r in eng.connect().execute(text(f"PRAGMA table_info({table})")).fetchall()}
# ── subjects ──────────────────────────────────
def test_subjects_check_is_readonly(self, subj_env):
mod, eng, _ = subj_env
assert self._run(mod, ["--check", "--url", str(eng.url)]) == 0
assert "entity_id" not in self._cols(eng, "subjects")
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM subjects")).scalar() == 207
def test_subjects_apply_backfill_down_roundtrip(self, subj_env):
mod, eng, tmp = subj_env
url = str(eng.url)
assert self._run(mod, ["--apply", "--url", url]) == 0
assert "entity_id" in self._cols(eng, "subjects")
# 二跑幂等
assert self._run(mod, ["--apply", "--url", url]) == 0
with eng.connect() as conn:
names = {r[0] for r in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='index'")).fetchall()}
assert "uk_entity_subject" in names and "ix_subjects_entity_id" in names
assert conn.execute(text("SELECT COUNT(*) FROM subjects WHERE entity_id IS NOT NULL")).scalar() == 0
# 回填闸门:无取证件 / 取证件行数不符 → 拒绝(fail-closed)
assert self._run(mod, ["--backfill", "--url", url]) == 3
bad = tmp / "ev_bad.json"
bad.write_text(json.dumps({"dama_evidence": "x", "finance_confirmation": "y",
"counts": {"e1": 1, "global_null": 43}}), encoding="utf-8")
assert self._run(mod, ["--backfill", "--url", url, "--evidence", str(bad)]) == 3
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM subjects WHERE entity_id=1")).scalar() == 0
# 正确取证件 → 164 行归 e1、43 行留 NULL
good = tmp / "ev_ok.json"
good.write_text(json.dumps({"dama_evidence": "dama-...out", "finance_confirmation": "任总...",
"counts": {"e1": 164, "global_null": 43}}), encoding="utf-8")
assert self._run(mod, ["--backfill", "--url", url, "--evidence", str(good),
"--target-entity", "1"]) == 0
with eng.connect() as conn:
dist = dict(conn.execute(text(
"SELECT IFNULL(entity_id,'NULL'), COUNT(*) FROM subjects GROUP BY 1")).fetchall())
assert dist == {1: 164, "NULL": 43}, dist
# 回滚:有分账套数据须 --yes;回滚后列消失、code 唯一性恢复
assert self._run(mod, ["--down", "--url", url]) == 3
assert self._run(mod, ["--down", "--url", url, "--yes"]) == 0
assert "entity_id" not in self._cols(eng, "subjects")
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM subjects")).scalar() == 207
def test_subjects_prod_name_guard(self, subj_env, tmp_path, monkeypatch):
mod, _, _ = subj_env
monkeypatch.chdir(tmp_path)
assert self._run(mod, ["--apply", "--url", "sqlite:///cma"]) == 3 # 未放行
monkeypatch.setenv("CMA_DDL_APPROVED", "1")
assert self._run(mod, ["--apply", "--url", "sqlite:///cma", "--allow-prod"]) == 3 # 表不存在 → 3
# ── analysis_results ──────────────────────────
def test_analysis_apply_and_down(self, analysis_env):
mod, eng, _ = analysis_env
url = str(eng.url)
assert self._run(mod, ["--check", "--url", url]) == 0
assert self._run(mod, ["--apply", "--url", url]) == 0
assert "entity_id" in self._cols(eng, "analysis_results")
assert self._run(mod, ["--apply", "--url", url]) == 0 # 幂等
with eng.connect() as conn:
names = {r[0] for r in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='index'")).fetchall()}
assert "ix_analysis_results_entity_id" in names
# 存量 2 行不被代猜账套
assert conn.execute(text(
"SELECT COUNT(*) FROM analysis_results WHERE entity_id IS NOT NULL")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM analysis_results")).scalar() == 2
assert self._run(mod, ["--down", "--url", url, "--yes"]) == 0
assert "entity_id" not in self._cols(eng, "analysis_results")
def test_analysis_prod_name_guard(self, analysis_env, tmp_path, monkeypatch):
mod, _, _ = analysis_env
monkeypatch.chdir(tmp_path)
assert self._run(mod, ["--apply", "--url", "sqlite:///cma"]) == 3