fix(data): 数据管理模块脱节修复 — Excel导入/sources端点 entity 隔离 + ERP直连通道诚实化(410) + 真实zxt链路展示

- import_excel/import_excel_smart KPI字典按 entity_id 过滤(防跨账套匹配串写,P0安全)
- import_excel_smart 自动建KPI编码防冲突(_next_code 循环取号)
- sources list/create/update/delete 全部按 entity_id 隔离(create 写入当前账套)
- sync-kpis + admin/erp-sync 下线返回410(erp-gateway DB凭据2026-08-31已删,真实通道=zxt智享通注入)
- 新增 GET /data/channel-status:展示真实zxt取数通道状态+遗留ERP数据源清单(诚实化)
- DataManage.vue:数据源tab顶部展示真实通道(最近注入period/时间/行数/落盘目录mtime)、ERP遗留源标注'非当前通道'、实际取数映射(kpi_value_sources)表格
- 新增 test_data_entity_isolation_20260908.py(8条:双账套导入隔离/CRUD隔离/sync-kpis 410/channel-status)
This commit is contained in:
Hermes CI Fix
2026-09-08 11:19:38 +08:00
parent bdcfa2500e
commit 193061cf4d
5 changed files with 471 additions and 79 deletions
@@ -0,0 +1,230 @@
"""数据管理模块脱节修复测试(2026-09-08 P0,project-20260908-data-manage-fix)
覆盖:
- Excel 导入不跨账套匹配(import-excel / import-excel-smart 双端点)
- 数据源管理端点 entity 隔离(list/create/update/delete + 跨账套 404)
- sync-kpis 已停用(410)
- channel-status 真实通道状态端点
"""
import io
import pandas as pd
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import KPIDefinition, KPIValue, DataSourceConfig, Entity
from tests.conftest import create_test_user, get_token_for_user, auth_header
DATA = "/api/cma/data"
def _ensure_entity(db: Session, eid: int, name: str = None) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name or f"企业{eid}", short_name=f"E{eid}", status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _login(client: TestClient, username: str, password: str, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": username, "password": password, "entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _excel_bytes(df: pd.DataFrame) -> io.BytesIO:
buf = io.BytesIO()
df.to_excel(buf, index=False)
buf.seek(0)
return buf
class TestExcelImportEntityIsolation:
"""Excel 导入只匹配当前账套 KPI,防跨账套写入(P0 安全)"""
def _mk_kpi(self, db: Session, code: str, eid: int) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid, kpi_code=code, kpi_name=f"{code}-名称",
dimension="finance", category="financial_report", formula="-",
data_source_type="excel", data_source="Excel导入", data_owner="财务部",
frequency="monthly", unit="元", target_value=0,
kpi_level="operational", status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def test_import_excel_entity2_does_not_write_entity1_kpi(self, client: TestClient, db: Session):
"""entity2 登录导入 F_REVENUE → entity1 的 F_REVENUE 零写入(旧实现会全表匹配串写)"""
_ensure_entity(db, 2)
k1 = self._mk_kpi(db, "F_REVENUE", 1)
_ensure_entity(db, 2)
create_test_user(db)
token2 = _login(client, "testadmin", "admin123", entity_id=2)
before = db.query(KPIValue).filter(KPIValue.kpi_id == k1.id).count()
df = pd.DataFrame({
"kpi_code": ["F_REVENUE"],
"period": ["2026-08"],
"actual_value": [999.0],
})
resp = client.post(
f"{DATA}/import-excel",
headers=auth_header(token2),
files={"file": ("t.xlsx", _excel_bytes(df),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert resp.status_code == 200
# entity2 无 F_REVENUE → 匹配不到 → 跳过 0 条(不是跨账套写入 entity1)
assert "0 条" in resp.json()["message"]
assert "不存在" in resp.json()["message"]
after = db.query(KPIValue).filter(KPIValue.kpi_id == k1.id).count()
assert after == before, "entity2 导入不得写入 entity1 的 KPI"
def test_import_excel_smart_creates_kpi_in_current_entity_only(self, client: TestClient, db: Session):
"""entity2 智能导入不存在的 F_REVENUE → 自动创建在 entity2(不匹配 entity1 的同名KPI)"""
_ensure_entity(db, 2)
k1 = self._mk_kpi(db, "F_REVENUE", 1)
create_test_user(db)
token2 = _login(client, "testadmin", "admin123", entity_id=2)
df = pd.DataFrame({
"kpi_code": ["F_REVENUE"],
"period": ["2026-08"],
"actual_value": [999.0],
})
resp = client.post(
f"{DATA}/import-excel-smart",
headers=auth_header(token2),
files={"file": ("测试利润表.xlsx", _excel_bytes(df),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
# entity2 自动创建 KPI(PL_ 前缀因为文件名含"利润表"),不写 entity1 的 F_REVENUE
assert "自动创建1个新KPI" in body["message"]
created = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == 2,
KPIDefinition.kpi_code.like("PL_%"),
).first()
assert created is not None, "entity2 导入应自动创建 PL_xxx KPI 在 entity2 账套"
# entity1 的 F_REVENUE 及其 KPIValue 未被触碰
assert db.query(KPIValue).filter(KPIValue.kpi_id == k1.id).count() == 0
def test_import_excel_entity1_own_kpi_still_works(self, client: TestClient, db: Session):
"""正常路径:entity1 导入自己的 F_REVENUE 成功"""
k1 = self._mk_kpi(db, "F_REVENUE", 1)
create_test_user(db)
token1 = get_token_for_user(client) # entity1
df = pd.DataFrame({
"kpi_code": ["F_REVENUE"],
"period": ["2026-08"],
"actual_value": [100.0],
})
resp = client.post(
f"{DATA}/import-excel",
headers=auth_header(token1),
files={"file": ("t.xlsx", _excel_bytes(df),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert resp.status_code == 200
assert "导入成功 1 条" in resp.json()["message"]
row = db.query(KPIValue).filter(KPIValue.kpi_id == k1.id).first()
assert row is not None and row.actual_value == 100.0
class TestSourceCRUDEntityIsolation:
"""数据源管理端点 entity 隔离(P1)"""
def _mk_entity2_source(self, client, db, token2) -> int:
resp = client.post(
f"{DATA}/sources",
headers=auth_header(token2),
json={"name": "entity2数据源", "source_type": "manual"},
)
assert resp.status_code == 200
assert resp.json()["data"]["entity_id"] == 2
return resp.json()["data"]["id"]
def test_list_sources_isolated_by_entity(self, client: TestClient, db: Session):
"""entity2 登录 list_sources 看不到 entity1 的数据源"""
_ensure_entity(db, 2)
create_test_user(db)
token1 = get_token_for_user(client)
token2 = _login(client, "testadmin", "admin123", entity_id=2)
r1 = client.post(f"{DATA}/sources", headers=auth_header(token1),
json={"name": "entity1数据源", "source_type": "manual"})
assert r1.status_code == 200
# entity2 登录看不到 entity1 的源
resp2 = client.get(f"{DATA}/sources", headers=auth_header(token2))
assert resp2.status_code == 200
names2 = [s["name"] for s in resp2.json()["data"]]
assert "entity1数据源" not in names2
# entity1 登录能看到自己的
resp1 = client.get(f"{DATA}/sources", headers=auth_header(token1))
names1 = [s["name"] for s in resp1.json()["data"]]
assert "entity1数据源" in names1
def test_cross_entity_update_delete_404(self, client: TestClient, db: Session):
"""entity2 不能改/删 entity1 的数据源(404 越权防护)"""
_ensure_entity(db, 2)
create_test_user(db)
token1 = get_token_for_user(client)
token2 = _login(client, "testadmin", "admin123", entity_id=2)
r1 = client.post(f"{DATA}/sources", headers=auth_header(token1),
json={"name": "entity1的源", "source_type": "manual"})
e1_source_id = r1.json()["data"]["id"]
# entity2 尝试更新 entity1 的源 → 404(隔离生效,不再允许越权操作)
resp = client.put(f"{DATA}/sources/{e1_source_id}", headers=auth_header(token2),
json={"name": "篡改"})
assert resp.status_code == 404
resp = client.delete(f"{DATA}/sources/{e1_source_id}", headers=auth_header(token2))
assert resp.status_code == 404
# entity1 自己仍可删除
resp = client.delete(f"{DATA}/sources/{e1_source_id}", headers=auth_header(token1))
assert resp.status_code == 200
class TestSyncKpisDecommissioned:
"""sync-kpis 收口:ERP 直连通道 2026-08-31 停用,端点返回 410(下线说明)"""
def test_sync_kpis_returns_410(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{DATA}/sync-kpis", headers=auth_header(token))
assert resp.status_code == 410
assert "2026-08-31" in resp.json()["detail"]
assert "zxt" in resp.json()["detail"]
def test_admin_erp_sync_returns_410(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post("/api/cma/admin/erp-sync", headers=auth_header(token))
assert resp.status_code == 410
resp2 = client.get("/api/cma/admin/erp-sync/dry-run", headers=auth_header(token))
assert resp2.status_code == 410
class TestChannelStatus:
"""channel-status 真实取数链路状态端点"""
def test_channel_status_shape(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{DATA}/channel-status", headers=auth_header(token))
assert resp.status_code == 200
d = resp.json()
assert "real_channel" in d and "erp_direct" in d
assert d["real_channel"]["channel"].startswith("zxt")
assert d["erp_direct"]["enabled"] is False
assert "2026-08-31" in d["erp_direct"]["reason"]