ci/woodpecker/push/main Pipeline failed
- ① 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(发布须另取放行)
173 lines
6.8 KiB
Python
173 lines
6.8 KiB
Python
"""
|
||
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}
|