本体三支柱: 科目↔KPI↔OKR三层互联 + 追溯链API + OKR详情页签

- 新表 kpi_subject_map(89映射/24KPI) / objective_kpi(3O×5KPI) / krs(3O×3KR)
- GET /api/cma/ontology/trace?objective_id=N O→KPI→科目逐层追溯
- GET /api/cma/ontology/objectives 三层链路概览
- OkrDetail.vue 新增本体追溯链页签(KR目标/当前值 + KPI→科目标签流)
- 含并发已上线未提交的KPI多粒度列(target_monthly/quarterly/yearly)
- 回归: pytest 409 passed
This commit is contained in:
Hermes CI Fix
2026-08-19 22:58:10 +08:00
parent 0b305e3ab3
commit bd9c70ea05
7 changed files with 601 additions and 6 deletions
+157
View File
@@ -0,0 +1,157 @@
"""
CMA本体三支柱追溯链 API — 科目 ↔ KPI ↔ OKR的O 三层互联 (2026-08-19)
追溯链: 目标(O) → 指标(KPI) → 科目(数据)
objective_kpi 表: O 由哪些 KPI 度量
kpi_subject_map 表: KPI 由哪些科目计算
krs 表: O 的关键结果 KR (OKR完整化)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role
from app.models import Objective, KPIDefinition, ObjectiveKPI, KPISubjectMap, KR, Subject
router = APIRouter(prefix="/api/cma/ontology", tags=["本体三支柱"],
dependencies=[Depends(require_role("ceo", "finance", "it", "business"))],
)
def _num(v):
"""Decimal → float 便于 JSON 序列化"""
return float(v) if v is not None else None
@router.get("/trace")
def trace_ontology(
objective_id: int = Query(..., description="OKR目标ID, 从O→KPI→科目逐层追溯"),
db: Session = Depends(get_db),
):
"""本体追溯链: O(目标) → KPI(指标) → 科目(数据)"""
obj = db.query(Objective).filter(Objective.id == objective_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
# 第2层: O 支撑的 KPI (objective_kpi)
links = (
db.query(ObjectiveKPI, KPIDefinition)
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
.filter(ObjectiveKPI.objective_id == objective_id)
.order_by(ObjectiveKPI.id)
.all()
)
# 第3层: 每个 KPI 依赖的科目 (kpi_subject_map)
subject_by_code = {
s.subject_code: s.subject_name
for s in db.query(Subject).filter(Subject.is_active == 1).all()
}
kpi_layer = []
for link, kpi in links:
maps = (
db.query(KPISubjectMap)
.filter(KPISubjectMap.kpi_id == kpi.id)
.order_by(KPISubjectMap.id)
.all()
)
subjects = [
{
"subject_code": m.subject_code,
"subject_name": subject_by_code.get(m.subject_code, ""),
"calc_type": m.calc_type,
"weight": _num(m.weight),
"remark": m.remark,
}
for m in maps
]
kpi_layer.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"unit": kpi.unit,
"weight": _num(link.weight),
"formula": kpi.formula,
"subjects": subjects,
})
# KR 层 (OKR完整化: O→KR)
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
kr_list = []
for kr in krs:
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first() if kr.metric_kpi_id else None
kr_list.append({
"id": kr.id,
"title": kr.title,
"metric_kpi_id": kr.metric_kpi_id,
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
"target_value": _num(kr.target_value),
"current_value": _num(kr.current_value),
"progress": kr.progress,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
})
subject_total = sum(len(k["subjects"]) for k in kpi_layer)
return {
"objective": {
"id": obj.id,
"title": obj.title,
"description": obj.description,
"dimension": obj.dimension,
"quarter": obj.quarter,
"owner": obj.owner,
"status": obj.status,
"progress": obj.progress,
},
"krs": kr_list,
"kpis": kpi_layer,
"chain": {
"objective_id": obj.id,
"objective_title": obj.title,
"kpi_count": len(kpi_layer),
"subject_count": subject_total,
"path": "O(目标) → KPI(指标) → 科目(数据)",
},
}
@router.get("/objectives")
def list_ontology_objectives(
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
db: Session = Depends(get_db),
):
"""所有OKR目标的三层链路概览(前端OKR页用)"""
q = db.query(Objective)
if quarter:
q = q.filter(Objective.quarter == quarter)
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
results = []
for o in objs:
kpi_links = (
db.query(ObjectiveKPI, KPIDefinition)
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
.filter(ObjectiveKPI.objective_id == o.id)
.all()
)
krs = db.query(KR).filter(KR.objective_id == o.id).all()
results.append({
"id": o.id,
"title": o.title,
"dimension": o.dimension,
"quarter": o.quarter,
"owner": o.owner,
"status": o.status,
"progress": o.progress,
"kpi_count": len(kpi_links),
"kr_count": len(krs),
"kpis": [{"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"weight": _num(l.weight)} for l, k in kpi_links],
"krs": [{"id": kr.id, "title": kr.title, "progress": kr.progress,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None}
for kr in krs],
})
return {"total": len(results), "items": results}
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from dotenv import load_dotenv
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, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality
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
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 app.auth_middleware import require_auth
@@ -74,6 +74,7 @@ app.include_router(okr_templates.router)
app.include_router(subjects.router)
app.include_router(driver_budget.router)
app.include_router(bot_kpis.router)
app.include_router(ontology.router)
app.include_router(bot_iron_law.router)
app.include_router(analysis_results.router)
app.include_router(expenses.router)
+47 -2
View File
@@ -1,5 +1,5 @@
"""管理会计OS 数据模型"""
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint, Numeric, Date
from app.database import Base
from app.models.budget_plan import BudgetPlan
@@ -75,7 +75,10 @@ class KPIDefinition(Base):
data_owner = Column(String(100), default="待指定", comment="数据责任人")
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
unit = Column(String(50), default="%", comment="单位")
target_value = Column(Float, nullable=True, comment="目标值")
target_value = Column(Float, nullable=True, comment="目标值(兼容旧字段)")
target_monthly = Column(Float, nullable=True, comment="月度目标值")
target_quarterly = Column(Float, nullable=True, comment="季度目标值")
target_yearly = Column(Float, nullable=True, comment="年度目标值")
threshold_green = Column(String(100), nullable=True, comment="绿灯阈值")
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
@@ -675,3 +678,45 @@ class SocialSecurity(Base):
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# ============================================================
# 本体三支柱: 科目↔KPI↔OKR 三层互联 (2026-08-19)
# 追溯链: 目标(O) → 指标(KPI) → 科目(数据)
# ============================================================
class KPISubjectMap(Base):
"""科目↔KPI映射 — 指标计算依赖的底层会计科目"""
__tablename__ = "kpi_subject_map"
id = Column(Integer, primary_key=True, index=True)
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
subject_code = Column(String(20), nullable=False, comment="科目编码")
calc_type = Column(String(20), default="sum", comment="sum/avg/ratio/other")
weight = Column(Numeric(5, 2), default=1.00, comment="权重(负=扣减项)")
remark = Column(String(200), nullable=True, comment="备注")
__table_args__ = (UniqueConstraint("kpi_id", "subject_code", name="uk_kpi_subject"),)
class ObjectiveKPI(Base):
"""KPI↔O支撑 — 目标由哪些KPI度量"""
__tablename__ = "objective_kpi"
id = Column(Integer, primary_key=True, index=True)
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
weight = Column(Numeric(5, 2), default=1.00, comment="支撑权重")
__table_args__ = (UniqueConstraint("objective_id", "kpi_id", name="uk_obj_kpi"),)
class KR(Base):
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
__tablename__ = "krs"
id = Column(Integer, primary_key=True, index=True)
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
title = Column(String(200), nullable=False, comment="KR标题")
metric_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=True, comment="度量KPI ID")
target_value = Column(Numeric(15, 2), nullable=True, comment="目标值")
current_value = Column(Numeric(15, 2), nullable=True, comment="当前值")
progress = Column(Integer, default=0, comment="完成进度 0-100")
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled")
due_date = Column(Date, nullable=True, comment="截止日期")
created_at = Column(DateTime, server_default=func.now())