feat: 多租户隔离P2批1 — OKR域+kpi_values加entity_id列
- 模型: Objective/KR/ObjectiveKPI/KPIValue 加 entity_id - DB: 4表加列; kpi_values 1610条按kpi_id回填(1269酣客/341博海) - OKR域: 测试O删除重建; okr list/get/create + ontology trace/objectives 按token企业隔离(跨企业404) - KPIValue写入: data/bot_bridge/bot_kpis 创建时带entity_id - 验证: 酣客创建OKR博海不可见; 跨企业读404; pytest 451通过(expenses单跑37通过为既有排序flaky)
This commit is contained in:
@@ -528,7 +528,7 @@ def bot_import_excel(
|
|||||||
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
|
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
kv = KPIValue(kpi_id=kpi.id, period=period, actual_value=val,
|
kv = KPIValue(kpi_id=kpi.id, entity_id=kpi.entity_id, period=period, actual_value=val,
|
||||||
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
||||||
db.add(kv)
|
db.add(kv)
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ def update_bot_kpi_value(
|
|||||||
else:
|
else:
|
||||||
val = KPIValue(
|
val = KPIValue(
|
||||||
kpi_id=kpi_id,
|
kpi_id=kpi_id,
|
||||||
|
entity_id=kpi.entity_id if kpi else None, # 账套隔离 P2
|
||||||
period=period,
|
period=period,
|
||||||
actual_value=actual_value,
|
actual_value=actual_value,
|
||||||
source_type="manual",
|
source_type="manual",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ async def import_excel(file: UploadFile = File(...),
|
|||||||
|
|
||||||
from app.models import KPIDefinition
|
from app.models import KPIDefinition
|
||||||
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).all()}
|
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).all()}
|
||||||
|
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).all()} # 账套隔离 P2
|
||||||
|
|
||||||
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
|
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
|
||||||
count = 0
|
count = 0
|
||||||
@@ -59,6 +60,7 @@ async def import_excel(file: UploadFile = File(...),
|
|||||||
|
|
||||||
db.add(KPIValue(
|
db.add(KPIValue(
|
||||||
kpi_id=kid,
|
kpi_id=kid,
|
||||||
|
entity_id=kpi_entity_map.get(kpi_code),
|
||||||
period=period,
|
period=period,
|
||||||
actual_value=float(value),
|
actual_value=float(value),
|
||||||
source_type="excel",
|
source_type="excel",
|
||||||
@@ -298,6 +300,7 @@ async def import_excel_smart(
|
|||||||
|
|
||||||
db.add(KPIValue(
|
db.add(KPIValue(
|
||||||
kpi_id=kpis[kpi_code].id,
|
kpi_id=kpis[kpi_code].id,
|
||||||
|
entity_id=kpis[kpi_code].entity_id, # 账套隔离 P2
|
||||||
period=raw_period,
|
period=raw_period,
|
||||||
actual_value=val,
|
actual_value=val,
|
||||||
source_type="excel",
|
source_type="excel",
|
||||||
|
|||||||
+11
-8
@@ -7,8 +7,9 @@ from sqlalchemy import func
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
from app.models import Objective, ActionPlan
|
from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
|
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
|
||||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||||
@@ -21,9 +22,10 @@ def list_objectives(
|
|||||||
dimension: Optional[str] = Query(None),
|
dimension: Optional[str] = Query(None),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""列出OKR目标"""
|
"""列出OKR目标(账套隔离: 按token企业, 2026-08-23 P2)"""
|
||||||
q = db.query(Objective)
|
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||||
if quarter:
|
if quarter:
|
||||||
q = q.filter(Objective.quarter == quarter)
|
q = q.filter(Objective.quarter == quarter)
|
||||||
if dimension:
|
if dimension:
|
||||||
@@ -55,8 +57,9 @@ def list_objectives(
|
|||||||
def create_objective(
|
def create_objective(
|
||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""创建OKR目标(支持JSON Body和Query参数两种方式)"""
|
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
|
||||||
# 兼容旧版Query参数
|
# 兼容旧版Query参数
|
||||||
title = data.get("title") or ""
|
title = data.get("title") or ""
|
||||||
quarter = data.get("quarter") or ""
|
quarter = data.get("quarter") or ""
|
||||||
@@ -66,7 +69,7 @@ def create_objective(
|
|||||||
if not title or not quarter:
|
if not title or not quarter:
|
||||||
raise HTTPException(422, "缺少必填字段: title, quarter")
|
raise HTTPException(422, "缺少必填字段: title, quarter")
|
||||||
obj = Objective(title=title, quarter=quarter, description=description,
|
obj = Objective(title=title, quarter=quarter, description=description,
|
||||||
dimension=dimension, owner=owner)
|
dimension=dimension, owner=owner, entity_id=entity_id)
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(obj)
|
db.refresh(obj)
|
||||||
@@ -74,9 +77,9 @@ def create_objective(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{obj_id}")
|
@router.get("/{obj_id}")
|
||||||
def get_objective(obj_id: int, db: Session = Depends(get_db)):
|
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""获取单个OKR详情"""
|
"""获取单个OKR详情(账套隔离: 跨企业404)"""
|
||||||
obj = db.query(Objective).filter(Objective.id == obj_id).first()
|
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
|
||||||
if not obj:
|
if not obj:
|
||||||
raise HTTPException(404, "目标不存在")
|
raise HTTPException(404, "目标不存在")
|
||||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
|
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
from app.models import Objective, KPIDefinition, ObjectiveKPI, KPISubjectMap, KR, Subject
|
from app.models import Objective, KPIDefinition, ObjectiveKPI, KPISubjectMap, KR, Subject
|
||||||
|
|
||||||
@@ -27,9 +28,10 @@ def _num(v):
|
|||||||
def trace_ontology(
|
def trace_ontology(
|
||||||
objective_id: int = Query(..., description="OKR目标ID, 从O→KPI→科目逐层追溯"),
|
objective_id: int = Query(..., description="OKR目标ID, 从O→KPI→科目逐层追溯"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""本体追溯链: O(目标) → KPI(指标) → 科目(数据)"""
|
"""本体追溯链: O(目标) → KPI(指标) → 科目(数据) — 账套隔离 (2026-08-23 P2)"""
|
||||||
obj = db.query(Objective).filter(Objective.id == objective_id).first()
|
obj = db.query(Objective).filter(Objective.id == objective_id, Objective.entity_id == entity_id).first()
|
||||||
if not obj:
|
if not obj:
|
||||||
raise HTTPException(404, "目标不存在")
|
raise HTTPException(404, "目标不存在")
|
||||||
|
|
||||||
@@ -122,9 +124,10 @@ def trace_ontology(
|
|||||||
def list_ontology_objectives(
|
def list_ontology_objectives(
|
||||||
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""所有OKR目标的三层链路概览(前端OKR页用)"""
|
"""所有OKR目标的三层链路概览(前端OKR页用) — 账套隔离 (2026-08-23 P2)"""
|
||||||
q = db.query(Objective)
|
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||||
if quarter:
|
if quarter:
|
||||||
q = q.filter(Objective.quarter == quarter)
|
q = q.filter(Objective.quarter == quarter)
|
||||||
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class KPIValue(Base):
|
|||||||
"""KPI实际值"""
|
"""KPI实际值"""
|
||||||
__tablename__ = "kpi_values"
|
__tablename__ = "kpi_values"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=None, comment="企业ID (P2多租户隔离 2026-08-23, 按kpi_id回填)")
|
||||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
|
||||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||||
actual_value = Column(Float, nullable=True, comment="实际值")
|
actual_value = Column(Float, nullable=True, comment="实际值")
|
||||||
@@ -193,6 +194,7 @@ class Objective(Base):
|
|||||||
"""OKR目标"""
|
"""OKR目标"""
|
||||||
__tablename__ = "objectives"
|
__tablename__ = "objectives"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||||
title = Column(String(200), nullable=False, comment="目标标题")
|
title = Column(String(200), nullable=False, comment="目标标题")
|
||||||
description = Column(Text, nullable=True, comment="目标描述")
|
description = Column(Text, nullable=True, comment="目标描述")
|
||||||
dimension = Column(String(50), nullable=True, comment="关联维度: finance/customer/process/learning")
|
dimension = Column(String(50), nullable=True, comment="关联维度: finance/customer/process/learning")
|
||||||
@@ -703,6 +705,7 @@ class ObjectiveKPI(Base):
|
|||||||
"""KPI↔O支撑 — 目标由哪些KPI度量"""
|
"""KPI↔O支撑 — 目标由哪些KPI度量"""
|
||||||
__tablename__ = "objective_kpi"
|
__tablename__ = "objective_kpi"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
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")
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
|
||||||
weight = Column(Numeric(5, 2), default=1.00, comment="支撑权重")
|
weight = Column(Numeric(5, 2), default=1.00, comment="支撑权重")
|
||||||
@@ -713,6 +716,7 @@ class KR(Base):
|
|||||||
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
|
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
|
||||||
__tablename__ = "krs"
|
__tablename__ = "krs"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
||||||
title = Column(String(200), nullable=False, comment="KR标题")
|
title = Column(String(200), nullable=False, comment="KR标题")
|
||||||
metric_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=True, comment="度量KPI ID")
|
metric_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=True, comment="度量KPI ID")
|
||||||
|
|||||||
Reference in New Issue
Block a user