fix(security): retire check-governance + scope /reports/dupont to session entity
- data_quality.py: remove GET /check-governance (no caller repo-wide; its entity_id param had no session auth -> cross-tenant read; duplicate of governance-check) -> the path now returns 404 - reports.py: /reports/dupont resolves the analysis target from the session account (assert_entity_slug_scope / resolve_session_slug); explicit cross-entity entity param -> 403, illegal value -> 400; the 2026-08-31 cross-entity exemption is revoked (docstring updated) - frontend: drop the checkGovernance api definition; DupontAnalysis.vue entity radio group -> read-only session entity tag, request without param - tests: TestCheckGovernanceRetired (404 x2), TestDupontSessionScope (3), TestDupont reworked to account-bound tokens (8 tests) - docs/api-verification-split.md: drop the retired row, note 334/404 -> 333/403 pytest: 10 failed / 957 passed / 1 xfailed vs HEAD baseline 10 failed / 951 passed / 1 xfailed (identical failure set -> zero regression) (cma-residual-cross-entity-cleanup-20260914)
This commit is contained in:
@@ -342,48 +342,13 @@ RULES_META = {
|
|||||||
DETAIL_LIMIT = 10 # 每条规则detail最多列出的条数(避免响应过大)
|
DETAIL_LIMIT = 10 # 每条规则detail最多列出的条数(避免响应过大)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/check-governance")
|
|
||||||
def check_governance(
|
|
||||||
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""DAMA数据治理规则检查(财务七规则)— 返回质量评分+异常清单。
|
|
||||||
|
|
||||||
评分规则: 满分100,error级规则每条扣10分,warning级规则每条扣5分,
|
|
||||||
每条规则最多扣一次分(按规则是否命中,不按count累扣),最低0分。
|
|
||||||
|
|
||||||
收敛说明(2026-08-31 安全修复 P1-2):规则执行统一复用
|
|
||||||
_run_governance_checks(Python 解析 suggestion JSON,SQLite 兼容),
|
|
||||||
删除原 _run_rule_checks(MySQL JSON_EXTRACT/JSON_VALID 版,SQLite 不兼容)。
|
|
||||||
本端点保留原评分口径与响应结构(governance-check 端点使用新的扣分口径)。
|
|
||||||
"""
|
|
||||||
gov = _run_governance_checks(db, entity_id or 0)
|
|
||||||
issues = gov["issues"]
|
|
||||||
|
|
||||||
# 计算评分(check-governance 原口径:error 扣10 / warning 扣5,每规则最多扣一次)
|
|
||||||
score = 100
|
|
||||||
for item in issues:
|
|
||||||
if item["count"] > 0:
|
|
||||||
score -= 10 if item["level"] == "error" else 5
|
|
||||||
score = max(0, score)
|
|
||||||
|
|
||||||
passed = [item["rule"] for item in issues if item["count"] == 0]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"checked_at": datetime.now().isoformat(timespec="seconds"),
|
|
||||||
"entity_id": entity_id or 0,
|
|
||||||
"score": score,
|
|
||||||
"total_rules": len(issues),
|
|
||||||
"issues": issues,
|
|
||||||
"passed": passed,
|
|
||||||
"rules_meta": RULES_META,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 财务七规则检查(governance-check)— 2026-08-30 P1 最终方案
|
# 财务七规则检查(governance-check)— 2026-08-30 P1 最终方案
|
||||||
# 与 /check-governance 的区别:
|
# 端点收敛(cma-residual-cross-entity-cleanup-20260914):
|
||||||
# * 评分规则不同:error 扣 min(15, count*3),warning 扣 min(10, count*1)
|
# /check-governance 已下线 —— 全仓无调用方(前端仅残留 api 定义、无 .vue 调用);
|
||||||
|
# 其 entity_id 参数无会话鉴权(0=全部/1/2 任取)→ 跨租户;与 governance-check 功能重复。
|
||||||
|
# 本端点评分口径:
|
||||||
|
# * 评分规则:error 扣 min(15, count*3),warning 扣 min(10, count*1)
|
||||||
# * 规则2/3 用 Python 解析 suggestion JSON(不依赖 MySQL JSON 函数)
|
# * 规则2/3 用 Python 解析 suggestion JSON(不依赖 MySQL JSON 函数)
|
||||||
# * 规则4 区分 error(test/sync/虚拟) 与 manual(待人工确认 warning)
|
# * 规则4 区分 error(test/sync/虚拟) 与 manual(待人工确认 warning)
|
||||||
# * 规则7 按 status='active' 口径(与 budget.py 一致,避免多版本叠加失真)
|
# * 规则7 按 status='active' 口径(与 budget.py 一致,避免多版本叠加失真)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ CMA管理报表中心 — 管理会计OS
|
|||||||
3. KPI趋势报告 — 选定KPI的历史趋势
|
3. KPI趋势报告 — 选定KPI的历史趋势
|
||||||
4. 四维度绩效评分卡 — BSC健康度雷达图
|
4. 四维度绩效评分卡 — BSC健康度雷达图
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
||||||
import json
|
import json
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -19,7 +19,7 @@ from app.database import get_db
|
|||||||
from app.auth_middleware import require_role, require_auth
|
from app.auth_middleware import require_role, require_auth
|
||||||
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
|
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
|
||||||
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi
|
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger("cma.reports")
|
logger = logging.getLogger("cma.reports")
|
||||||
@@ -1885,15 +1885,22 @@ def _get_dupont_kpi(db: Session, entity_id: int, kpi_code: str, period: str = "2
|
|||||||
|
|
||||||
@router.get("/dupont")
|
@router.get("/dupont")
|
||||||
def get_dupont_analysis(
|
def get_dupont_analysis(
|
||||||
entity: str = Query("bohai"),
|
request: Request,
|
||||||
|
entity: Optional[str] = Query(None, description="hanke/bohai(须与会话账套一致);缺省按会话账套"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""杜邦分析 — ROE三级拆解 (CMA P2)
|
"""杜邦分析 — ROE三级拆解 (CMA P2)
|
||||||
豁免多租户隔离(2026-08-31):跨实体对比分析端点,entity 参数显式指定
|
|
||||||
分析对象(bohai→entity 2 / hanke→entity 1),非默认全库查询,故不叠加
|
多租户口径(cma-residual-cross-entity-cleanup-20260914,取代 2026-08-31 豁免):
|
||||||
Depends(get_entity_id)(叠加会导致 token 绑定的 entity 与显式 entity 参数
|
分析对象 = 会话账套(登录即定企业,页内无跨企业切换)。
|
||||||
不一致时被 403 拦截,破坏跨企业对比功能)"""
|
- 显式 entity 与会话账套不一致 → 403(文案同 get_entity_id);非法值 → 400
|
||||||
if entity == "bohai":
|
- 无会话 token(Bot/存量脚本通道)→ 缺省 bohai(保留原默认,仅非会话通道)
|
||||||
|
旧豁免("跨实体对比分析端点")已撤销:该端点为单对象分析,非并排对比。
|
||||||
|
"""
|
||||||
|
slug = assert_entity_slug_scope(request, entity)
|
||||||
|
if slug is None:
|
||||||
|
slug = resolve_session_slug(request, "bohai")
|
||||||
|
if slug == "bohai":
|
||||||
# 博海标准KPI(F_REVENUE/F_NET_PROFIT)无verified值 → 优先DB读,读不到回退文档确认常量
|
# 博海标准KPI(F_REVENUE/F_NET_PROFIT)无verified值 → 优先DB读,读不到回退文档确认常量
|
||||||
net_profit = _get_dupont_kpi(db, 2, "F_NET_PROFIT")
|
net_profit = _get_dupont_kpi(db, 2, "F_NET_PROFIT")
|
||||||
if net_profit is None:
|
if net_profit is None:
|
||||||
@@ -1963,7 +1970,7 @@ def get_dupont_analysis(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if entity == "hanke":
|
if slug == "hanke":
|
||||||
# 酣客: F_REVENUE/F_NET_PROFIT 从DB读 verified 值(2026H1: 713.27 / -90.86)
|
# 酣客: F_REVENUE/F_NET_PROFIT 从DB读 verified 值(2026H1: 713.27 / -90.86)
|
||||||
revenue = _get_dupont_kpi(db, 1, "F_REVENUE")
|
revenue = _get_dupont_kpi(db, 1, "F_REVENUE")
|
||||||
net_profit = _get_dupont_kpi(db, 1, "F_NET_PROFIT")
|
net_profit = _get_dupont_kpi(db, 1, "F_NET_PROFIT")
|
||||||
|
|||||||
@@ -647,16 +647,37 @@ class TestStatutory:
|
|||||||
|
|
||||||
|
|
||||||
class TestDupont:
|
class TestDupont:
|
||||||
"""杜邦分析"""
|
"""杜邦分析 — 多租户口径(cma-residual-cross-entity-cleanup-20260914)
|
||||||
|
|
||||||
|
分析对象=会话账套:显式 entity 与会话不一致 → 403;非法值 → 400;
|
||||||
|
旧「跨实体对比」豁免已撤销。
|
||||||
|
"""
|
||||||
|
|
||||||
BASE = "/api/cma/reports/dupont"
|
BASE = "/api/cma/reports/dupont"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _login_entity(client: TestClient, db: Session, entity_id: int) -> str:
|
||||||
|
"""按账套登录(1=酣客 / 2=博海):确保企业存在 + 授权 + 取 token"""
|
||||||
|
from app.models import Entity, UserEntity
|
||||||
|
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||||
|
if not ent:
|
||||||
|
db.add(Entity(id=entity_id, name=f"企业{entity_id}", short_name=f"E{entity_id}", status="active"))
|
||||||
|
db.commit()
|
||||||
|
user = create_test_user(db)
|
||||||
|
if not db.query(UserEntity).filter(UserEntity.user_id == user.id, UserEntity.entity_id == entity_id).first():
|
||||||
|
db.add(UserEntity(user_id=user.id, entity_id=entity_id))
|
||||||
|
db.commit()
|
||||||
|
resp = client.post("/api/cma/auth/login", json={
|
||||||
|
"username": "testadmin", "password": "admin123", "entity_id": entity_id,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, f"账套登录失败({entity_id}): {resp.status_code} {resp.text[:200]}"
|
||||||
|
return resp.json()["token"]
|
||||||
|
|
||||||
def test_bohai_fallback(self, client: TestClient, db: Session):
|
def test_bohai_fallback(self, client: TestClient, db: Session):
|
||||||
"""博海无DB数据 → 回退文档常量"""
|
"""博海会话 + 无 entity 参数 → 会话账套=博海;无DB数据回退文档常量"""
|
||||||
create_test_user(db)
|
token = self._login_entity(client, db, 2)
|
||||||
token = get_token_for_user(client)
|
resp = client.get(self.BASE, headers=auth_header(token))
|
||||||
resp = client.get(f"{self.BASE}?entity=bohai", headers=auth_header(token))
|
assert resp.status_code == 200, resp.text[:300]
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["entity_name"] == "陕西博海科技(IT服务)"
|
assert data["entity_name"] == "陕西博海科技(IT服务)"
|
||||||
assert data["roe"] > 0
|
assert data["roe"] > 0
|
||||||
@@ -665,52 +686,69 @@ class TestDupont:
|
|||||||
|
|
||||||
def test_bohai_with_db_data(self, client: TestClient, db: Session):
|
def test_bohai_with_db_data(self, client: TestClient, db: Session):
|
||||||
"""博海(entity 2) DB有verified KPI → 用DB值"""
|
"""博海(entity 2) DB有verified KPI → 用DB值"""
|
||||||
create_test_user(db)
|
token = self._login_entity(client, db, 2)
|
||||||
token = get_token_for_user(client)
|
|
||||||
k1 = mk_kpi(db, "F_NET_PROFIT", entity_id=2, target=0)
|
k1 = mk_kpi(db, "F_NET_PROFIT", entity_id=2, target=0)
|
||||||
mk_val(db, k1, "2026-H1", 20.0)
|
mk_val(db, k1, "2026-H1", 20.0)
|
||||||
k2 = mk_kpi(db, "F_REVENUE", entity_id=2, target=0)
|
k2 = mk_kpi(db, "F_REVENUE", entity_id=2, target=0)
|
||||||
mk_val(db, k2, "2026-H1", 400.0)
|
mk_val(db, k2, "2026-H1", 400.0)
|
||||||
resp = client.get(f"{self.BASE}?entity=bohai", headers=auth_header(token))
|
resp = client.get(self.BASE, headers=auth_header(token))
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200, resp.text[:300]
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["raw_data"]["net_profit"] == 20.0
|
assert data["raw_data"]["net_profit"] == 20.0
|
||||||
assert data["raw_data"]["revenue"] == 400.0
|
assert data["raw_data"]["revenue"] == 400.0
|
||||||
assert data["roe"] > 0
|
assert data["roe"] > 0
|
||||||
|
|
||||||
def test_hanke_no_data(self, client: TestClient, db: Session):
|
def test_hanke_session_default_is_hanke(self, client: TestClient, db: Session):
|
||||||
"""酣客无数据 → roe None,不报错"""
|
"""酣客会话 + 无 entity 参数 → 返回酣客(不再默认博海);酣客无数据 → roe None"""
|
||||||
create_test_user(db)
|
token = self._login_entity(client, db, 1)
|
||||||
token = get_token_for_user(client)
|
resp = client.get(self.BASE, headers=auth_header(token))
|
||||||
resp = client.get(f"{self.BASE}?entity=hanke", headers=auth_header(token))
|
assert resp.status_code == 200, resp.text[:300]
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
assert data["entity"] == "hanke"
|
||||||
assert data["entity_name"] == "陕西酣客文化传媒(白酒经销)"
|
assert data["entity_name"] == "陕西酣客文化传媒(白酒经销)"
|
||||||
assert data["roe"] is None
|
assert data["roe"] is None
|
||||||
assert data["factors"]["net_profit_margin"]["value"] is None
|
assert data["factors"]["net_profit_margin"]["value"] is None
|
||||||
|
|
||||||
|
def test_hanke_cross_entity_bohai_403(self, client: TestClient, db: Session):
|
||||||
|
"""酣客会话显式请求 entity=bohai(博海真实KPI)→ 403(本单核心验收点)"""
|
||||||
|
token = self._login_entity(client, db, 1)
|
||||||
|
resp = client.get(f"{self.BASE}?entity=bohai", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 403, f"跨实体应 403,实际 {resp.status_code}: {resp.text[:200]}"
|
||||||
|
assert "无权访问企业 entity_id=2(当前账套: 1)" in resp.text
|
||||||
|
|
||||||
|
def test_bohai_cross_entity_hanke_403(self, client: TestClient, db: Session):
|
||||||
|
"""博海会话显式请求 entity=hanke → 403"""
|
||||||
|
token = self._login_entity(client, db, 2)
|
||||||
|
resp = client.get(f"{self.BASE}?entity=hanke", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 403, f"跨实体应 403,实际 {resp.status_code}: {resp.text[:200]}"
|
||||||
|
|
||||||
|
def test_same_entity_explicit_200(self, client: TestClient, db: Session):
|
||||||
|
"""显式 entity 与会话账套一致 → 200"""
|
||||||
|
token = self._login_entity(client, db, 1)
|
||||||
|
resp = client.get(f"{self.BASE}?entity=hanke", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200, resp.text[:200]
|
||||||
|
assert resp.json()["entity"] == "hanke"
|
||||||
|
|
||||||
|
def test_illegal_entity_400(self, client: TestClient, db: Session):
|
||||||
|
"""非法实体值 → 400 显式报错(不再静默回落)"""
|
||||||
|
token = self._login_entity(client, db, 1)
|
||||||
|
resp = client.get(f"{self.BASE}?entity=xxx", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 400, f"非法实体应 400,实际 {resp.status_code}: {resp.text[:200]}"
|
||||||
|
assert "非法实体" in resp.text
|
||||||
|
|
||||||
def test_hanke_with_loss_data(self, client: TestClient, db: Session):
|
def test_hanke_with_loss_data(self, client: TestClient, db: Session):
|
||||||
"""酣客亏损数据 → 负ROE"""
|
"""酣客会话亏损数据 → 负ROE"""
|
||||||
create_test_user(db)
|
token = self._login_entity(client, db, 1)
|
||||||
token = get_token_for_user(client)
|
|
||||||
k1 = mk_kpi(db, "F_REVENUE", entity_id=1, target=0)
|
k1 = mk_kpi(db, "F_REVENUE", entity_id=1, target=0)
|
||||||
mk_val(db, k1, "2026-H1", 100.0)
|
mk_val(db, k1, "2026-H1", 100.0)
|
||||||
k2 = mk_kpi(db, "F_NET_PROFIT", entity_id=1, target=0)
|
k2 = mk_kpi(db, "F_NET_PROFIT", entity_id=1, target=0)
|
||||||
mk_val(db, k2, "2026-H1", -10.0)
|
mk_val(db, k2, "2026-H1", -10.0)
|
||||||
resp = client.get(f"{self.BASE}?entity=hanke", headers=auth_header(token))
|
resp = client.get(self.BASE, headers=auth_header(token))
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200, resp.text[:300]
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["roe"] == -10.0
|
assert data["roe"] == -10.0
|
||||||
assert data["factors"]["net_profit_margin"]["status"] == "🔴"
|
assert data["factors"]["net_profit_margin"]["status"] == "🔴"
|
||||||
|
|
||||||
def test_unknown_entity(self, client: TestClient, db: Session):
|
|
||||||
"""未知实体 → error"""
|
|
||||||
create_test_user(db)
|
|
||||||
token = get_token_for_user(client)
|
|
||||||
resp = client.get(f"{self.BASE}?entity=xxx", headers=auth_header(token))
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert "error" in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateReports:
|
class TestGenerateReports:
|
||||||
"""自动报告生成(周报/月报/专项)+ 历史"""
|
"""自动报告生成(周报/月报/专项)+ 历史"""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- alert_rules create 写入 entity_id(跨 entity 不可见)
|
- alert_rules create 写入 entity_id(跨 entity 不可见)
|
||||||
- reports 跨 entity 过滤(profit-summary / kpi-trends)
|
- reports 跨 entity 过滤(profit-summary / kpi-trends)
|
||||||
- _eval_threshold invert 参数(低于阈值触发红灯)
|
- _eval_threshold invert 参数(低于阈值触发红灯)
|
||||||
- /check-governance 在 SQLite 测试库不 500(data_quality 收敛后复用 Python 解析)
|
- /check-governance 下线(cma-residual-cross-entity-cleanup-20260914)→ 404;governance-check 保留
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -204,24 +204,24 @@ class TestInvertThreshold:
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# /check-governance SQLite 兼容(P1-2)
|
# /check-governance 下线(cma-residual-cross-entity-cleanup-20260914)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
class TestCheckGovernanceSQLite:
|
class TestCheckGovernanceRetired:
|
||||||
def test_check_governance_no_500_on_sqlite(self, client: TestClient, db: Session):
|
def test_check_governance_endpoint_removed(self, client: TestClient, db: Session):
|
||||||
"""data_quality 收敛后 /check-governance 在 SQLite 测试库不 500"""
|
"""/check-governance 已下线 → 404(该端点 entity_id 参数无会话鉴权,跨租户)"""
|
||||||
_seed_entity_kpi(db, entity_id=1, code="GOV_KPI")
|
_seed_entity_kpi(db, entity_id=1, code="GOV_KPI")
|
||||||
db.commit()
|
db.commit()
|
||||||
create_test_user(db)
|
create_test_user(db)
|
||||||
token = get_token_for_user(client, "testadmin", "admin123")
|
token = get_token_for_user(client, "testadmin", "admin123")
|
||||||
|
|
||||||
resp = client.get("/api/cma/data-quality/check-governance", headers=auth_header(token))
|
resp = client.get("/api/cma/data-quality/check-governance", headers=auth_header(token))
|
||||||
assert resp.status_code == 200, f"check-governance 500: {resp.text[:300]}"
|
assert resp.status_code == 404, f"应 404,实际 {resp.status_code}: {resp.text[:200]}"
|
||||||
data = resp.json()
|
# 跨租户取数路径随之消失(同参数不再有响应体)
|
||||||
assert "score" in data
|
resp2 = client.get("/api/cma/data-quality/check-governance?entity_id=2", headers=auth_header(token))
|
||||||
assert data["total_rules"] == 7
|
assert resp2.status_code == 404, resp2.text[:200]
|
||||||
|
|
||||||
def test_governance_check_still_works(self, client: TestClient, db: Session):
|
def test_governance_check_still_works(self, client: TestClient, db: Session):
|
||||||
"""governance-check 端点(新口径)在收敛后仍正常"""
|
"""governance-check 端点(唯一入口)在收敛后仍正常"""
|
||||||
_seed_entity_kpi(db, entity_id=1, code="GOV2_KPI")
|
_seed_entity_kpi(db, entity_id=1, code="GOV2_KPI")
|
||||||
db.commit()
|
db.commit()
|
||||||
create_test_user(db)
|
create_test_user(db)
|
||||||
@@ -340,3 +340,38 @@ class TestCrossEntityEndpointAuth:
|
|||||||
assert r1.status_code == 403, r1.text[:200]
|
assert r1.status_code == 403, r1.text[:200]
|
||||||
r2 = client.get("/api/cma/predict/accuracy?entity_id=2", headers=auth_header(token))
|
r2 = client.get("/api/cma/predict/accuracy?entity_id=2", headers=auth_header(token))
|
||||||
assert r2.status_code == 403, r2.text[:200]
|
assert r2.status_code == 403, r2.text[:200]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# reports/dupont 口径收敛(cma-residual-cross-entity-cleanup-20260914)
|
||||||
|
# 旧「跨实体对比」豁免(2026-08-31)撤销 → 分析对象=会话账套
|
||||||
|
# ============================================================
|
||||||
|
class TestDupontSessionScope:
|
||||||
|
BASE = "/api/cma/reports/dupont"
|
||||||
|
|
||||||
|
def _login(self, client, db):
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
create_test_user(db)
|
||||||
|
return get_token_for_user(client, "testadmin", "admin123") # 登录账套 entity_id=1
|
||||||
|
|
||||||
|
def test_dupont_cross_entity_bohai_403(self, client, db):
|
||||||
|
"""酣客会话显式 entity=bohai(博海真实KPI)→ 403(旧豁免已撤销)"""
|
||||||
|
token = self._login(client, db)
|
||||||
|
r = client.get(f"{self.BASE}?entity=bohai", headers=auth_header(token))
|
||||||
|
assert r.status_code == 403, f"跨实体应 403,实际 {r.status_code}: {r.text[:200]}"
|
||||||
|
assert "无权访问企业 entity_id=2(当前账套: 1)" in r.text
|
||||||
|
|
||||||
|
def test_dupont_no_param_is_session_entity(self, client, db):
|
||||||
|
"""无 entity 参数 → 按会话账套(酣客),不再默认博海"""
|
||||||
|
token = self._login(client, db)
|
||||||
|
r = client.get(self.BASE, headers=auth_header(token))
|
||||||
|
assert r.status_code == 200, r.text[:200]
|
||||||
|
assert r.json()["entity"] == "hanke"
|
||||||
|
assert "酣客" in r.json()["entity_name"]
|
||||||
|
|
||||||
|
def test_dupont_same_entity_200(self, client, db):
|
||||||
|
"""同实体显式参数 → 200"""
|
||||||
|
token = self._login(client, db)
|
||||||
|
r = client.get(f"{self.BASE}?entity=hanke", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200, r.text[:200]
|
||||||
|
assert r.json()["entity"] == "hanke"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# CMA API验证分工表(2026-08-31)
|
# CMA API验证分工表(2026-08-31)
|
||||||
|
|
||||||
> 生成:项目Bot | 数据源:后端 /openapi.json 实测 | 统计口径:334路径/404方法操作(openapi实测,非任务书398估算)
|
> 生成:项目Bot | 数据源:后端 /openapi.json 实测 | 统计口径:333路径/403方法操作(openapi实测,非任务书398估算)
|
||||||
|
>
|
||||||
|
> 2026-09-14 更新(cma-residual-cross-entity-cleanup-20260914):`GET /api/cma/data-quality/check-governance` 已下线(无调用方 + entity_id 参数无会话鉴权跨租户,与 governance-check 重复)→ 404 操作数 404→403、路径 334→333。
|
||||||
|
|
||||||
## 分类标准
|
## 分类标准
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
- ③数据找人: 82 个操作
|
- ③数据找人: 82 个操作
|
||||||
- ④决策闭环: 95 个操作
|
- ④决策闭环: 95 个操作
|
||||||
|
|
||||||
## 完整清单(404操作)
|
## 完整清单(403操作)
|
||||||
|
|
||||||
| 方法 | API路径 | 所属层 | 验证人 | 当前状态 |
|
| 方法 | API路径 | 所属层 | 验证人 | 当前状态 |
|
||||||
|:--|:--|:--|:--|:--|
|
|:--|:--|:--|:--|:--|
|
||||||
@@ -223,7 +225,6 @@
|
|||||||
| GET | `/api/cma/data-classification/stats` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
| GET | `/api/cma/data-classification/stats` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
||||||
| PUT | `/api/cma/data-classification/subjects/{subject_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
| PUT | `/api/cma/data-classification/subjects/{subject_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
| GET | `/api/cma/data-quality/check` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
| GET | `/api/cma/data-quality/check` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
||||||
| GET | `/api/cma/data-quality/check-governance` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
|
||||||
| GET | `/api/cma/data-quality/governance-check` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
| GET | `/api/cma/data-quality/governance-check` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
||||||
| GET | `/api/cma/data-quality/logs` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
| GET | `/api/cma/data-quality/logs` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
| DELETE | `/api/cma/data-quality/logs/{log_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
| DELETE | `/api/cma/data-quality/logs/{log_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
@@ -370,7 +371,7 @@
|
|||||||
| GET | `/api/cma/reports/budget-execution` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
| GET | `/api/cma/reports/budget-execution` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
| GET | `/api/cma/reports/cash-flow` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
| GET | `/api/cma/reports/cash-flow` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
| GET | `/api/cma/reports/category-map` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
| GET | `/api/cma/reports/category-map` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
| GET | `/api/cma/reports/dupont` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
| GET | `/api/cma/reports/dupont` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验(分析对象=会话账套;跨实体 403) |
|
||||||
| POST | `/api/cma/reports/generate` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
| POST | `/api/cma/reports/generate` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
| GET | `/api/cma/reports/history` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
| GET | `/api/cma/reports/history` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
| GET | `/api/cma/reports/history/{report_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
| GET | `/api/cma/reports/history/{report_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
|||||||
@@ -362,9 +362,9 @@ export const dataQualityApi = {
|
|||||||
logs: (params?: any) => api.get('/data-quality/logs', { params }),
|
logs: (params?: any) => api.get('/data-quality/logs', { params }),
|
||||||
updateLog: (id: number, data: any) => api.put(`/data-quality/logs/${id}`, data),
|
updateLog: (id: number, data: any) => api.put(`/data-quality/logs/${id}`, data),
|
||||||
deleteLog: (id: number) => api.delete(`/data-quality/logs/${id}`),
|
deleteLog: (id: number) => api.delete(`/data-quality/logs/${id}`),
|
||||||
// DAMA数据治理规则检查(财务七规则)
|
// DAMA数据治理规则检查(财务七规则)— /check-governance 已于 2026-09-14 下线
|
||||||
checkGovernance: (params?: any) => api.get('/data-quality/check-governance', { params }),
|
// (cma-residual-cross-entity-cleanup-20260914:无调用方 + entity_id 参数无会话鉴权跨租户)
|
||||||
// 财务七规则检查(最终方案:评分+7规则+实体筛选,只读幂等)
|
// 财务七规则检查(唯一入口:评分+7规则+实体筛选,只读幂等;实体按会话账套)
|
||||||
governanceCheck: (params?: any) => api.get('/data-quality/governance-check', { params }),
|
governanceCheck: (params?: any) => api.get('/data-quality/governance-check', { params }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,10 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h3 class="page-title">杜邦分析</h3>
|
<h3 class="page-title">杜邦分析</h3>
|
||||||
</div>
|
</div>
|
||||||
<!-- 顶部控制 -->
|
<!-- 顶部控制:企业只读标识(不可点)——账套由登录时选定,页内无切换入口 -->
|
||||||
<div class="flex-row" style="gap:12px;margin-bottom:16px;align-items:center;">
|
<div class="flex-row" style="gap:12px;margin-bottom:16px;align-items:center;">
|
||||||
<span style="font-weight:600;font-size:14px;">分析对象:</span>
|
<span style="font-weight:600;font-size:14px;">分析对象:</span>
|
||||||
<el-radio-group v-model="entity" @change="fetchDupont">
|
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
|
||||||
<el-radio value="bohai">陕西博海科技(IT服务)</el-radio>
|
|
||||||
<el-radio value="hanke">陕西酣客(白酒经销)</el-radio>
|
|
||||||
</el-radio-group>
|
|
||||||
<el-button type="primary" size="small" @click="fetchDupont" :loading="loading">刷新</el-button>
|
<el-button type="primary" size="small" @click="fetchDupont" :loading="loading">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,9 +113,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { sessionEntityName } from '../utils/entity'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const entity = ref('bohai')
|
const entityLabel = ref(sessionEntityName())
|
||||||
const data = ref<any>({})
|
const data = ref<any>({})
|
||||||
|
|
||||||
const api = axios.create({ baseURL: '/api/cma', timeout: 15000 })
|
const api = axios.create({ baseURL: '/api/cma', timeout: 15000 })
|
||||||
@@ -151,7 +149,8 @@ function formatMoney(val: number): string {
|
|||||||
async function fetchDupont() {
|
async function fetchDupont() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const r: any = await api.get('/reports/dupont', { params: { entity: entity.value } })
|
// 分析对象由会话账套决定(后端按 token 解析),不再传 entity 参数
|
||||||
|
const r: any = await api.get('/reports/dupont')
|
||||||
data.value = r.data || r
|
data.value = r.data || r
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('杜邦分析加载失败:', e)
|
console.error('杜邦分析加载失败:', e)
|
||||||
|
|||||||
Reference in New Issue
Block a user