Files
cma-management/backend/app/api/ontology.py
T
Hermes CI Fix fb4609be14
ci/woodpecker/push/main Pipeline failed
feat(entity-dim): 账套维度收口批 — subjects/analysis_results 加列 + 分析结论账套化 + 模板实例化构造点补账套
- ① kpi_templates instantiate 构造点显式传会话账套(原缺 entity_id ⇒ 静默落 e1),kpi_code 判重按账套收紧
- ② kpi_subject_map 3 个读取点加护栏注释(不加列、不改语义)
- ③ subjects 分层:读口 union(NULL=全局标准准则科目 / N=账套私有),UPSERT 唯一键改 (entity_id, subject_code)
- ④ analysis_results 账套化:读/写/删三端点按会话账套判定归属(列未落地时 kpi_code JOIN 止血),
     无归属行进 unattributed 登记、删除一律 404
- 模型层按现网 DDL 补齐:KPIDefinition(template_id/is_system + uk_entity_kpi)、AnalysisResult.entity_id、Subject.entity_id
- 新增 migrate_{subjects,analysis_results}_entity_id_20260923.py(--check/--apply/--down)、
  22 条对账用例 test_entity_dimension_convergence_20260923.py

DDL 已由 ops 受控通道落地(subjects 21:31:56 / analysis_results 21:31:59 CST,审批 1e9b4d9a/5ffb1640)
本提交为切片提交,未 push(发布须另取放行)
2026-09-23 21:39:24 +08:00

173 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.deps import get_entity_id
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),
entity_id: int = Depends(get_entity_id),
):
"""本体追溯链: O(目标) → KPI(指标) → 科目(数据) — 账套隔离 (2026-08-23 P2)"""
obj = db.query(Objective).filter(Objective.id == objective_id, Objective.entity_id == entity_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)
# ⚠️ 护栏(2026-09-23 账套维度收口批 ②,零结构改动、零读取逻辑改动):
# 该表是唯一一张「语义上必然账套相关、却无账套列」的表 —— **账套维度经 `kpi_id` 继承**
# (`kpi_definitions.entity_id`,本函数先按 `Objective.entity_id` 过滤目标、再按 kpi_id 取映射)。
# **禁止新增按 `subject_code` 反查的读口,禁止按 `subject_code` 批量写的入口**:
# 科目码在标准准则体系里两账套同义(同一 code 可属不同账套),按科目反查/批量写立刻跨账套。
subject_by_code = {
s.subject_code: s.subject_name
for s in db.query(Subject).filter(
Subject.is_active == 1,
# 账套分层(2026-09-23 subjects 加 entity_id 列):全局标准科目 ∪ 本账套私有明细
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
).all()
}
kpi_layer = []
for link, kpi in links:
# kpi_id 已由上方 Objective.entity_id 过滤链继承账套维度(见护栏注释),故此处不再按科目口径过滤
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,
"operator": kr.operator or ">=",
"weight": _num(kr.weight),
"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),
entity_id: int = Depends(get_entity_id),
):
"""所有OKR目标的三层链路概览(前端OKR页用) — 账套隔离 (2026-08-23 P2)"""
q = db.query(Objective).filter(Objective.entity_id == entity_id)
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}