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:
Hermes CI Fix
2026-09-14 12:09:04 +08:00
parent 72c1bbce16
commit aac1bd1df1
7 changed files with 147 additions and 102 deletions
+5 -40
View File
@@ -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 一致,避免多版本叠加失真)
+16 -9
View File
@@ -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")
+67 -29
View File
@@ -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:
"""自动报告生成(周报/月报/专项)+ 历史""" """自动报告生成(周报/月报/专项)+ 历史"""
+45 -10
View File
@@ -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"
+5 -4
View File
@@ -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 | 🟡 读接口待抽验 |
+3 -3
View File
@@ -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 }),
} }
+6 -7
View File
@@ -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)