本体三支柱: 科目↔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())
+181
View File
@@ -0,0 +1,181 @@
-- CMA本体三支柱: 科目↔KPI↔OKR 三层互联 DDL (2026-08-19)
USE cma;
CREATE TABLE IF NOT EXISTS kpi_subject_map (
id INT AUTO_INCREMENT PRIMARY KEY,
kpi_id INT NOT NULL,
subject_code VARCHAR(20) NOT NULL,
calc_type VARCHAR(20) DEFAULT 'sum', -- sum/avg/ratio/other
weight DECIMAL(5,2) DEFAULT 1.00, -- 权重(负=扣减项)
remark VARCHAR(200),
UNIQUE KEY uk_kpi_subject (kpi_id, subject_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科目↔KPI映射(指标计算依赖的底层科目)';
CREATE TABLE IF NOT EXISTS objective_kpi (
id INT AUTO_INCREMENT PRIMARY KEY,
objective_id INT NOT NULL,
kpi_id INT NOT NULL,
weight DECIMAL(5,2) DEFAULT 1.00,
UNIQUE KEY uk_obj_kpi (objective_id, kpi_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI↔O支撑(目标由哪些KPI度量)';
CREATE TABLE IF NOT EXISTS krs (
id INT AUTO_INCREMENT PRIMARY KEY,
objective_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
metric_kpi_id INT, -- 关联的度量KPI
target_value DECIMAL(15,2),
current_value DECIMAL(15,2),
progress INT DEFAULT 0, -- 0-100
status VARCHAR(20) DEFAULT 'pending',
due_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键结果KR(OKR完整化)';
-- kpi_subject_map 种子数据: 核心财务KPI → 会计科目映射 (2026-08-19)
-- weight: 1.00=加项 -1.00=减项; calc_type: sum=加总 ratio=比率
USE cma;
INSERT INTO kpi_subject_map (kpi_id, subject_code, calc_type, weight, remark) VALUES
-- F_REVENUE 营收 = 主营业务收入 + 利息收入 + 其他业务收入 + 投资收益
(1, '6001', 'sum', 1.00, '主营业务收入'),
(1, '6011', 'sum', 0.02, '利息收入(银行存款)'),
(1, '6051', 'sum', 0.05, '其他业务收入'),
(1, '6111', 'sum', 0.03, '投资收益'),
-- F_GROSS_MARGIN 毛利率 = (收入-成本)/收入
(2, '6001', 'ratio', 1.00, '毛利率分子-收入'),
(2, '6401', 'ratio', -1.00, '毛利率分子-主营业务成本'),
(2, '6402', 'ratio', -1.00, '毛利率分子-其他业务成本'),
-- F_NET_PROFIT 净利润 = 收入 - 成本 - 费用
(3, '6001', 'sum', 1.00, '净利润-主营业务收入'),
(3, '6051', 'sum', 0.05, '净利润-其他业务收入'),
(3, '6011', 'sum', 0.02, '净利润-利息收入'),
(3, '6111', 'sum', 0.03, '净利润-投资收益'),
(3, '6401', 'sum', -1.00, '净利润-主营业务成本'),
(3, '6402', 'sum', -1.00, '净利润-其他业务成本'),
(3, '6601', 'sum', -1.00, '净利润-销售费用'),
(3, '6602', 'sum', -1.00, '净利润-管理费用'),
(3, '660204','sum', -0.30, '净利润-研发费用'),
(3, '6603', 'sum', -1.00, '净利润-财务费用'),
(3, '6701', 'sum', -1.00, '净利润-资产减值损失'),
(3, '6801', 'sum', -1.00, '净利润-所得税费用'),
-- F_OP_CFLOW 经营性现金流 = 现金类科目净变动
(4, '1001', 'sum', 1.00, '经营性现金流-库存现金'),
(4, '1002', 'sum', 1.00, '经营性现金流-银行存款'),
(4, '1122', 'sum', 1.00, '经营性现金流-应收账款收回'),
(4, '2202', 'sum', 1.00, '经营性现金流-应付账款支付'),
-- F_COST_RATIO 费用率 = 期间费用/收入
(5, '6601', 'ratio', 1.00, '费用率-销售费用'),
(5, '6602', 'ratio', 1.00, '费用率-管理费用'),
(5, '6603', 'ratio', 1.00, '费用率-财务费用'),
(5, '6001', 'ratio', -1.00, '费用率分母-收入'),
-- F_AR_DAYS 应收账款周转天数 = 应收/收入*360
(6, '1122', 'ratio', 1.00, '应收周转-应收账款'),
(6, '6001', 'ratio', -1.00, '应收周转分母-主营业务收入'),
-- F_ASSET_TURNOVER 总资产周转率 = 收入/总资产
(13, '6001', 'ratio', 1.00, '资产周转分子-收入'),
(13, '1001', 'ratio', -1.00, '资产周转分母-库存现金'),
(13, '1002', 'ratio', -1.00, '资产周转分母-银行存款'),
(13, '1122', 'ratio', -1.00, '资产周转分母-应收账款'),
(13, '1405', 'ratio', -1.00, '资产周转分母-库存商品'),
(13, '1601', 'ratio', -1.00, '资产周转分母-固定资产'),
-- F_REVENUE_GROWTH 收入增长率 = 本期收入/上期收入-1
(14, '6001', 'ratio', 1.00, '收入增长-主营业务收入'),
-- F_CURRENT_RATIO 流动比率 = 流动资产/流动负债
(17, '1001', 'ratio', 1.00, '流动比率-库存现金'),
(17, '1002', 'ratio', 1.00, '流动比率-银行存款'),
(17, '1122', 'ratio', 1.00, '流动比率-应收账款'),
(17, '1405', 'ratio', 1.00, '流动比率-库存商品'),
(17, '2001', 'ratio', -1.00, '流动比率-短期借款'),
(17, '2202', 'ratio', -1.00, '流动比率-应付账款'),
(17, '2203', 'ratio', -1.00, '流动比率-预收账款'),
(17, '2211', 'ratio', -1.00, '流动比率-应付职工薪酬'),
-- F_QUICK_RATIO 速动比率 = (流动资产-存货)/流动负债
(18, '1001', 'ratio', 1.00, '速动比率-库存现金'),
(18, '1002', 'ratio', 1.00, '速动比率-银行存款'),
(18, '1122', 'ratio', 1.00, '速动比率-应收账款'),
(18, '1405', 'ratio', -1.00, '速动比率-存货扣减'),
(18, '2202', 'ratio', -1.00, '速动比率-应付账款'),
(18, '2203', 'ratio', -1.00, '速动比率-预收账款'),
(18, '2211', 'ratio', -1.00, '速动比率-应付职工薪酬'),
-- F_INV_DAYS 存货周转天数 = 存货/成本*360
(19, '1405', 'ratio', 1.00, '存货周转-库存商品'),
(19, '6401', 'ratio', -1.00, '存货周转分母-主营业务成本'),
-- F_ROI 总资产报酬率 = 利润/资产
(20, '4103', 'ratio', 1.00, 'ROI分子-本年利润'),
(20, '4001', 'ratio', -1.00, 'ROI分母-实收资本'),
(20, '4002', 'ratio', -1.00, 'ROI分母-资本公积'),
-- F_DEBT_RATIO 资产负债率 = 负债/资产
(30, '2001', 'ratio', 1.00, '负债率-短期借款'),
(30, '2202', 'ratio', 1.00, '负债率-应付账款'),
(30, '2203', 'ratio', 1.00, '负债率-预收账款'),
(30, '2211', 'ratio', 1.00, '负债率-应付职工薪酬'),
(30, '2221', 'ratio', 1.00, '负债率-应交税费'),
(30, '2241', 'ratio', 1.00, '负债率-其他应付款'),
(30, '2501', 'ratio', 1.00, '负债率-长期借款'),
(30, '2502', 'ratio', 1.00, '负债率-应付债券'),
(30, '4001', 'ratio', -1.00, '负债率分母-实收资本'),
(30, '4002', 'ratio', -1.00, '负债率分母-资本公积'),
-- F_INTEREST_COVER 利息保障倍数 = 利润/利息支出
(31, '4103', 'ratio', 1.00, '利息保障分子-本年利润'),
(31, '660301','ratio', -1.00, '利息保障分母-利息支出(借款)'),
-- F_EVA 经济增加值 = 利润 - 资本成本
(32, '4103', 'sum', 1.00, 'EVA-本年利润'),
(32, '4001', 'sum', -0.06, 'EVA-资本成本(实收资本×6%)'),
-- F_FCF 自由现金流
(45, '1002', 'sum', 1.00, 'FCF-银行存款'),
(45, '2202', 'sum', 1.00, 'FCF-应付账款'),
-- F_OP_PROFIT_MARGIN 经营利润率 = (收入-成本-期间费用)/收入
(183, '6001', 'ratio', 1.00, '经营利润率-收入'),
(183, '6401', 'ratio', -1.00, '经营利润率-主营业务成本'),
(183, '6601', 'ratio', -1.00, '经营利润率-销售费用'),
(183, '6602', 'ratio', -1.00, '经营利润率-管理费用'),
(183, '6603', 'ratio', -1.00, '经营利润率-财务费用'),
-- F_ROE 净资产收益率 = 净利润/净资产
(44, '4103', 'ratio', 1.00, 'ROE分子-本年利润'),
(44, '4001', 'ratio', -1.00, 'ROE分母-实收资本'),
(44, '4002', 'ratio', -1.00, 'ROE分母-资本公积'),
-- P_COST_CUT 招待费砍半
(54, '6601', 'sum', 1.00, '招待费-销售费用(业务招待子目)'),
-- P_CHANNEL_NEG 渠补谈判完成率(以渠道收入为基数)
(48, '6001', 'ratio', 1.00, '渠补谈判-主营业务收入'),
(48, '6051', 'ratio', 1.00, '渠补谈判-其他业务收入'),
-- C_MARKET_SHARE 市场份额(以收入为口径)
(33, '6001', 'ratio', 1.00, '市场份额-主营业务收入');
-- objective_kpi + krs 种子数据 (2026-08-19)
USE cma;
-- ① KPI↔O支撑: 现有3个O各关联5个KPI
INSERT INTO objective_kpi (objective_id, kpi_id, weight) VALUES
-- O11 优化成本结构——渠补谈判+管理费压缩
(11, 5, 1.00), -- F_COST_RATIO 费用率
(11, 54, 1.00), -- P_COST_CUT 招待费砍半
(11, 183, 1.00), -- F_OP_PROFIT_MARGIN 经营利润率
(11, 3, 1.00), -- F_NET_PROFIT 净利润
(11, 48, 1.00), -- P_CHANNEL_NEG 渠补谈判完成率
-- O12 保障现金流安全——应收催收+厂补确认
(12, 4, 1.00), -- F_OP_CFLOW 经营性现金流
(12, 6, 1.00), -- F_AR_DAYS 应收账款周转天数
(12, 45, 1.00), -- F_FCF 自由现金流
(12, 408, 1.00), -- F_FACTORY_REBATE_RATE 上游厂补率
(12, 17, 1.00), -- F_CURRENT_RATIO 流动比率
-- O13 渠道关系改善——用数据谈判渠补
(13, 46, 1.00), -- C_REBATE_RATE 渠补率
(13, 48, 1.00), -- P_CHANNEL_NEG 渠补谈判完成率
(13, 407, 1.00), -- F_REBATE_RATE 返利率
(13, 7, 1.00), -- C_SATISFACTION 客户满意度
(13, 24, 1.00); -- C_RETENTION_RATE 客户保留率
-- ② krs: 现有3个O各配3个KR
INSERT INTO krs (objective_id, title, metric_kpi_id, target_value, current_value, progress, status, due_date) VALUES
-- O11 优化成本结构
(11, '完成渠补谈判,渠道B渠补率降至72%', 48, 100.00, 40.00, 40, 'in_progress', '2026-09-30'),
(11, '管理费用率压缩至15%以内', 5, 15.00, 18.00, 60, 'in_progress', '2026-09-30'),
(11, '经营利润率提升至8%', 183, 8.00, 5.20, 40, 'in_progress', '2026-09-30'),
-- O12 保障现金流安全
(12, '应收账款周转天数降至45天', 6, 45.00, 62.00, 30, 'in_progress', '2026-09-30'),
(12, '经营性现金流季度回正至100万元', 4, 100.00, 40.00, 35, 'in_progress', '2026-09-30'),
(12, '上游厂补率确认至90%', 408, 90.00, 60.00, 40, 'in_progress', '2026-09-30'),
-- O13 渠道关系改善
(13, '渠道渠补率降至70%以下', 46, 70.00, 78.00, 50, 'in_progress', '2026-09-30'),
(13, '完成10场数据化渠补谈判', 48, 10.00, 2.00, 20, 'in_progress', '2026-09-30'),
(13, '客户满意度提升至90分', 7, 90.00, 85.00, 33, 'in_progress', '2026-09-30');
+57
View File
@@ -0,0 +1,57 @@
# CMA本体三支柱:科目↔KPI↔OKR 三层互联 — 交付报告
> 任务:yanxue-cma-ontology-trilogy.md2026-08-19 21:00 并发任务)
> 执行:项目Botwecom-project | 完成:2026-08-19 22:56
> 优先级:P1(本体层核心补全)
## 交付内容
### 1. 数据层(3张新表 + 种子数据)
| 表 | 作用 | 数据量 | 验收 |
|:--|:--|:--|:--|
| `kpi_subject_map` | 科目↔KPI映射(指标→底层账) | **89条映射 / 24个KPI** | ≥10 ✓ |
| `objective_kpi` | KPI↔O支撑(目标→指标) | **3个O × 5个KPI** | 各≥3 ✓ |
| `krs` | 关键结果KR(OKR完整化) | **3个O × 3个KR** | 各2-3 ✓ |
- 备份:`backups/ontology-trilogy-backup-20260819.sql`subjects/kpi_definitions/objectives 全量,135KB
- 可重放脚本:`backend/scripts/seed_ontology_trilogy.sql`DDL + 种子,幂等可重跑)
### 2. API层(`backend/app/api/ontology.py`
- `GET /api/cma/ontology/trace?objective_id=N`**本体追溯链**:O(目标) → KPI(指标) → 科目(数据),含 KR 列表、KPI 支撑权重、科目 calc_type/weight
- `GET /api/cma/ontology/objectives` — 全部 OKR 三层链路概览(前端用)
### 3. 前端层(`OkrDetail.vue` + `api/index.ts`
- OKR 详情新增「本体追溯链」页签:追溯链横幅 + KR 卡片(目标/当前值/进度)+ KPI→科目 标签流(calc_type/weight),非科目驱动指标明确标注"不追溯账本"
## 追溯链实测(O→KPI→科目)
```
O#11 优化成本结构 → 5 KPI → 24 科目
费用率 → [6601销售费用, 6602管理费用, 6603财务费用, 6001收入(分母)]
净利润 → [6001+6051+6011+6111收入 - 6401-6402成本 - 6601-6602-6603费用...12科目]
O#12 保障现金流 → 5 KPI → 16 科目
O#13 渠道关系改善 → 5 KPI → 7 科目
```
从任一 O 可逐层下钻到账本;反之从科目可上溯到目标。
## 验证记录(铁律七)
1. ✅ 备份完成(mysqldump 3表 135KB
2. ✅ 每步 SQL 后 SELECT 验证(89/15/9 行确认)
3. ✅ 后端重启:`systemctl restart cma-backend`systemd,非 restart.sh 的 pkill 模式)→ active + `/health` ok
4. ✅ 追溯链 API 3个O全部跑通(含 KR + 科目映射)
5. ✅ 前端 `npm run build` 13.89s 成功,已部署 `/var/www/cma/`
6. ✅ 回归门:`pytest tests/`**409 passed, 1 xfailed, 0 failed**
- 注:首次全量跑有 13 个 test_reports 失败,二次全量 + 单文件隔离均通过 → 属既有测试排序/状态污染,与本改动无关
## 遗留说明
- 业务/调研类 KPI(客户满意度、保留率等)非科目驱动,trace 中 subjects 为空数组并标注,符合本体语义(不强行虚构账本映射)
- `models/__init__.py` 同时含并发任务"KPI多粒度"未提交列(target_monthly/quarterly/yearly2026-08-17已上线),本提交一并带入(同文件无法拆分,功能均已完成)
- 并发任务"自动化测试补覆盖"的 conftest/tests 改动未包含在本提交
## Git
- 提交:本体三支柱(科目↔KPI↔OKR)三层互联 + 追溯链API + OKR详情页签
- 推送:SSH 2222 → git.sxbh.ltd
+6
View File
@@ -95,6 +95,12 @@ export const okrApi = {
api.post(`/okr/${okrId}/decomposition/milestones/generate`, { kr_id: krId }),
}
// ── 本体三支柱追溯链 (科目↔KPI↔OKR) ──
export const ontologyApi = {
trace: (objectiveId: number) => api.get('/ontology/trace', { params: { objective_id: objectiveId } }),
objectives: (params?: any) => api.get('/ontology/objectives', { params }),
}
export const dataApi = {
importExcel: (file: File, qs?: string) => {
const form = new FormData()
+151 -3
View File
@@ -66,6 +66,59 @@
</div>
</div>
</el-tab-pane>
<el-tab-pane label="本体追溯链" name="ontology">
<div v-if="traceLoading" class="empty-tab">追溯链加载中...</div>
<div v-else-if="!trace" class="empty-tab">暂无追溯链数据</div>
<template v-else>
<div class="chain-banner">
<span class="chain-step">O 目标</span>
<span class="chain-arrow"></span>
<span class="chain-step">KPI 指标 ({{ trace.kpis.length }})</span>
<span class="chain-arrow"></span>
<span class="chain-step">科目 数据 ({{ trace.chain.subject_count }})</span>
</div>
<!-- KR(关键结果) -->
<div class="trace-section" v-if="trace.krs && trace.krs.length">
<div class="trace-section-title">关键结果 KR</div>
<div class="kr-list">
<div v-for="kr in trace.krs" :key="kr.id" class="kr-card">
<div class="kr-header">
<span class="kr-title">{{ kr.title }}</span>
<el-tag size="small" :type="statusTagType(kr.status)">{{ statusLabel(kr.status) }}</el-tag>
</div>
<div class="kr-meta">
<span v-if="kr.metric_kpi_name" class="kr-meta-item">度量KPI: {{ kr.metric_kpi_name }}</span>
<span class="kr-meta-item">目标: {{ kr.target_value }}</span>
<span class="kr-meta-item">当前: {{ kr.current_value }}</span>
<span v-if="kr.due_date" class="kr-meta-item">截止: {{ kr.due_date }}</span>
</div>
<el-progress :percentage="kr.progress || 0" :stroke-width="6" />
</div>
</div>
</div>
<!-- KPI 科目 -->
<div class="trace-section">
<div class="trace-section-title">KPI 科目指标追溯到底层账</div>
<div v-for="k in trace.kpis" :key="k.kpi_id" class="kpi-trace-card">
<div class="kpi-trace-head">
<span class="kpi-trace-code">{{ k.kpi_code }}</span>
<span class="kpi-trace-name">{{ k.kpi_name }}</span>
<el-tag size="small" :type="dimTagType(k.dimension)">{{ dimLabel(k.dimension) }}</el-tag>
<span v-if="k.unit" class="kpi-trace-unit">{{ k.unit }}</span>
</div>
<div v-if="k.subjects && k.subjects.length" class="subject-chips">
<el-tag v-for="s in k.subjects" :key="s.subject_code" size="small" effect="plain" class="subject-chip">
{{ s.subject_code }} {{ s.subject_name }}
<span class="subject-calc">({{ s.calc_type }}, {{ s.weight }})</span>
</el-tag>
</div>
<div v-else class="no-subject">非科目驱动指标业务/调研数据源不追溯账本</div>
</div>
</div>
</template>
</el-tab-pane>
<el-tab-pane label="分解视图" name="decomposition">
<OkrDecomposition :okr-id="okrId" @refresh="loadData" />
</el-tab-pane>
@@ -83,7 +136,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { okrApi } from '../api/index'
import { okrApi, ontologyApi } from '../api/index'
import OkrDecomposition from '../components/okr/OkrDecomposition.vue'
const route = useRoute()
@@ -91,6 +144,8 @@ const okrId = computed(() => Number(route.params.id))
const loading = ref(true)
const objective = ref<any>(null)
const trace = ref<any>(null)
const traceLoading = ref(false)
const activeTab = ref('krs')
const DIM_LABELS: Record<string, string> = {
@@ -103,11 +158,11 @@ function dimTagType(d: string) {
return types[d] || ''
}
function statusLabel(s: string) {
const labels: Record<string, string> = { active: '进行中', completed: '已完成', cancelled: '已取消' }
const labels: Record<string, string> = { active: '进行中', completed: '已完成', cancelled: '已取消', in_progress: '进行中', pending: '待开始' }
return labels[s] || s
}
function statusTagType(s: string) {
const types: Record<string, string> = { active: 'warning', completed: 'success', cancelled: 'danger' }
const types: Record<string, string> = { active: 'warning', completed: 'success', cancelled: 'danger', in_progress: 'warning', pending: 'info' }
return types[s] || ''
}
@@ -125,8 +180,23 @@ async function loadData() {
}
}
async function loadTrace() {
if (!okrId.value) return
traceLoading.value = true
try {
const res: any = await ontologyApi.trace(okrId.value)
trace.value = res
} catch (e: any) {
ElMessage.warning('追溯链加载失败: ' + (e?.response?.data?.detail || e?.message || ''))
trace.value = null
} finally {
traceLoading.value = false
}
}
onMounted(() => {
loadData()
loadTrace()
})
</script>
@@ -218,6 +288,84 @@ onMounted(() => {
padding: 40px 0;
font-size: 14px;
}
/* ── 本体追溯链 ── */
.chain-banner {
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(90deg, #f0f5ff, #f6ffed);
border: 1px solid #d9e8ff;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
font-size: 13px;
}
.chain-step {
font-weight: 600;
color: #303133;
}
.chain-arrow {
color: #409EFF;
font-weight: 700;
}
.trace-section {
margin-bottom: 18px;
}
.trace-section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
padding-left: 8px;
border-left: 3px solid #409EFF;
}
.kpi-trace-card {
background: #fff;
border: 1px solid #EBEEF5;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
}
.kpi-trace-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.kpi-trace-code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-weight: 700;
font-size: 13px;
color: #409EFF;
background: #ecf5ff;
padding: 2px 8px;
border-radius: 4px;
}
.kpi-trace-name {
font-weight: 500;
font-size: 14px;
}
.kpi-trace-unit {
font-size: 12px;
color: #909399;
}
.subject-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.subject-chip {
font-size: 12px;
}
.subject-calc {
color: #909399;
margin-left: 2px;
}
.no-subject {
font-size: 12px;
color: #C0C4CC;
}
.not-found {
padding: 60px 0;
}