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
+119 -59
View File
@@ -1,6 +1,6 @@
"""数据对接 API"""
import pandas as pd
import io, json, hashlib, re
import io, json, hashlib, re, os
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from sqlalchemy.orm import Session
@@ -11,6 +11,17 @@ from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
# 陕西酣客财务原始数据的真实通道 = DAMA 读智享通网页 → 落盘 → 财务Bot 注入(source_type='zxt-sync')
# 任何 ERP API 直连同步入口(/data/sync-kpis、/admin/erp-sync)一律拒绝执行,防止写 estimated 假数据
ERP_DIRECT_CHANNEL_DISABLED = True
ERP_DIRECT_CHANNEL_REASON = ("ERP直连同步通道已于2026-08-31停用(erp-gateway 容器 DB 凭据已删除,zxbtest 账号停用)。"
"财务原始数据真实通道为智享通 zxt 通道:DAMA 读智享通网页 → 落盘 /root/WB_WS/_data/zxt-hanke → 财务Bot 注入 CMA"
"(subjects+kpi_values 双写, source_type='zxt-sync')。如需恢复 ERP API 直连,请联系运维重新配置 erp-gateway DB 凭据。")
# 智享通 zxt 落盘目录(真实通道文件来源;任总 2026-09-08 确认财务原始数据走 zxt_push)
ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke"
router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@@ -21,7 +32,8 @@ async def import_excel(file: UploadFile = File(...),
period_col: str = Query("period", description="Excel中期间列名"),
value_col: str = Query("actual_value", description="Excel中实际值列名"),
default_period: str = Query(None, description="如文件无期间列,统一使用此值"),
db: Session = Depends(get_db)):
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
@@ -38,8 +50,8 @@ async def import_excel(file: UploadFile = File(...),
raise HTTPException(400, "Excel文件为空,没有数据行")
from app.models import KPIDefinition
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).all()}
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).all()} # 账套隔离 P2
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()}
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()} # 账套隔离
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
count = 0
@@ -198,16 +210,16 @@ async def import_excel_smart(
# 6. 检测报表类型(用于自动生成KPI编码前缀)
stype = _smart_detect_statement_type(fname)
# 7. 预加载KPI字典
# 7. 预加载KPI字典(★ 按当前账套过滤,防跨账套匹配 — 2026-09-08 P0 安全修复)
from app.models import KPIDefinition
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).all()}
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()}
known_codes = set(kpis.keys())
# 构建别名映射(去掉空格/大小写/特殊字符)
alias_map: dict[str, str] = {}
for code in known_codes:
clean = re.sub(r'[\s\-_()()]', '', code).lower()
alias_map[clean] = code
# 中文名映射("营业收入"→F_REVENUE)
# 中文名映射("营业收入"→F_REVENUE)——同账套内 kpi_name 冲突时后者覆盖,保持与原逻辑一致
name_map: dict[str, str] = {}
for code, kpi_obj in kpis.items():
name_map[kpi_obj.kpi_name] = code
@@ -218,6 +230,16 @@ async def import_excel_smart(
imported = 0
created_kpis = 0
skipped_rows = []
code_serial = len(kpis) + 1
def _next_code() -> str:
"""生成当前账套内不冲突的新KPI编码"""
nonlocal code_serial
while True:
candidate = f"{stype_prefix}{code_serial:03d}"
code_serial += 1
if candidate not in known_codes:
return candidate
for idx, row in df.iterrows():
raw_kpi_raw = row.get(kpi_col, "")
@@ -265,9 +287,9 @@ async def import_excel_smart(
kpi_code = code
break
# ⑤ 仍未匹配 → 自动创建KPI
# ⑤ 仍未匹配 → 自动创建KPI(★ 创建在当前账套 entity_id 下)
if not kpi_code:
new_code = f"{stype_prefix}{len(kpis) + created_kpis + 1:03d}"
new_code = _next_code()
new_kpi = KPIDefinition(
entity_id=entity_id,
kpi_code=new_code,
@@ -300,7 +322,7 @@ async def import_excel_smart(
db.add(KPIValue(
kpi_id=kpis[kpi_code].id,
entity_id=kpis[kpi_code].entity_id, # 账套隔离 P2
entity_id=entity_id, # 账套隔离:只写入当前账套
period=raw_period,
actual_value=val,
source_type="excel",
@@ -323,13 +345,14 @@ async def import_excel_smart(
return {"message": msg, "batch": batch, "total": imported, "skipped": len(skipped_rows)}
@router.get("/sources")
def list_sources(db: Session = Depends(get_db)):
sources = db.query(DataSourceConfig).all()
def list_sources(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
sources = db.query(DataSourceConfig).filter(DataSourceConfig.entity_id == entity_id).all()
return {"data": [{c.name: getattr(s, c.name) for c in DataSourceConfig.__table__.columns} for s in sources]}
@router.post("/sources")
def create_source(data: dict, db: Session = Depends(get_db)):
def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = DataSourceConfig(
entity_id=entity_id, # 账套隔离:创建在当前账套下
name=data.get("name", ""),
source_type=data.get("source_type", "manual"),
api_endpoint=data.get("api_endpoint"),
@@ -347,8 +370,11 @@ def create_source(data: dict, db: Session = Depends(get_db)):
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@router.put("/sources/{source_id}")
def update_source(source_id: int, data: dict, db: Session = Depends(get_db)):
source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first()
def update_source(source_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = db.query(DataSourceConfig).filter(
DataSourceConfig.id == source_id,
DataSourceConfig.entity_id == entity_id,
).first()
if not source:
raise HTTPException(404, "数据源不存在")
for key in ["name", "source_type", "api_endpoint", "api_key", "query_sql", "sync_type", "status"]:
@@ -361,8 +387,11 @@ def update_source(source_id: int, data: dict, db: Session = Depends(get_db)):
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@router.delete("/sources/{source_id}")
def delete_source(source_id: int, db: Session = Depends(get_db)):
source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first()
def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = db.query(DataSourceConfig).filter(
DataSourceConfig.id == source_id,
DataSourceConfig.entity_id == entity_id,
).first()
if not source:
raise HTTPException(404, "数据源不存在")
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
@@ -371,47 +400,78 @@ def delete_source(source_id: int, db: Session = Depends(get_db)):
return {"message": "删除成功"}
# ── 数据链路状态(2026-09-08 P0 诚实化:展示真实取数通道,替代误导性 active ERP 数据源) ──
def _zxt_channel_status(entity_id: int, db: Session) -> dict:
"""真实取数通道状态:kpi_values source_type='zxt-sync' 最近注入信息 + 落盘目录"""
latest = db.query(
func.max(KPIValue.period).label("latest_period"),
func.max(KPIValue.calculated_at).label("latest_at"),
func.count(KPIValue.id).label("row_count"),
).filter(
KPIValue.source_type == "zxt-sync",
KPIValue.entity_id == entity_id,
).first()
dir_mtime = None
dir_exists = os.path.isdir(ZXT_LANDING_DIR)
if dir_exists:
try:
newest = max(
(os.path.getmtime(os.path.join(root, f))
for root, _dirs, files in os.walk(ZXT_LANDING_DIR)
for f in files if f.endswith(".csv")),
default=0,
)
if newest:
dir_mtime = datetime.fromtimestamp(newest).strftime("%Y-%m-%d %H:%M")
except Exception:
pass
return {
"channel": "zxt(智享通→落盘→注入)",
"enabled": True,
"latest_period": latest.latest_period if latest else None,
"latest_inject_at": latest.latest_at.strftime("%Y-%m-%d %H:%M:%S") if latest and latest.latest_at else None,
"injected_rows": latest.row_count if latest else 0,
"landing_dir": ZXT_LANDING_DIR,
"landing_dir_exists": dir_exists,
"landing_dir_mtime": dir_mtime,
}
@router.get("/channel-status")
def channel_status(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""数据链路状态:真实取数通道(zxt) + 遗留 ERP 数据源说明(诚实化展示)"""
zxt = _zxt_channel_status(entity_id, db)
# 当前账套的 data_source_config 遗留 ERP 源(有端点指向 erp-gateway 且从未同步成功)
legacy = db.query(DataSourceConfig).filter(
DataSourceConfig.entity_id == entity_id,
DataSourceConfig.source_type == "erp",
).all()
legacy_rows = [{
"id": s.id,
"name": s.name,
"api_endpoint": s.api_endpoint,
"status": s.status,
"last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else None,
} for s in legacy]
return {
"real_channel": zxt,
"erp_direct": {
"enabled": False,
"reason": ERP_DIRECT_CHANNEL_REASON,
"legacy_sources_count": len(legacy_rows),
"legacy_sources": legacy_rows,
},
}
@router.get("/sync-kpis")
def sync_kpis_from_erp(db: Session = Depends(get_db)):
"""从ERP数据源同步KPI值(调用erp_sync模块)"""
from scripts.erp_sync import run_sync
import traceback
from datetime import datetime as dt
def sync_kpis_from_erp(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""[已停用] 从ERP数据源同步KPI值
try:
# 获取所有标记为erp数据源的KPI
erp_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.data_source_type == "erp",
).all()
kpi_count = len(erp_kpis)
kpi_codes = [k.kpi_code for k in erp_kpis]
# 执行同步 (dry_run=False, use_api=False 使用本地fallback)
run_sync(dry_run=False, kpi_codes=kpi_codes, use_api=False)
# 记录操作日志
log = OperationLog(
action="sync_kpis",
target_type="kpi",
detail=f"ERP同步: {kpi_count}个KPI, 编码: {', '.join(kpi_codes[:10])}{'...' if kpi_count > 10 else ''}",
)
db.add(log)
db.commit()
return {
"message": f"ERP数据同步完成",
"total_kpis": kpi_count,
"kpi_codes": kpi_codes,
"synced_at": dt.now().isoformat(),
}
except Exception as e:
log = OperationLog(
action="sync_kpis_error",
target_type="kpi",
detail=f"ERP同步失败: {str(e)[:500]}",
)
db.add(log)
db.commit()
raise HTTPException(500, f"ERP同步失败: {str(e)}")
ERP 直连通道已于 2026-08-31 停用(erp-gateway DB 凭据删除)。
真实财务数据通道 = zxt 智享通注入(source_type='zxt-sync'),勿再调用本端点。
"""
raise HTTPException(status_code=410, detail=ERP_DIRECT_CHANNEL_REASON)
+9 -14
View File
@@ -109,26 +109,21 @@ def startup():
logger.info("管理会计OS后端启动完成")
# ERP 直连同步通道停用(2026-08-31 起 erp-gateway DB 凭据删除;真实财务数据走 zxt 智享通注入)
_ERP_CHANNEL_DISABLED_REASON = ("ERP直连同步通道已于2026-08-31停用(erp-gateway DB 凭据已删除,zxbtest 账号停用)。"
"财务原始数据真实通道为 zxt 智享通注入(source_type='zxt-sync'),本端点不再可用。")
@app.post("/api/cma/admin/erp-sync")
def admin_erp_sync(user=Depends(require_auth), kpi_codes: str = None):
"""手动触发ERP数据同步"""
kpi_list = kpi_codes.split(",") if kpi_codes else None
try:
run_erp_sync(dry_run=False, kpi_codes=kpi_list, use_api=True)
return {"message": "ERP同步完成", "kpis": kpi_list}
except Exception as e:
return JSONResponse(status_code=500, content={"detail": f"同步失败: {str(e)}"})
"""[已停用] 手动触发ERP数据同步 — ERP 直连通道 2026-08-31 停用,返回 410"""
return JSONResponse(status_code=410, content={"detail": _ERP_CHANNEL_DISABLED_REASON})
@app.get("/api/cma/admin/erp-sync/dry-run")
def admin_erp_sync_dry_run(user=Depends(require_auth), kpi_codes: str = None):
"试运行,不写入数据库"""
kpi_list = kpi_codes.split(",") if kpi_codes else None
try:
run_erp_sync(dry_run=True, kpi_codes=kpi_list, use_api=True)
return {"message": "试运行完成"}
except Exception as e:
return JSONResponse(status_code=500, content={"detail": f"试运行失败: {str(e)}"})
"""[已停用] 试运行 — ERP 直连通道 2026-08-31 停用,返回 410"""
return JSONResponse(status_code=410, content={"detail": _ERP_CHANNEL_DISABLED_REASON})
@app.post("/api/cma/admin/alerts/check")
@@ -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"]