本体三支柱: 科目↔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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user