Files
cma-management/backend/app/api/templates.py
T
wecom-fullstack 467472311d feat(cma): audit write-side entity closure + entity-dimension data dictionary
task dama-entity-isolation-sweep-20260923 (sections 2-2 4/5, merged by projectBot 2026-09-23 18:2x)

4) operation_logs write side: every audit write carries the account (no DDL, no column added)

   - new app/utils/oplog.py::log_op() single wrapper: sets detail.entity_id; scope=global for

     by-design non-entity writes (platform dict / role-level); scope=batch + OPLOG_BATCH_NO_ENTITY

     sentinel for cross-account batches; no context -> OPLOG_NO_ENTITY sentinel (never guess)

   - 16 remaining construction points converted (data/alert_rules/bi_reports/kpi_causality/

     bot_bridge x2/budget_generate/driver_budget/kpis _log x3/templates _log x2/erp_sync);

     pre-4 state had 20 sites without entity_id, now 0 outside the wrapper

   - read side untouched (knowledge_service still user_id fallback); history not backfilled

5) docs/entity-dimension-data-dictionary.md: 8-table three-state registry

   (kpi_templates/driver_factor_templates/scenario_suggestions/bot_bridge_config = non-entity;

    kpi_subject_map = inherited via kpi_id; analysis_results/subjects = account dimension,

    legacy rows still NULL -> other task; operation_logs = non-entity column + detail.entity_id)

Evidence: RED 2 failed/7 passed (reverted call sites) -> GREEN full 1428 passed; 9 new tests

including two read-back rows; app md5 restore verified identical; no cross-account writes

LOCAL commit only - push is gated (ahead contains the P1 entity-dim batch commit fb4609b)
2026-09-23 23:08:26 +08:00

189 lines
7.7 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.
"""KPI模板库 API — 管理会计OS
支持系统预置模板 + 用户自定义模板
从模板实例化创建KPI时,复制模板快照到kpi_definitions"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import KPITemplate, KPIDefinition, OperationLog
from app.utils.oplog import log_op, SCOPE_GLOBAL
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
def template_to_dict(t):
return {c.name: getattr(t, c.name) for c in t.__table__.columns}
@router.get("")
def list_templates(
dimension: Optional[str] = None,
category: Optional[str] = None,
keyword: Optional[str] = None,
is_system: Optional[int] = None,
db: Session = Depends(get_db),
):
"""获取模板列表,支持按维度/类别/关键字筛选"""
query = db.query(KPITemplate)
if dimension:
query = query.filter(KPITemplate.dimension == dimension)
if category:
query = query.filter(KPITemplate.category == category)
if keyword:
query = query.filter(KPITemplate.kpi_name.contains(keyword))
if is_system is not None:
query = query.filter(KPITemplate.is_system == is_system)
templates = query.order_by(KPITemplate.is_system.desc(), KPITemplate.kpi_code).all()
return {"total": len(templates), "data": [template_to_dict(t) for t in templates]}
@router.get("/{template_id}")
def get_template(template_id: int, db: Session = Depends(get_db)):
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
return template_to_dict(t)
@router.post("")
def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""用户创建自定义模板"""
existing = db.query(KPITemplate).filter(KPITemplate.kpi_code == data.get("kpi_code", "")).first()
if existing:
raise HTTPException(400, f"模板编码 {data['kpi_code']} 已存在")
t = KPITemplate(
kpi_code=data.get("kpi_code"),
kpi_name=data.get("kpi_name"),
dimension=data.get("dimension"),
category=data.get("category"),
formula=data.get("formula"),
formula_desc=data.get("formula_desc"),
unit=data.get("unit", "%"),
target_value=data.get("target_value"),
description=data.get("description"),
is_system=0, # 用户创建的永远不是系统模板
usage_count=0,
)
db.add(t)
db.commit()
db.refresh(t)
# kpi_templates = 系统预置/用户自定义**模板库**(非账套维度,裁定见 docs/entity-dimension-data-dictionary.md)
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name},
scope=SCOPE_GLOBAL)
return template_to_dict(t)
@router.put("/{template_id}")
def update_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""修改自定义模板(系统预置不可修改)"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
if t.is_system:
raise HTTPException(403, "系统预置模板不可修改")
for k, v in data.items():
if hasattr(t, k) and v is not None:
setattr(t, k, v)
db.commit()
return template_to_dict(t)
@router.delete("/{template_id}")
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
"""删除自定义模板(系统预置不可删除)"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
if t.is_system:
raise HTTPException(403, "系统预置模板不可删除")
db.delete(t)
db.commit()
return {"message": "模板已删除"}
@router.post("/{template_id}/instantiate")
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db),
user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
"""从模板实例化创建KPI,复制模板快照到kpi_definitions
账套维度(2026-09-23 账套维度收口批 ①):新 KPI 显式挂**会话账套**,不做静默兜底。
历史缺陷:本构造点未传 `entity_id` ⇒ 模型 `default=1` 把任意账套实例化出的 KPI
静默挂到酣客(e1),与「建预算未写账套」同源。
判重口径:`kpi_code` 唯一性按账套判定(库内 `uk_entity_kpi(UNIQUE entity_id,kpi_code)`);
全表判重会让博海无法建与酣客同码的 KPI(反之则放过同账套重复)。
"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
kpi_code = data.get("kpi_code", t.kpi_code)
kpi_name = data.get("kpi_name", t.kpi_name)
# 检查编码唯一性(按账套收紧:仅本账套内判重)
existing = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if existing:
raise HTTPException(400, f"KPI编码 {kpi_code} 在本账套已存在,请修改")
# 数据治理:入库必检(规则1) + 编码规范(规则3)
merged = {
"kpi_code": kpi_code,
"kpi_name": kpi_name,
"dimension": data.get("dimension", t.dimension),
"category": data.get("category", t.category),
"formula": data.get("formula", t.formula),
"unit": data.get("unit", t.unit or "%"),
"target_value": data.get("target_value", t.target_value),
}
gov_issues = [i for i in validate_kpi_payload(merged, db=db) if i["rule"] in (1, 3)]
if gov_issues:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
kpi = KPIDefinition(
entity_id=entity_id, # 账套维度:显式挂会话账套(不得依赖模型 default=1)
template_id=t.id,
is_system=0, # 从模板实例化的KPI不是系统预置
kpi_code=kpi_code,
kpi_name=kpi_name,
dimension=data.get("dimension", t.dimension),
category=data.get("category", t.category),
formula=data.get("formula", t.formula),
formula_desc=data.get("formula_desc", t.formula_desc),
unit=data.get("unit", t.unit or "%"),
target_value=data.get("target_value", t.target_value),
objective=data.get("objective"),
data_source_type=data.get("data_source_type", "manual"),
frequency=data.get("frequency", "monthly"),
responsible_dept=data.get("responsible_dept"),
responsible_user=data.get("responsible_user"),
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
# 更新模板使用计数
t.usage_count = (t.usage_count or 0) + 1
db.commit()
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code},
entity_id=entity_id)
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None, scope=None):
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
return log_op(db, action, target_type=target_type, target_id=target_id,
detail=detail, user_id=user_id, entity_id=entity_id, scope=scope, commit=True)