feat(entity-dim): 账套维度收口批 — subjects/analysis_results 加列 + 分析结论账套化 + 模板实例化构造点补账套
ci/woodpecker/push/main Pipeline failed

- ① 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(发布须另取放行)
This commit is contained in:
Hermes CI Fix
2026-09-23 21:39:24 +08:00
parent 0b3a8aa111
commit fb4609be14
14 changed files with 1429 additions and 65 deletions
+4 -4
View File
@@ -554,7 +554,7 @@ class TestSubjectsWrite:
def test_update_existing_switch(self, client: TestClient, db: Session):
db.add(Subject(subject_code="9301", subject_name="旧名称", level=1,
category="equity", is_active=1))
category="equity", is_active=1, entity_id=1)) # 账套维度:本账套私有行(分层后 NULL=全局标准科目)
db.commit()
resp = client.post(self.URL, json={
"subjects": [self._row("9301", "新名称", category="cost", level=2)],
@@ -603,9 +603,9 @@ class TestSubjectsWrite:
def test_revenue_existing_row_update_existing_true(self, client: TestClient, db: Session):
"""原 400 复现用例:库内既有 revenue 行(6001 主营业务收入)+ update_existing=true"""
db.add(Subject(subject_code="6001", subject_name="主营业务收入", level=1,
category="revenue", is_active=1))
category="revenue", is_active=1, entity_id=1)) # 本账套私有行(分层口径)
other = Subject(subject_code="6051", subject_name="其他业务收入", level=1,
category="revenue", is_active=1)
category="revenue", is_active=1, entity_id=1)
db.add(other)
db.commit()
before = db.query(Subject).count()
@@ -625,7 +625,7 @@ class TestSubjectsWrite:
def test_profit_loss_existing_row_update_existing_true(self, client: TestClient, db: Session):
db.add(Subject(subject_code="6901", subject_name="终止经营损益", level=1,
category="profit_loss", is_active=1))
category="profit_loss", is_active=1, entity_id=1)) # 本账套私有行(分层口径)
db.commit()
resp = client.post(self.URL, json={
"subjects": [self._row("6901", "终止经营损益(V2)", category="profit_loss")],
@@ -0,0 +1,612 @@
"""账套维度收口批 · 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