Compare commits
2
Commits
cb318115e8
...
a64f184525
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a64f184525 | ||
|
|
6af4366a84 |
@@ -1 +0,0 @@
|
|||||||
worktree测试文件
|
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""数据分类分级 — 重要数据标记(2026-08-26 政策驱动)
|
||||||
|
|
||||||
|
背景:《网络数据安全风险评估办法》(2026-08-20 三部门施行)
|
||||||
|
- 重要数据处理者每年一次强制评估
|
||||||
|
- 评估前提 = 先分类分级("不知道重要数据在哪,评估无从谈起")
|
||||||
|
- 本模块 = "分类分级工具"第一版(标记+清单),不做评估算法
|
||||||
|
|
||||||
|
数据级别(参考 GB/T 43697 简化):
|
||||||
|
- core 核心数据(一旦遭篡改/破坏/泄露,直接危害国家安全/经济运行)
|
||||||
|
- important 重要数据(一旦遭篡改/破坏/泄露,危害公共利益/企业关键业务)
|
||||||
|
- general 一般数据(其余)
|
||||||
|
|
||||||
|
覆盖对象:
|
||||||
|
- subjects 会计科目(全局,无 entity_id,与现有科目管理一致)
|
||||||
|
- kpi_definitions KPI字典(按 entity_id 多租户隔离)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
|
from app.auth_middleware import require_role, require_auth
|
||||||
|
from app.models import Subject, KPIDefinition, Entity
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/cma/data-classification", tags=["数据分类分级"],
|
||||||
|
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 写操作只允许 ceo/finance/it(与 KPI 字典一致)
|
||||||
|
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||||
|
|
||||||
|
VALID_LEVELS = {"core", "important", "general"}
|
||||||
|
|
||||||
|
LEVEL_LABELS = {
|
||||||
|
"core": "核心数据",
|
||||||
|
"important": "重要数据",
|
||||||
|
"general": "一般数据",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 行业参考目录(内置提示,可按行业自定义扩展)
|
||||||
|
INDUSTRY_REFERENCE = [
|
||||||
|
{"industry": "通用/企业服务", "category": "客户数据", "suggest_level": "important",
|
||||||
|
"desc": "客户名单、合同、订单、应收账款等客户经营数据"},
|
||||||
|
{"industry": "通用/企业服务", "category": "财务数据", "suggest_level": "important",
|
||||||
|
"desc": "财务报表、银行账户、税务申报、薪酬数据"},
|
||||||
|
{"industry": "通用/企业服务", "category": "员工数据", "suggest_level": "important",
|
||||||
|
"desc": "员工身份、薪酬、社保、考勤、健康信息"},
|
||||||
|
{"industry": "金融", "category": "账户交易", "suggest_level": "core",
|
||||||
|
"desc": "客户账户、交易流水、信贷记录(金融行业核心数据)"},
|
||||||
|
{"industry": "医疗", "category": "健康医疗", "suggest_level": "core",
|
||||||
|
"desc": "病历、健康档案、基因数据(医疗行业核心数据)"},
|
||||||
|
{"industry": "工业", "category": "关键基础设施", "suggest_level": "important",
|
||||||
|
"desc": "生产控制、工艺参数、供应链关键环节数据"},
|
||||||
|
{"industry": "互联网", "category": "用户个人信息", "suggest_level": "important",
|
||||||
|
"desc": "个人信息、日志、位置数据(万人以上规模需重点评估)"},
|
||||||
|
{"industry": "能源", "category": "能源数据", "suggest_level": "important",
|
||||||
|
"desc": "能源生产、传输、消费监测数据"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_to_item(s: Subject) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "subject",
|
||||||
|
"id": s.id,
|
||||||
|
"code": s.subject_code,
|
||||||
|
"name": s.subject_name,
|
||||||
|
"dimension": None,
|
||||||
|
"category": s.new_standard_category or s.category or "",
|
||||||
|
"important_flag": 1 if s.important_flag else 0,
|
||||||
|
"data_level": s.data_level or "general",
|
||||||
|
"data_category": s.data_category or "",
|
||||||
|
"data_owner": None,
|
||||||
|
"storage": "会计科目台账",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi_to_item(k: KPIDefinition) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "kpi",
|
||||||
|
"id": k.id,
|
||||||
|
"code": k.kpi_code,
|
||||||
|
"name": k.kpi_name,
|
||||||
|
"dimension": k.dimension,
|
||||||
|
"category": k.category or "",
|
||||||
|
"important_flag": 1 if k.important_flag else 0,
|
||||||
|
"data_level": k.data_level or "general",
|
||||||
|
"data_category": k.data_category or "",
|
||||||
|
"data_owner": k.data_owner,
|
||||||
|
"storage": f"KPI台账(entity_id={k.entity_id})",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_items(
|
||||||
|
data_type: Optional[str],
|
||||||
|
data_level: Optional[str],
|
||||||
|
important: Optional[int],
|
||||||
|
keyword: Optional[str],
|
||||||
|
entity_id: int,
|
||||||
|
db: Session,
|
||||||
|
) -> list:
|
||||||
|
"""内部清单查询(供 inventory/export 复用,避免依赖注入问题)"""
|
||||||
|
items: list[dict] = []
|
||||||
|
|
||||||
|
if not data_type or data_type == "subject":
|
||||||
|
q = db.query(Subject).filter(Subject.is_active == 1)
|
||||||
|
if data_level:
|
||||||
|
q = q.filter(Subject.data_level == data_level)
|
||||||
|
if important == 1:
|
||||||
|
q = q.filter(Subject.important_flag == 1)
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
q = q.filter(
|
||||||
|
Subject.subject_name.like(like) | Subject.subject_code.like(like)
|
||||||
|
)
|
||||||
|
for s in q.order_by(Subject.subject_code).all():
|
||||||
|
items.append(_subject_to_item(s))
|
||||||
|
|
||||||
|
if not data_type or data_type == "kpi":
|
||||||
|
q = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
)
|
||||||
|
if data_level:
|
||||||
|
q = q.filter(KPIDefinition.data_level == data_level)
|
||||||
|
if important == 1:
|
||||||
|
q = q.filter(KPIDefinition.important_flag == 1)
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
q = q.filter(
|
||||||
|
KPIDefinition.kpi_name.like(like) | KPIDefinition.kpi_code.like(like)
|
||||||
|
)
|
||||||
|
for k in q.order_by(KPIDefinition.kpi_code).all():
|
||||||
|
items.append(_kpi_to_item(k))
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_of(items: list) -> dict:
|
||||||
|
return {
|
||||||
|
"total": len(items),
|
||||||
|
"marked": sum(1 for i in items if i["important_flag"] == 1),
|
||||||
|
"by_level": {
|
||||||
|
"core": sum(1 for i in items if i["data_level"] == "core"),
|
||||||
|
"important": sum(1 for i in items if i["data_level"] == "important"),
|
||||||
|
"general": sum(1 for i in items if i["data_level"] == "general"),
|
||||||
|
},
|
||||||
|
"by_type": {
|
||||||
|
"subject": sum(1 for i in items if i["type"] == "subject"),
|
||||||
|
"kpi": sum(1 for i in items if i["type"] == "kpi"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory")
|
||||||
|
def get_inventory(
|
||||||
|
data_type: Optional[str] = Query(None, description="subject/kpi,缺省返回全部"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||||
|
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||||
|
keyword: Optional[str] = Query(None, description="编码/名称搜索"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""数据资产清单(科目 + KPI 合并输出,含重要级别)"""
|
||||||
|
if data_level and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||||
|
|
||||||
|
items = _query_items(data_type, data_level, important, keyword, entity_id, db)
|
||||||
|
return {"total": len(items), "items": items, "stats": _stats_of(items)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_stats(
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""数据分类分级统计概览(评估前准备看板)"""
|
||||||
|
subject_total = db.query(Subject).filter(Subject.is_active == 1).count()
|
||||||
|
subject_marked = db.query(Subject).filter(
|
||||||
|
Subject.is_active == 1, Subject.important_flag == 1
|
||||||
|
).count()
|
||||||
|
kpi_total = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||||
|
).count()
|
||||||
|
kpi_marked = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.important_flag == 1,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"entity_name": ent.name if ent else "",
|
||||||
|
"subjects": {"total": subject_total, "marked": subject_marked},
|
||||||
|
"kpis": {"total": kpi_total, "marked": kpi_marked},
|
||||||
|
"total": subject_total + kpi_total,
|
||||||
|
"marked_total": subject_marked + kpi_marked,
|
||||||
|
"coverage_pct": round((subject_marked + kpi_marked) / (subject_total + kpi_total) * 100, 1)
|
||||||
|
if (subject_total + kpi_total) else 0,
|
||||||
|
"policy_note": "《网络数据安全风险评估办法》(2026-08-20施行):重要数据处理者每年一次强制评估,评估前提=先完成数据分类分级",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/subjects/{subject_id}")
|
||||||
|
def mark_subject(
|
||||||
|
subject_id: int,
|
||||||
|
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||||
|
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""标记单个科目:重要数据标记 + 数据分级 + 行业分类"""
|
||||||
|
subject = db.query(Subject).filter(Subject.id == subject_id).first()
|
||||||
|
if not subject:
|
||||||
|
raise HTTPException(404, "科目不存在")
|
||||||
|
|
||||||
|
if data_level is not None and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||||
|
|
||||||
|
if important_flag is not None:
|
||||||
|
subject.important_flag = 1 if important_flag else 0
|
||||||
|
if data_level is not None:
|
||||||
|
subject.data_level = data_level
|
||||||
|
if data_level in ("core", "important"):
|
||||||
|
subject.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||||
|
if data_category is not None:
|
||||||
|
subject.data_category = data_category or None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "更新成功",
|
||||||
|
"subject_id": subject_id,
|
||||||
|
"important_flag": subject.important_flag,
|
||||||
|
"data_level": subject.data_level,
|
||||||
|
"data_category": subject.data_category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/kpis/{kpi_id}")
|
||||||
|
def mark_kpi(
|
||||||
|
kpi_id: int,
|
||||||
|
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||||
|
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""标记单个KPI:重要数据标记 + 数据分级 + 行业分类(按账套隔离)"""
|
||||||
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.id == kpi_id,
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI不存在或不属于当前账套")
|
||||||
|
|
||||||
|
if data_level is not None and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||||
|
|
||||||
|
if important_flag is not None:
|
||||||
|
kpi.important_flag = 1 if important_flag else 0
|
||||||
|
if data_level is not None:
|
||||||
|
kpi.data_level = data_level
|
||||||
|
if data_level in ("core", "important"):
|
||||||
|
kpi.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||||
|
if data_category is not None:
|
||||||
|
kpi.data_category = data_category or None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "更新成功",
|
||||||
|
"kpi_id": kpi_id,
|
||||||
|
"important_flag": kpi.important_flag,
|
||||||
|
"data_level": kpi.data_level,
|
||||||
|
"data_category": kpi.data_category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/batch")
|
||||||
|
def batch_mark(
|
||||||
|
data_type: str = Query(..., description="subject/kpi"),
|
||||||
|
ids: List[int] = Query(..., description="ID列表"),
|
||||||
|
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||||
|
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""批量标记(科目全局 / KPI按账套隔离)"""
|
||||||
|
if data_type not in ("subject", "kpi"):
|
||||||
|
raise HTTPException(400, f"无效的数据类型: {data_type},可选 subject/kpi")
|
||||||
|
if data_level is not None and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||||
|
if not ids:
|
||||||
|
raise HTTPException(400, "ids 不能为空")
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
if data_type == "subject":
|
||||||
|
q = db.query(Subject).filter(Subject.id.in_(ids), Subject.is_active == 1)
|
||||||
|
targets = q.all()
|
||||||
|
for s in targets:
|
||||||
|
if important_flag is not None:
|
||||||
|
s.important_flag = 1 if important_flag else 0
|
||||||
|
if data_level is not None:
|
||||||
|
s.data_level = data_level
|
||||||
|
if data_level in ("core", "important"):
|
||||||
|
s.important_flag = 1
|
||||||
|
if data_category is not None:
|
||||||
|
s.data_category = data_category or None
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
q = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.id.in_(ids),
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
)
|
||||||
|
targets = q.all()
|
||||||
|
for k in targets:
|
||||||
|
if important_flag is not None:
|
||||||
|
k.important_flag = 1 if important_flag else 0
|
||||||
|
if data_level is not None:
|
||||||
|
k.data_level = data_level
|
||||||
|
if data_level in ("core", "important"):
|
||||||
|
k.important_flag = 1
|
||||||
|
if data_category is not None:
|
||||||
|
k.data_category = data_category or None
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"批量更新成功", "data_type": data_type, "updated_count": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export_csv(
|
||||||
|
data_type: Optional[str] = Query(None, description="subject/kpi,缺省全部"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||||
|
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""导出数据分类分级清单(评估前准备材料)CSV"""
|
||||||
|
if data_level and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level}")
|
||||||
|
|
||||||
|
items = _query_items(data_type, data_level, important, None, entity_id, db)
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(["类型", "编码", "名称", "维度", "分类", "重要标记", "数据级别", "行业参考分类", "数据责任人", "存储位置"])
|
||||||
|
for i in items:
|
||||||
|
writer.writerow([
|
||||||
|
"科目" if i["type"] == "subject" else "KPI",
|
||||||
|
i["code"],
|
||||||
|
i["name"],
|
||||||
|
i["dimension"] or "",
|
||||||
|
i["category"] or "",
|
||||||
|
"是" if i["important_flag"] else "否",
|
||||||
|
LEVEL_LABELS.get(i["data_level"], i["data_level"]),
|
||||||
|
i["data_category"] or "",
|
||||||
|
i["data_owner"] or "",
|
||||||
|
i["storage"] or "",
|
||||||
|
])
|
||||||
|
|
||||||
|
content = "\ufeff" + buf.getvalue() # BOM 兼容 Excel
|
||||||
|
filename = f"data-classification-inventory_{datetime.now().strftime('%Y%m%d')}.csv"
|
||||||
|
from starlette.responses import Response
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/industry-reference")
|
||||||
|
def industry_reference():
|
||||||
|
"""行业参考目录(内置提示,按行业给出重点数据分类建议)"""
|
||||||
|
return {"items": INDUSTRY_REFERENCE}
|
||||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products
|
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification
|
||||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||||
from scripts.erp_sync import run_sync as run_erp_sync
|
from scripts.erp_sync import run_sync as run_erp_sync
|
||||||
from app.auth_middleware import require_auth
|
from app.auth_middleware import require_auth
|
||||||
@@ -80,6 +80,7 @@ app.include_router(bot_iron_law.router)
|
|||||||
app.include_router(analysis_results.router)
|
app.include_router(analysis_results.router)
|
||||||
app.include_router(expenses.router)
|
app.include_router(expenses.router)
|
||||||
app.include_router(cash.router)
|
app.include_router(cash.router)
|
||||||
|
app.include_router(data_classification.router)
|
||||||
app.include_router(tax_compliance.router)
|
app.include_router(tax_compliance.router)
|
||||||
app.include_router(verify.router)
|
app.include_router(verify.router)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ class KPIDefinition(Base):
|
|||||||
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
|
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
|
||||||
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
|
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
|
||||||
category = Column(String(50), nullable=True, comment="BSC二级类别: revenue_growth/profitability/cost_control/asset_efficiency/cash_risk/customer_scale/customer_concentration/customer_satisfaction/supply_chain/delivery_quality/talent_pipeline/employee_engagement/innovation")
|
category = Column(String(50), nullable=True, comment="BSC二级类别: revenue_growth/profitability/cost_control/asset_efficiency/cash_risk/customer_scale/customer_concentration/customer_satisfaction/supply_chain/delivery_quality/talent_pipeline/employee_engagement/innovation")
|
||||||
|
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
|
||||||
|
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
|
||||||
|
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
|
||||||
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
||||||
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
||||||
kpi_level = Column(String(20), default="operational", comment="strategic/operational")
|
kpi_level = Column(String(20), default="operational", comment="strategic/operational")
|
||||||
@@ -574,6 +577,9 @@ class Subject(Base):
|
|||||||
level = Column(Integer, default=1, comment="科目级别 1-4")
|
level = Column(Integer, default=1, comment="科目级别 1-4")
|
||||||
category = Column(String(50), nullable=True, comment="科目类别")
|
category = Column(String(50), nullable=True, comment="科目类别")
|
||||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类: operating/investing/financing/tax/discontinued")
|
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类: operating/investing/financing/tax/discontinued")
|
||||||
|
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
|
||||||
|
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
|
||||||
|
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
|
||||||
is_active = Column(Integer, default=1, comment="是否启用")
|
is_active = Column(Integer, default=1, comment="是否启用")
|
||||||
remark = Column(String(500), nullable=True, comment="备注")
|
remark = Column(String(500), nullable=True, comment="备注")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,247 @@
|
|||||||
|
"""数据分类分级 API 测试 — 重要数据标记 + 资产清单 + 导出"""
|
||||||
|
import hashlib
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Subject, KPIDefinition, Entity
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_subject(db: Session, **kwargs) -> Subject:
|
||||||
|
"""创建测试科目"""
|
||||||
|
defaults = {
|
||||||
|
"subject_code": "1001",
|
||||||
|
"subject_name": "库存现金",
|
||||||
|
"level": 1,
|
||||||
|
"is_active": 1,
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
s = Subject(**defaults)
|
||||||
|
db.add(s)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_kpi(db: Session, **kwargs) -> KPIDefinition:
|
||||||
|
"""创建测试KPI(带entity_id)"""
|
||||||
|
defaults = {
|
||||||
|
"entity_id": 1,
|
||||||
|
"kpi_code": "F_TEST_REVENUE",
|
||||||
|
"kpi_name": "营业收入(万元)",
|
||||||
|
"dimension": "finance",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
k = KPIDefinition(**defaults)
|
||||||
|
db.add(k)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(k)
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
class TestInventory:
|
||||||
|
def test_inventory_empty(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["stats"]["marked"] == 0
|
||||||
|
|
||||||
|
def test_inventory_contains_subject_and_kpi(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
types = {i["type"] for i in data["items"]}
|
||||||
|
assert types == {"subject", "kpi"}
|
||||||
|
assert data["total"] == 2
|
||||||
|
# 未标记时级别默认 general
|
||||||
|
assert data["stats"]["by_level"]["general"] == 2
|
||||||
|
|
||||||
|
def test_filter_by_data_type(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"data_type": "kpi"}, headers=auth_header(token))
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["type"] == "kpi"
|
||||||
|
|
||||||
|
def test_filter_by_level(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"data_level": "important"}, headers=auth_header(token))
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["name"] == "库存现金"
|
||||||
|
|
||||||
|
def test_filter_important_only(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"important": 1}, headers=auth_header(token))
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
|
||||||
|
def test_invalid_level_400(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"data_level": "secret"}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkSubject:
|
||||||
|
def test_mark_subject(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
s = create_test_subject(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put(f"/api/cma/data-classification/subjects/{s.id}",
|
||||||
|
params={"data_level": "important", "data_category": "财务数据"},
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# 重要级别自动视为重要数据
|
||||||
|
assert body["important_flag"] == 1
|
||||||
|
assert body["data_level"] == "important"
|
||||||
|
assert body["data_category"] == "财务数据"
|
||||||
|
|
||||||
|
def test_mark_subject_general_clears(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
s = create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put(f"/api/cma/data-classification/subjects/{s.id}",
|
||||||
|
params={"data_level": "general", "important_flag": 0},
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["data_level"] == "general"
|
||||||
|
assert r.json()["important_flag"] == 0
|
||||||
|
|
||||||
|
def test_mark_subject_not_found(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put("/api/cma/data-classification/subjects/9999",
|
||||||
|
params={"data_level": "important"}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkKpi:
|
||||||
|
def test_mark_kpi(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
k = create_test_kpi(db, entity_id=1)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put(f"/api/cma/data-classification/kpis/{k.id}",
|
||||||
|
params={"data_level": "core", "data_category": "财务数据"},
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["important_flag"] == 1
|
||||||
|
assert r.json()["data_level"] == "core"
|
||||||
|
|
||||||
|
def test_kpi_entity_isolation(self, client: TestClient, db: Session):
|
||||||
|
"""多租户隔离:entity 2 的KPI,entity 1 的token不可见、不可标记"""
|
||||||
|
create_test_user(db)
|
||||||
|
k2 = create_test_kpi(db, entity_id=2, kpi_code="F_OTHER_ENTITY")
|
||||||
|
token = get_token_for_user(client) # token 绑定 entity 1
|
||||||
|
# 不可标记
|
||||||
|
r = client.put(f"/api/cma/data-classification/kpis/{k2.id}",
|
||||||
|
params={"data_level": "important"}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 404
|
||||||
|
# 清单中不可见
|
||||||
|
r2 = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"data_type": "kpi"}, headers=auth_header(token))
|
||||||
|
data = r2.json()
|
||||||
|
assert all(i["code"] != "F_OTHER_ENTITY" for i in data["items"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatch:
|
||||||
|
def test_batch_mark_subjects(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
s1 = create_test_subject(db, subject_code="1001", subject_name="库存现金")
|
||||||
|
s2 = create_test_subject(db, subject_code="1002", subject_name="银行存款")
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put("/api/cma/data-classification/batch",
|
||||||
|
params={"data_type": "subject", "ids": [s1.id, s2.id],
|
||||||
|
"data_level": "important", "data_category": "财务数据"},
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["updated_count"] == 2
|
||||||
|
# 回查确认生效
|
||||||
|
db.expire_all()
|
||||||
|
assert db.query(Subject).get(s1.id).important_flag == 1
|
||||||
|
assert db.query(Subject).get(s2.id).data_level == "important"
|
||||||
|
|
||||||
|
def test_batch_invalid_type(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put("/api/cma/data-classification/batch",
|
||||||
|
params={"data_type": "bad", "ids": [1]}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestExport:
|
||||||
|
def test_export_csv(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/export", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "text/csv" in r.headers["content-type"]
|
||||||
|
text = r.text
|
||||||
|
# 表头 + 类型 + 中文标签
|
||||||
|
assert "类型" in text and "数据级别" in text
|
||||||
|
assert "库存现金" in text
|
||||||
|
assert "科目" in text and "KPI" in text
|
||||||
|
assert "重要数据" in text
|
||||||
|
|
||||||
|
def test_export_filtered(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/export",
|
||||||
|
params={"important": 1}, headers=auth_header(token))
|
||||||
|
text = r.text
|
||||||
|
assert "库存现金" in text
|
||||||
|
assert "营业收入(万元)" not in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestStats:
|
||||||
|
def test_stats(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_subject(db, subject_code="1002", subject_name="银行存款")
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/stats", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["subjects"]["total"] == 2
|
||||||
|
assert data["subjects"]["marked"] == 1
|
||||||
|
assert data["kpis"]["total"] == 1
|
||||||
|
assert data["marked_total"] == 1
|
||||||
|
assert "policy_note" in data
|
||||||
|
|
||||||
|
def test_industry_reference(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/industry-reference", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert len(items) >= 5
|
||||||
|
industries = {i["industry"] for i in items}
|
||||||
|
assert "金融" in industries and "医疗" in industries
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
基线测试:KPI创建接口缺少必填元数据字段时返回 HTTP 422。
|
||||||
|
|
||||||
|
场景:POST /api/cma/kpis 请求体不传 formula 字段(数据治理规则2: 元数据必填),
|
||||||
|
期望返回 HTTP 422,且 errors 中包含 formula 相关提示。
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||||
|
|
||||||
|
|
||||||
|
class TestKpi422Baseline:
|
||||||
|
"""KPI创建缺少必填元数据字段 → 422 基线测试"""
|
||||||
|
|
||||||
|
def test_create_kpi_missing_formula_returns_422(self, client: TestClient, db: Session):
|
||||||
|
"""不传 formula 字段时,创建KPI返回 422"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
# 构造请求体:其余必填字段齐全,唯独不传 formula
|
||||||
|
payload = {
|
||||||
|
"kpi_code": "F_BASELINE_001",
|
||||||
|
"kpi_name": "基线测试收入指标",
|
||||||
|
"dimension": "finance",
|
||||||
|
"target_value": 1000000,
|
||||||
|
"unit": "元",
|
||||||
|
# 注意:故意不传 formula(必填元数据字段)
|
||||||
|
"data_source": "测试系统",
|
||||||
|
"data_owner": "测试管理员",
|
||||||
|
}
|
||||||
|
resp = client.post("/api/cma/kpis", headers=auth_header(token), json=payload)
|
||||||
|
assert resp.status_code == 422, f"期望422,实际 {resp.status_code}: {resp.text}"
|
||||||
|
|
||||||
|
# 校验错误信息中包含 formula 字段
|
||||||
|
# 注意:FastAPI HTTPException(detail=dict) 时响应体为 {"detail": {...}}
|
||||||
|
body = resp.json()
|
||||||
|
detail = body.get("detail", {})
|
||||||
|
errors = detail.get("errors", []) if isinstance(detail, dict) else []
|
||||||
|
assert any("formula" in e for e in errors), f"errors 应提及 formula: {body}"
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# CMA 数据分析三原则(找异常 / 读动机 / 理关联)——交付方法论
|
||||||
|
|
||||||
|
> 版本:v1.0 | 2026-08-26 | 提出:yanxueBot(user-insight-9-perspectives 视角5)
|
||||||
|
> 落地:项目Bot + 财务Bot | 状态:交付方法论(P2,不新增功能,用现有CMA能力)
|
||||||
|
> 配套模板:`docs/templates/cma-data-analysis-report-template.md`
|
||||||
|
|
||||||
|
## 0. 为什么做这套方法论
|
||||||
|
|
||||||
|
客户买了CMA,看到的不该是"一堆报表",而应该是"一个故事"。
|
||||||
|
同一个数据,讲法不同,客户价值感知完全不同:
|
||||||
|
|
||||||
|
- 讲数据:营业收入8.08万 → 客户:"哦,知道了"(无感)
|
||||||
|
- 讲故事:营业收入环比暴跌90%,触发红色预警,根因是渠道补贴冲减、真实毛利51.4%被账面口径掩盖 → 客户:"那怎么办?"(有行动)
|
||||||
|
|
||||||
|
三原则就是把"数据→洞察"做成可复制的标准流程:**找异常(信号)→ 读动机(原因)→ 理关联(对策)**。
|
||||||
|
|
||||||
|
## 1. 三原则总览
|
||||||
|
|
||||||
|
| # | 原则 | 一句话 | 对应CMA能力 | 输出 |
|
||||||
|
|---|------|--------|------------|------|
|
||||||
|
| 1 | 找异常 | 偏离预期的数字=信号=机会 | 预警规则 alert_rules(static/trend_down)、KPI偏离看板、预测偏差告警 | 异常清单(KPI×偏离度×红黄绿) |
|
||||||
|
| 2 | 读动机 | 数据背后是活生生的人 | 业务访谈、场景还原、口径拆解(多模型对比) | 动机/口径解释(为什么会这样) |
|
||||||
|
| 3 | 理关联 | 串联行为与市场,理清因果 | KPI因果链(positive/negative)、模拟推演、四维度联动 | 因果链图+对策建议(所以怎么办) |
|
||||||
|
|
||||||
|
记忆口诀:**"哪儿不对 → 为什么 → 牵一发动哪里"**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 原则一:找异常(不只看常态)
|
||||||
|
|
||||||
|
### 2.1 定义
|
||||||
|
常态是背景,异常才是信号。客户最值钱的信息不是"这个月正常",而是"这个月不对劲"。
|
||||||
|
异常=偏离预期(目标/阈值/趋势/历史同期)的数字。偏离越大,机会越大(无论好坏)。
|
||||||
|
|
||||||
|
### 2.2 CMA落地工具
|
||||||
|
- **预警规则**(alert_rules):static(绿/黄/红阈值)+ trend_down(环比跌幅)双引擎,现行78条
|
||||||
|
- **KPI看板/偏离视图**:实际值 vs 目标值,红黄绿一目了然
|
||||||
|
- **预测偏差告警**(rule_type=forecast_deviation):实际 vs 模型预测,跑偏即报警
|
||||||
|
- **多粒度对比**:月/季/年目标(target_calc_type)齐比,避免单粒度误判
|
||||||
|
|
||||||
|
### 2.3 落地步骤(30分钟)
|
||||||
|
1. 拉当期全量KPI实际值,按红黄绿筛出红/黄
|
||||||
|
2. 环比/同比/目标三个维度排序,取Top5偏离
|
||||||
|
3. 只保留"偏离有业务含义"的,剔除口径噪音(如导入错误、季节性)
|
||||||
|
4. 输出异常清单:KPI名 | 实际值 | 预期值 | 偏离度 | 红黄绿 | 首次出现时间
|
||||||
|
|
||||||
|
### 2.4 客户话术
|
||||||
|
- 开场:"这个月有X个指标在警报区,我们一个个看。"
|
||||||
|
- 提问:"这个数字偏离了目标X%,您觉得是市场变了,还是口径变了?"
|
||||||
|
- 升级:"连续两个月trend_down,这不是偶发,是结构性问题。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 原则二:读动机(不只看表象)
|
||||||
|
|
||||||
|
### 3.1 定义
|
||||||
|
数据是人行为的痕迹。同一个"毛利率低",可能是成本高(真差),也可能是返利未确认(口径差)。
|
||||||
|
不访谈、不还原场景,就敢下结论 = 报告是废纸。
|
||||||
|
|
||||||
|
### 3.2 CMA落地工具
|
||||||
|
- **业务访谈**:按KPI问业务负责人"这个数怎么来的、最近变了什么"
|
||||||
|
- **口径拆解/多模型对比**:账面口径 vs 管理口径(如Model C),还原真实经营
|
||||||
|
- **场景还原**:把数字放回业务场景(门店、客户、合同、渠道)
|
||||||
|
- **数据血缘/来源核查**:确认数字本身没错,再谈动机
|
||||||
|
|
||||||
|
### 3.3 落地步骤(60分钟)
|
||||||
|
1. 对异常清单每条,先做"口径自检":数字对了吗?口径对吗?
|
||||||
|
2. 再问"业务自检":最近有什么动作/事件影响它?
|
||||||
|
3. 访谈至少1个业务负责人,记录原话
|
||||||
|
4. 输出动机解释:异常KPI → 表面原因 → 深层动机(谁、为什么、什么场景)
|
||||||
|
|
||||||
|
### 3.4 客户话术
|
||||||
|
- "账面看是XX,但您看这个口径拆解——实际是XX。"
|
||||||
|
- "我猜是XX原因导致的,对吗?"(给客户一个可确认/可纠正的假设,不要下结论)
|
||||||
|
- "这个数字背后是哪个业务动作?是主动调整还是被动结果?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 原则三:理关联(不只盯增长)
|
||||||
|
|
||||||
|
### 4.1 定义
|
||||||
|
单点数字会骗人,因果链不会。涨跌不只看自己,要看它牵动谁、被谁牵动。
|
||||||
|
理清因果 = 找到真正的驱动杠杆,对策才有落点。
|
||||||
|
|
||||||
|
### 4.2 CMA落地工具
|
||||||
|
- **KPI因果链**(kpi_causality):positive/negative 方向 + strength 强度 + lag_months 滞后(现行28条:positive 20 / negative 8)
|
||||||
|
- **模拟推演**(POST /api/cma/kpi-causality/simulate):改一个KPI,预测下游影响链
|
||||||
|
- **四维度联动**:财务/客户/内部流程/学习成长 跨层传导(BSC四层泳道)
|
||||||
|
- **战略地图**:节点间连线即因果,从KPI追溯到OKR/战略
|
||||||
|
|
||||||
|
### 4.3 落地步骤(45分钟)
|
||||||
|
1. 对每个异常KPI,查上游(谁驱动它)+ 下游(它影响谁)
|
||||||
|
2. 标注强度(>0.7强相关)与方向,找出Top3驱动链
|
||||||
|
3. 用模拟推演验证:"如果修复这个驱动,下游能改善多少"
|
||||||
|
4. 输出因果链图 + 对策建议:优先动"强驱动、可干预"的杠杆点
|
||||||
|
|
||||||
|
### 4.4 客户话术
|
||||||
|
- "这个KPI不是孤立的——它由X驱动(强度0.9),又牵动Y。"
|
||||||
|
- "真正的问题不在表面这个数,在它上游的X。"
|
||||||
|
- "我们建议先动X:按模拟推演,X每改善10%,Y能改善约9%。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 《CMA数据分析报告》结构模板(含三原则应用位置)
|
||||||
|
|
||||||
|
完整可复用模板见 `docs/templates/cma-data-analysis-report-template.md`,骨架如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
一、经营总览(一页纸)
|
||||||
|
- 核心KPI仪表盘(红黄绿)+ 一句话结论【找异常入口】
|
||||||
|
|
||||||
|
二、异常发现【原则一·找异常】
|
||||||
|
- 异常清单表:KPI | 实际 | 预期 | 偏离 | 红黄绿
|
||||||
|
- Top3异常重点展开(趋势图+阈值线)
|
||||||
|
|
||||||
|
三、动机解读【原则二·读动机】
|
||||||
|
- 每条Top异常:口径自检结论 + 业务动机(谁/为什么/什么场景)
|
||||||
|
- 管理口径 vs 账面口径对比(如适用)
|
||||||
|
|
||||||
|
四、因果关联【原则三·理关联】
|
||||||
|
- 异常KPI的因果链图(上游驱动/下游影响,标注强度方向)
|
||||||
|
- 模拟推演结果:动哪个杠杆、影响多大
|
||||||
|
|
||||||
|
五、对策与行动
|
||||||
|
- 按"强驱动+可干预"排序的3条建议(对应责任KPI)
|
||||||
|
- 下期目标修正建议(如需)
|
||||||
|
|
||||||
|
六、附录
|
||||||
|
- 数据口径说明 / 预警规则清单 / 访谈记录要点
|
||||||
|
```
|
||||||
|
|
||||||
|
每章都在"讲一个故事":**先让客户看见异常(信号),再让他理解为什么(动机),最后带他看连锁反应和对策(关联)**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 样例:陕酣客(陕西酣客文化传媒,entity 1)三原则试跑
|
||||||
|
|
||||||
|
> 用CMA现行真实数据(2026-08-26 查询):预警规则78条(static 39 + trend_down 39)、因果链28条(positive 20 + negative 8)、营业收入实际值2026-06~08。
|
||||||
|
|
||||||
|
### 6.1 找异常
|
||||||
|
- 营业收入:2026-06 实际129.32 → 2026-07 81.08 → 2026-08 8.08
|
||||||
|
- 环比:7月 -37%,8月 **-90%**;静态规则 red < 4000(触发红色);trend_down 阈值10%(远超)
|
||||||
|
- 毛利率:账面口径 0.13%(静态规则 red < 30,严重偏离)
|
||||||
|
- 结论:收入崩盘 + 毛利率异常 = 双红色信号,值得深挖
|
||||||
|
|
||||||
|
### 6.2 读动机
|
||||||
|
- 毛利率账面0.13%的原因(访谈+口径拆解):白酒经销模式下,上游厂返利/补贴挂账未确认、渠道补贴冲减收入,账面口径失真
|
||||||
|
- 调整为管理口径(Model C:还原厂补+剔除冲减)后,真实毛利率 **51.4%**
|
||||||
|
- 动机解读:不是经营变差,是"返利确认节奏"和"渠道补贴政策"两个业务动作主导了账面数字——数据背后的活人是酒厂结算员和渠道客户
|
||||||
|
|
||||||
|
### 6.3 理关联(因果链实证)
|
||||||
|
| 因果 | 方向 | 强度 | 含义 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 毛利率 → 净利润 | positive | 0.96 | 毛利是利润的第一驱动(强) |
|
||||||
|
| 上游厂补率 → 净利润 | positive | 0.9 | 厂补确认=真金白银 |
|
||||||
|
| 渠补率 → 净利润 | negative | 0.85 | 渠道补贴是利润黑洞 |
|
||||||
|
| 费用率 → 净利润 | negative | 0.8 | 费用管控第二杠杆 |
|
||||||
|
| 营业收入 → 净利润 | positive | 0.1 | 收入规模对利润贡献很弱(反直觉!) |
|
||||||
|
|
||||||
|
- 洞察:表面看"收入崩了",因果链显示**利润真正的驱动是毛利率(0.96)和厂补率(0.9)**,而收入规模只贡献0.1——所以对策不是"冲收入",而是"确认厂补、压渠补、管费用"
|
||||||
|
- 模拟推演示意:厂补率每改善10%,净利润传导约 +9%;渠补率每压缩10%,净利润 +8.5%
|
||||||
|
|
||||||
|
### 6.4 一句话故事
|
||||||
|
"这个月收入掉了90%,账面毛利0.13%——但拆开口径,真实毛利51.4%;因果链告诉我们利润的命门是厂补确认和渠道补贴,不是收入规模。所以下月重点:催厂补、砍无效渠补。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 交付流程(标准三步)
|
||||||
|
|
||||||
|
| 步骤 | 动作 | 工具 | 时间盒 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| Step 1 | 跑异常清单 | 预警/KPI看板 | 30min |
|
||||||
|
| Step 2 | 访谈+口径拆解 | 业务访谈/多模型 | 60min |
|
||||||
|
| Step 3 | 因果链+对策 | 因果链/模拟推演 | 45min |
|
||||||
|
|
||||||
|
铁律:**没有访谈就写动机 = 编故事;没有因果链就写对策 = 拍脑袋。**
|
||||||
|
|
||||||
|
## 8. 验收与自检清单
|
||||||
|
|
||||||
|
- [ ] 报告含异常清单(有红黄绿,不只有常态)
|
||||||
|
- [ ] 每条Top异常有动机解释(有访谈/口径依据,不是猜测)
|
||||||
|
- [ ] 每个对策能追溯到因果链(有强度/方向/推演支撑)
|
||||||
|
- [ ] 客户能一句话复述故事("收入掉了但真实毛利51.4%,命门是厂补")
|
||||||
|
|
||||||
|
## 关联
|
||||||
|
- 来源:user-insight-9-perspectives.md(视角5,一组数据一个故事)
|
||||||
|
- CMA能力:alert_rules(78条)/ kpi_causality(28条)/ simulate / 四维度BSC
|
||||||
|
- 案例:陕酣客(entity 1)—— 账面毛利0.13% → Model C 51.4%
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 《CMA数据分析报告》交付模板
|
||||||
|
|
||||||
|
> 用法:复制本模板,按客户数据填充。三原则贯穿全篇——找异常(第2章)、读动机(第3章)、理关联(第4章)。
|
||||||
|
> 配套方法论:`docs/cma-data-analysis-3-principles.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 封面
|
||||||
|
- 客户名称 / 报告期间 / 交付方(博海科技)/ 日期
|
||||||
|
|
||||||
|
## 一、经营总览(一页纸)
|
||||||
|
- 核心KPI仪表盘(表格或卡片,绿/黄/红三色标记)
|
||||||
|
- 一句话结论(用"数据+故事"句式,如:"收入-90%但真实毛利51.4%,利润命门是厂补确认")
|
||||||
|
|
||||||
|
| KPI | 实际值 | 目标/阈值 | 状态 | 一句话 |
|
||||||
|
|-----|--------|-----------|------|--------|
|
||||||
|
| 营业收入 | 8.08 | red<4000 | 🔴 | 环比-90%,崩盘 |
|
||||||
|
| 毛利率 | 0.13%(账面) | red<30 | 🔴 | 口径失真,真实51.4% |
|
||||||
|
| ... | ... | ... | ... | ... |
|
||||||
|
|
||||||
|
## 二、异常发现【原则一:找异常】
|
||||||
|
### 2.1 异常清单
|
||||||
|
| KPI | 实际值 | 预期值 | 偏离度 | 红黄绿 | 首次出现 |
|
||||||
|
|-----|--------|--------|--------|--------|----------|
|
||||||
|
| ... | ... | ... | ... | ... | ... |
|
||||||
|
|
||||||
|
### 2.2 Top3异常展开(每个含:趋势图+阈值线+偏离说明)
|
||||||
|
1. 异常KPI A:趋势 + 触发规则 + 偏离解读
|
||||||
|
2. 异常KPI B:...
|
||||||
|
3. 异常KPI C:...
|
||||||
|
|
||||||
|
## 三、动机解读【原则二:读动机】
|
||||||
|
### 3.1 口径自检
|
||||||
|
- 数字准确性:来源表/导入批次/负责人确认
|
||||||
|
- 口径说明:账面口径 vs 管理口径差异(如Model C还原)
|
||||||
|
|
||||||
|
### 3.2 业务动机(每条Top异常)
|
||||||
|
| 异常KPI | 表面原因 | 深层动机(谁/为什么/场景) | 依据(访谈/口径) |
|
||||||
|
|---------|----------|--------------------------|-------------------|
|
||||||
|
| ... | ... | ... | ... |
|
||||||
|
|
||||||
|
### 3.3 关键访谈记录要点
|
||||||
|
- 访谈对象 / 时间 / 原话要点
|
||||||
|
|
||||||
|
## 四、因果关联【原则三:理关联】
|
||||||
|
### 4.1 异常KPI因果链
|
||||||
|
- 上游驱动(谁影响它):KPI | 方向 | 强度 | 滞后
|
||||||
|
- 下游影响(它影响谁):KPI | 方向 | 强度 | 滞后
|
||||||
|
|
||||||
|
### 4.2 模拟推演
|
||||||
|
- 场景:调整X KPI → 下游Y预测变化(表格)
|
||||||
|
|
||||||
|
### 4.3 关键洞察
|
||||||
|
- 反直觉发现(如"收入规模对利润贡献仅0.1,毛利贡献0.96")
|
||||||
|
|
||||||
|
## 五、对策与行动
|
||||||
|
### 5.1 优先行动(按强驱动+可干预排序)
|
||||||
|
| 序号 | 行动 | 驱动KPI | 因果依据 | 预期效果 | 责任KPI |
|
||||||
|
|------|------|---------|----------|----------|---------|
|
||||||
|
| 1 | ... | ... | 强度/方向 | ... | ... |
|
||||||
|
|
||||||
|
### 5.2 下期目标修正建议(如需)
|
||||||
|
- 目标/阈值调整建议 + 理由
|
||||||
|
|
||||||
|
## 六、附录
|
||||||
|
- 数据口径说明 / 预警规则清单 / 完整因果链图 / 访谈记录 / 数据来源
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 客户沟通话术库(按场景)
|
||||||
|
|
||||||
|
**开场**
|
||||||
|
- "这个月有X个指标在警报区,我们一个个看。"
|
||||||
|
- "先看结论:整体X,但有两个信号值得注意。"
|
||||||
|
|
||||||
|
**找异常**
|
||||||
|
- "这个数字偏离目标X%,您觉得是市场变了,还是口径变了?"
|
||||||
|
- "连续两个月下降,这不是偶发,是结构性问题。"
|
||||||
|
|
||||||
|
**读动机**
|
||||||
|
- "账面看是XX,但口径拆解后实际是XX。"
|
||||||
|
- "我猜是XX导致的,对吗?"(给假设,让客户确认/纠正)
|
||||||
|
- "这是主动调整还是被动结果?"
|
||||||
|
|
||||||
|
**理关联**
|
||||||
|
- "这个KPI不是孤立的——它由X驱动(强度0.9),又牵动Y。"
|
||||||
|
- "真正的问题不在表面这个数,在它上游的X。"
|
||||||
|
- "按模拟推演,X每改善10%,Y能改善约9%。"
|
||||||
|
|
||||||
|
**收尾**
|
||||||
|
- "一句话总结:XX。(客户能复述,才算讲明白)"
|
||||||
|
- "下月重点盯X,我们把它写进预警规则。"
|
||||||
@@ -9,10 +9,10 @@ interface MenuItem {
|
|||||||
|
|
||||||
// ── 角色路由映射 ──
|
// ── 角色路由映射 ──
|
||||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance'],
|
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance'],
|
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance'],
|
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance'],
|
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROLE_ACTIONS: Record<string, string[]> = {
|
export const ROLE_ACTIONS: Record<string, string[]> = {
|
||||||
@@ -61,6 +61,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||||||
// ── GROUP 5: 基础数据与知识──
|
// ── GROUP 5: 基础数据与知识──
|
||||||
{ path: '/kpis', label: 'KPI字典', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' },
|
{ path: '/kpis', label: 'KPI字典', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' },
|
||||||
{ path: '/data', label: '数据管理', icon: 'Connection', roles: ['ceo', 'finance', 'it'], group: '基础数据与知识' },
|
{ path: '/data', label: '数据管理', icon: 'Connection', roles: ['ceo', 'finance', 'it'], group: '基础数据与知识' },
|
||||||
|
{ path: '/data-classification', label: '数据分类分级', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' },
|
||||||
{ path: '/knowledge', label: 'CMA知识库', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' },
|
{ path: '/knowledge', label: 'CMA知识库', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' },
|
||||||
{ path: '/okr-templates', label: 'OKR模板库', icon: 'Collection', roles: ['ceo', 'finance', 'it'], group: '基础数据与知识' },
|
{ path: '/okr-templates', label: 'OKR模板库', icon: 'Collection', roles: ['ceo', 'finance', 'it'], group: '基础数据与知识' },
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const routes = [
|
|||||||
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'], editable: true } },
|
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'], editable: true } },
|
||||||
{ path: 'okr/:id', name: 'OkrDetail', component: () => import('@/views/OkrDetail.vue'), meta: { title: 'OKR详情', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'okr/:id', name: 'OkrDetail', component: () => import('@/views/OkrDetail.vue'), meta: { title: 'OKR详情', roles: ['ceo', 'finance', 'it'] } },
|
||||||
{ path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
|
{ path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||||
|
{ path: 'data-classification', name: 'DataClassification', component: () => import('@/views/DataClassification.vue'), meta: { title: '数据分类分级', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
||||||
{ path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } },
|
{ path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } },
|
||||||
{ path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } },
|
||||||
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dc-page">
|
||||||
|
<!-- 政策提示 -->
|
||||||
|
<div class="policy-banner">
|
||||||
|
<span class="policy-icon">📜</span>
|
||||||
|
<span>
|
||||||
|
《网络数据安全风险评估办法》(2026-08-20 施行):重要数据处理者每年一次强制评估,评估前提=先完成数据分类分级。
|
||||||
|
本页为「分类分级工具」第一版:标记 + 数据资产清单 + 导出(评估前准备材料)。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计概览 -->
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num">{{ stats.subjects?.total ?? 0 }}</div>
|
||||||
|
<div class="stat-label">会计科目</div>
|
||||||
|
<div class="stat-sub">已标记 {{ stats.subjects?.marked ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num">{{ stats.kpis?.total ?? 0 }}</div>
|
||||||
|
<div class="stat-label">KPI 指标</div>
|
||||||
|
<div class="stat-sub">已标记 {{ stats.kpis?.marked ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num">{{ stats.marked_total ?? 0 }}</div>
|
||||||
|
<div class="stat-label">重要数据项</div>
|
||||||
|
<div class="stat-sub">覆盖率 {{ stats.coverage_pct ?? 0 }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-num">{{ stats.entity_name || ('账套 #' + (stats.entity_id ?? '-')) }}</div>
|
||||||
|
<div class="stat-label">当前账套</div>
|
||||||
|
<div class="stat-sub">多租户隔离</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作条 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-radio-group v-model="dataType" size="small" @change="loadInventory">
|
||||||
|
<el-radio-button label="">全部</el-radio-button>
|
||||||
|
<el-radio-button label="subject">科目</el-radio-button>
|
||||||
|
<el-radio-button label="kpi">KPI</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<el-select v-model="filterLevel" placeholder="数据级别" clearable size="small" style="width:130px;" @change="loadInventory">
|
||||||
|
<el-option label="核心数据" value="core" />
|
||||||
|
<el-option label="重要数据" value="important" />
|
||||||
|
<el-option label="一般数据" value="general" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-checkbox v-model="onlyImportant" size="small" @change="loadInventory">仅看重要数据</el-checkbox>
|
||||||
|
|
||||||
|
<el-input v-model="keyword" placeholder="搜索编码/名称" size="small" style="width:180px;" clearable @change="loadInventory" />
|
||||||
|
|
||||||
|
<span style="margin-left:auto;">
|
||||||
|
<el-button size="small" type="success" :disabled="selectedIds.length === 0 || !canWrite" @click="openBatchPanel">
|
||||||
|
批量打标 ({{ selectedIds.length }})
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="primary" :loading="exporting" @click="doExport">导出清单 CSV</el-button>
|
||||||
|
<el-button size="small" @click="loadInventory">刷新</el-button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量打标面板 -->
|
||||||
|
<div v-if="batchVisible" class="batch-panel">
|
||||||
|
<span>已选 <b>{{ selectedIds.length }}</b> 项,批量设置:</span>
|
||||||
|
<el-select v-model="batchLevel" placeholder="数据级别" size="small" style="width:130px;">
|
||||||
|
<el-option label="核心数据" value="core" />
|
||||||
|
<el-option label="重要数据" value="important" />
|
||||||
|
<el-option label="一般数据" value="general" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="batchCategory" placeholder="行业参考分类(可选,如 财务数据/客户数据)" size="small" style="width:220px;" />
|
||||||
|
<el-button type="primary" size="small" :loading="batchLoading" @click="doBatch">确认打标</el-button>
|
||||||
|
<el-button size="small" @click="batchVisible = false">取消</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据资产清单 -->
|
||||||
|
<el-table
|
||||||
|
:data="items"
|
||||||
|
border stripe size="small"
|
||||||
|
style="width:100%;margin-top:8px;"
|
||||||
|
@selection-change="onSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="36" />
|
||||||
|
<el-table-column label="类型" width="64">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.type === 'subject' ? '' : 'primary'" size="small">
|
||||||
|
{{ row.type === 'subject' ? '科目' : 'KPI' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="code" label="编码" width="110" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="170" />
|
||||||
|
<el-table-column prop="dimension" label="维度" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.dimension">{{ dimLabel(row.dimension) }}</span>
|
||||||
|
<span v-else style="color:#ccc;">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="重要标记" width="84">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.important_flag ? 'danger' : 'info'" size="small">
|
||||||
|
{{ row.important_flag ? '重要' : '一般' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数据级别" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select
|
||||||
|
v-model="row.data_level"
|
||||||
|
size="small"
|
||||||
|
style="width:112px;"
|
||||||
|
:disabled="!canWrite"
|
||||||
|
@change="val => onLevelChange(row, val)"
|
||||||
|
>
|
||||||
|
<el-option label="核心数据" value="core" />
|
||||||
|
<el-option label="重要数据" value="important" />
|
||||||
|
<el-option label="一般数据" value="general" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="行业参考分类" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select
|
||||||
|
v-model="row.data_category"
|
||||||
|
size="small"
|
||||||
|
style="width:150px;"
|
||||||
|
:disabled="!canWrite"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
clearable
|
||||||
|
placeholder="选择或输入"
|
||||||
|
@change="val => onCategoryChange(row, val)"
|
||||||
|
>
|
||||||
|
<el-option v-for="c in categoryOptions" :key="c" :label="c" :value="c" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="data_owner" label="责任人" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.data_owner">{{ row.data_owner }}</span>
|
||||||
|
<span v-else style="color:#ccc;">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="storage" label="存储位置" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-size:12px;">{{ row.storage }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="table-footer">共 {{ totalItems }} 项 · 已标记 {{ markedCount }} 项</div>
|
||||||
|
|
||||||
|
<!-- 行业参考目录 -->
|
||||||
|
<el-collapse style="margin-top:12px;">
|
||||||
|
<el-collapse-item>
|
||||||
|
<template #title>
|
||||||
|
<span style="font-weight:600;">🏭 行业参考目录(按行业提示重点数据,可自定义)</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="industryRef" border stripe size="small" style="width:100%;">
|
||||||
|
<el-table-column prop="industry" label="行业" width="130" />
|
||||||
|
<el-table-column prop="category" label="数据分类" width="140" />
|
||||||
|
<el-table-column label="建议级别" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.suggest_level === 'core' ? 'danger' : 'warning'" size="small">
|
||||||
|
{{ row.suggest_level === 'core' ? '核心' : '重要' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="desc" label="说明" />
|
||||||
|
</el-table>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({ baseURL: '/api/cma', timeout: 30000 })
|
||||||
|
api.interceptors.request.use((config: any) => {
|
||||||
|
const token = localStorage.getItem('cma_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
const userRole = computed(() => (localStorage.getItem('cma_role') || 'finance'))
|
||||||
|
const canWrite = computed(() => ['ceo', 'finance', 'it'].includes(userRole.value))
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const markedCount = ref(0)
|
||||||
|
const stats = ref<any>({})
|
||||||
|
const industryRef = ref<any[]>([])
|
||||||
|
|
||||||
|
const dataType = ref('')
|
||||||
|
const filterLevel = ref('')
|
||||||
|
const onlyImportant = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const selectedIds = ref<number[]>([])
|
||||||
|
const selectedType = ref('subject')
|
||||||
|
|
||||||
|
const exporting = ref(false)
|
||||||
|
const batchVisible = ref(false)
|
||||||
|
const batchLevel = ref('important')
|
||||||
|
const batchCategory = ref('')
|
||||||
|
const batchLoading = ref(false)
|
||||||
|
|
||||||
|
const categoryOptions = ['财务数据', '客户数据', '员工数据', '生产数据', '供应链数据', '税务数据', '薪酬数据', '个人信息']
|
||||||
|
|
||||||
|
const dimMap: Record<string, string> = {
|
||||||
|
finance: '财务', customer: '客户', process: '流程', learning: '学习成长',
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimLabel(d: string) {
|
||||||
|
return dimMap[d] || d
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const r = await api.get('/data-classification/stats')
|
||||||
|
stats.value = (r as any).data || {}
|
||||||
|
} catch (e) {
|
||||||
|
stats.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInventory() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (dataType.value) params.data_type = dataType.value
|
||||||
|
if (filterLevel.value) params.data_level = filterLevel.value
|
||||||
|
if (onlyImportant.value) params.important = 1
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
const r = await api.get('/data-classification/inventory', { params })
|
||||||
|
const d = (r as any).data || {}
|
||||||
|
items.value = d.items || []
|
||||||
|
totalItems.value = d.total || 0
|
||||||
|
markedCount.value = (d.stats || {}).marked || 0
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载数据资产清单失败')
|
||||||
|
items.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIndustryRef() {
|
||||||
|
try {
|
||||||
|
const r = await api.get('/data-classification/industry-reference')
|
||||||
|
industryRef.value = (r as any).data?.items || []
|
||||||
|
} catch (e) {
|
||||||
|
industryRef.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectionChange(rows: any[]) {
|
||||||
|
selectedIds.value = rows.map((r: any) => r.id)
|
||||||
|
selectedType.value = rows.length ? rows[0].type : 'subject'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLevelChange(row: any, val: string) {
|
||||||
|
try {
|
||||||
|
const url = row.type === 'subject' ? `/data-classification/subjects/${row.id}` : `/data-classification/kpis/${row.id}`
|
||||||
|
await api.put(url, null, { params: { data_level: val } })
|
||||||
|
row.important_flag = (val === 'core' || val === 'important') ? 1 : row.important_flag
|
||||||
|
ElMessage.success(`${row.name} 分级更新成功`)
|
||||||
|
await loadStats()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('更新失败')
|
||||||
|
await loadInventory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCategoryChange(row: any, val: string) {
|
||||||
|
try {
|
||||||
|
const url = row.type === 'subject' ? `/data-classification/subjects/${row.id}` : `/data-classification/kpis/${row.id}`
|
||||||
|
await api.put(url, null, { params: { data_category: val || '' } })
|
||||||
|
ElMessage.success(`${row.name} 分类更新成功`)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('更新失败')
|
||||||
|
await loadInventory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchPanel() {
|
||||||
|
batchLevel.value = 'important'
|
||||||
|
batchCategory.value = ''
|
||||||
|
batchVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doBatch() {
|
||||||
|
batchLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.put('/data-classification/batch', null, {
|
||||||
|
params: {
|
||||||
|
data_type: selectedType.value,
|
||||||
|
ids: selectedIds.value,
|
||||||
|
data_level: batchLevel.value,
|
||||||
|
...(batchCategory.value ? { data_category: batchCategory.value } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ElMessage.success(`批量打标 ${selectedIds.value.length} 项成功`)
|
||||||
|
batchVisible.value = false
|
||||||
|
await loadInventory()
|
||||||
|
await loadStats()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('批量打标失败')
|
||||||
|
} finally {
|
||||||
|
batchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doExport() {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (dataType.value) params.data_type = dataType.value
|
||||||
|
if (filterLevel.value) params.data_level = filterLevel.value
|
||||||
|
if (onlyImportant.value) params.important = 1
|
||||||
|
const r: any = await api.get('/data-classification/export', { params, responseType: 'blob' })
|
||||||
|
const blob = r.data
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `数据分类分级清单_${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('清单已导出')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('导出失败')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadStats()
|
||||||
|
loadInventory()
|
||||||
|
loadIndustryRef()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dc-page { max-width: 1280px; margin: 0 auto; padding: 16px; }
|
||||||
|
.policy-banner {
|
||||||
|
background: linear-gradient(90deg, #fef3e2, #fff8ef);
|
||||||
|
border: 1px solid #f5d9a8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #7a5b1e;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.policy-icon { font-size: 16px; }
|
||||||
|
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 14px; }
|
||||||
|
.stat-card {
|
||||||
|
background: #fff; border: 1px solid #e5e7ef; border-radius: 10px;
|
||||||
|
padding: 14px 16px; box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||||
|
}
|
||||||
|
.stat-num { font-size: 24px; font-weight: 700; color: #1a1a2e; }
|
||||||
|
.stat-label { font-size: 13px; color: #666; margin-top: 2px; }
|
||||||
|
.stat-sub { font-size: 12px; color: #999; margin-top: 2px; }
|
||||||
|
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.batch-panel {
|
||||||
|
background: #f0f7ff; border: 1px solid #c8ddf5; border-radius: 8px;
|
||||||
|
padding: 10px 12px; margin-top: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.table-footer { font-size: 12px; color: #999; margin-top: 6px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user