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)
This commit is contained in:
@@ -141,6 +141,7 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user
|
||||
target_type="kpi",
|
||||
target_id=kpi.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套,端点已校验=会话账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "kpi_target",
|
||||
@@ -218,6 +219,7 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
|
||||
target_type="budget",
|
||||
target_id=plan.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "budget_adjust",
|
||||
@@ -278,6 +280,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
|
||||
target_type="action_plan",
|
||||
target_id=plan.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "action_plan",
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.database import get_db, Base
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
||||
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
|
||||
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
|
||||
@@ -140,10 +141,9 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
db.refresh(rule)
|
||||
|
||||
# 日志
|
||||
db.add(OperationLog(
|
||||
action="create_alert_rule", target_type="alert_rule",
|
||||
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
|
||||
))
|
||||
log_op(db, "create_alert_rule", target_type="alert_rule", target_id=rule.id,
|
||||
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
|
||||
from app.utils.oplog import log_op
|
||||
|
||||
logger = logging.getLogger("bi-reports")
|
||||
|
||||
@@ -160,7 +161,8 @@ def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
db.add(OperationLog(action="create", target_type="bi_report", detail=f"创建报表: {r.name}"))
|
||||
log_op(db, "create", target_type="bi_report", target_id=r.id,
|
||||
detail=f"创建报表: {r.name}", entity_id=getattr(r, "entity_id", None))
|
||||
db.commit()
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.models import KPICausality
|
||||
from app.risk_levels import risk_level
|
||||
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
|
||||
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
||||
from app.utils.oplog import log_op, SCOPE_GLOBAL
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge")
|
||||
@@ -971,11 +972,11 @@ def bot_dict_zxt_account(
|
||||
before = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
|
||||
db.execute(stmt, clean)
|
||||
after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
|
||||
db.add(OperationLog(
|
||||
action="import", target_type="zxt_account_dict", target_id=None,
|
||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
|
||||
"upserted": len(clean), "before": before, "after": after, "source": source},
|
||||
))
|
||||
log_op(db, "import", target_type="zxt_account_dict", target_id=None,
|
||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
|
||||
"upserted": len(clean), "before": before, "after": after, "source": source},
|
||||
# zxt_account_dict = 智享通平台级科目字典(无 entity 列)⇒ 显式声明非账套维度
|
||||
entity_id=None, scope=SCOPE_GLOBAL)
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1263,13 +1264,12 @@ def bot_subjects_upsert(
|
||||
}, synchronize_session=False)
|
||||
db.flush()
|
||||
after = db.query(func.count(Subject.id)).scalar() or 0
|
||||
db.add(OperationLog(
|
||||
action="import", target_type="subjects", target_id=None,
|
||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
|
||||
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
|
||||
"skipped": len(to_skip), "before": before, "after": after,
|
||||
"update_existing": update_existing, "source": "json"},
|
||||
))
|
||||
log_op(db, "import", target_type="subjects", target_id=None,
|
||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
|
||||
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
|
||||
"skipped": len(to_skip), "before": before, "after": after,
|
||||
"update_existing": update_existing, "source": "json"},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -137,7 +137,8 @@ def create_budget_plan(
|
||||
action="create",
|
||||
target_type="budget",
|
||||
target_id=plan.id,
|
||||
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value}, ensure_ascii=False),
|
||||
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value,
|
||||
"entity_id": entity_id}, ensure_ascii=False), # ④ 审计带账套
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
|
||||
from app.utils.oplog import log_op, SCOPE_BATCH
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -225,6 +226,7 @@ def generate_budget_from_kpis(
|
||||
period = f"{year}-{month:02d}"
|
||||
results = []
|
||||
total_amount = 0
|
||||
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
|
||||
|
||||
for item in items:
|
||||
kpi_id = item.get("kpi_id")
|
||||
@@ -238,6 +240,7 @@ def generate_budget_from_kpis(
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
continue
|
||||
_kpi_entities.add(getattr(kpi, "entity_id", None))
|
||||
|
||||
# 检查是否已有记录
|
||||
existing = db.query(BudgetPlan).filter(
|
||||
@@ -283,20 +286,20 @@ def generate_budget_from_kpis(
|
||||
"plan_id": plan_id,
|
||||
})
|
||||
|
||||
# 操作日志
|
||||
log = OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="kpi_generate_budget",
|
||||
target_type="budget",
|
||||
detail=json.dumps({
|
||||
"year": year,
|
||||
"month": month,
|
||||
"version": version,
|
||||
"item_count": len(results),
|
||||
"total_amount": total_amount,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
# 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
|
||||
_entities = sorted(x for x in _kpi_entities if x is not None)
|
||||
log_op(db, "kpi_generate_budget", target_type="budget",
|
||||
user_id=getattr(current_user, "id", None),
|
||||
detail={
|
||||
"year": year,
|
||||
"month": month,
|
||||
"version": version,
|
||||
"item_count": len(results),
|
||||
"total_amount": total_amount,
|
||||
"kpi_entity_ids": _entities,
|
||||
},
|
||||
entity_id=(_entities[0] if len(_entities) == 1 else None),
|
||||
scope=(SCOPE_BATCH if len(_entities) > 1 else None))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.utils.oplog import log_op
|
||||
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
|
||||
|
||||
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
|
||||
@@ -365,7 +366,8 @@ def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = De
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
# 操作日志
|
||||
db.add(OperationLog(action="create_source", target_type="source", detail=source.name))
|
||||
log_op(db, "create_source", target_type="source", target_id=source.id,
|
||||
detail=source.name, entity_id=entity_id)
|
||||
db.commit()
|
||||
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
||||
|
||||
@@ -382,7 +384,8 @@ def update_source(source_id: int, data: dict, db: Session = Depends(get_db), ent
|
||||
setattr(source, key, data[key])
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
db.add(OperationLog(action="update_source", target_type="source", detail=source.name))
|
||||
log_op(db, "update_source", target_type="source", target_id=source.id,
|
||||
detail=source.name, entity_id=entity_id)
|
||||
db.commit()
|
||||
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
||||
|
||||
@@ -394,7 +397,8 @@ def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int
|
||||
).first()
|
||||
if not source:
|
||||
raise HTTPException(404, "数据源不存在")
|
||||
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
|
||||
log_op(db, "delete_source", target_type="source", target_id=source.id,
|
||||
detail=source.name, entity_id=entity_id)
|
||||
db.delete(source)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
@@ -203,6 +203,7 @@ def push_deviation_to_map(
|
||||
target_type="map",
|
||||
target_id=map_id,
|
||||
detail=json.dumps({
|
||||
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||
"node_id": node_id,
|
||||
"deviation_id": deviation_id,
|
||||
"kpi_code": kpi_code,
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import SystemConfig, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
|
||||
router = APIRouter(
|
||||
@@ -126,6 +127,7 @@ def set_driver_mode(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
|
||||
mode = data.get("mode", "subject")
|
||||
@@ -145,13 +147,11 @@ def set_driver_mode(
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
|
||||
log = OperationLog(
|
||||
action="update",
|
||||
target_type="budget",
|
||||
target_id=0,
|
||||
detail=json.dumps({"mode": mode, "action": "切换编制模式"}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
# 2026-09-23 ④:审计带账套(会话账套)+ 显式标注该键当前仍是**全局键**
|
||||
#(budget_driver_mode 拆账套 = 另单排期项,见 dama-entity-isolation-sweep-20260923 §二之三)
|
||||
log_op(db, "update", target_type="budget", target_id=0,
|
||||
detail={"mode": mode, "action": "切换编制模式", "entity_scope": "global_key"},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
|
||||
@@ -335,6 +335,7 @@ def driver_calculate(
|
||||
target_type="budget",
|
||||
target_id=budget_record.id,
|
||||
detail=json.dumps({
|
||||
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||
"name": name,
|
||||
"formula": formula_text,
|
||||
"factors": complete_factors,
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
from app.services.causality_verification import (
|
||||
ALL_STATUSES,
|
||||
ALL_SOURCE_TYPES,
|
||||
@@ -356,8 +357,9 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
db.add(OperationLog(action="create", target_type="kpi_causality",
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})"))
|
||||
log_op(db, "create", target_type="kpi_causality", target_id=c.id,
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})",
|
||||
entity_id=c.entity_id)
|
||||
db.commit()
|
||||
return _to_dict(c)
|
||||
|
||||
@@ -407,8 +409,9 @@ def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db
|
||||
c.verify_status = target_status
|
||||
c.verified_at = datetime.now()
|
||||
c.verified_by = str(verified_by)[:50]
|
||||
db.add(OperationLog(action="verify", target_type="kpi_causality",
|
||||
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
|
||||
log_op(db, "verify", target_type="kpi_causality", target_id=causality_id,
|
||||
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})",
|
||||
entity_id=c.entity_id)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
d = _to_dict(c)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
|
||||
from app.utils.oplog import log_op
|
||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||
from app.utils.threshold_parser import (
|
||||
calc_level as _tp_calc_level,
|
||||
@@ -794,7 +795,7 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
_log(db, 1, "create", "kpi", kpi.id, data)
|
||||
_log(db, 1, "create", "kpi", kpi.id, data, entity_id=entity_id)
|
||||
return kpi_to_dict(kpi)
|
||||
|
||||
|
||||
@@ -834,7 +835,7 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
|
||||
if hasattr(kpi, k) and v is not None:
|
||||
setattr(kpi, k, v)
|
||||
db.commit()
|
||||
_log(db, 1, "update", "kpi", kpi_id, data)
|
||||
_log(db, 1, "update", "kpi", kpi_id, data, entity_id=entity_id)
|
||||
return kpi_to_dict(kpi)
|
||||
|
||||
|
||||
@@ -907,14 +908,15 @@ def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), us
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
kpi.map_id = map_id
|
||||
db.commit()
|
||||
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id})
|
||||
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id},
|
||||
entity_id=entity_id)
|
||||
return kpi_to_dict(kpi)
|
||||
|
||||
|
||||
def _log(db, user_id, action, target_type, target_id, detail):
|
||||
log = OperationLog(user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
def _log(db, user_id, action, target_type, target_id, detail, entity_id=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, commit=True)
|
||||
|
||||
|
||||
@router.get("/{kpi_id}/objectives")
|
||||
|
||||
@@ -2971,6 +2971,7 @@ def generate_report(
|
||||
target_type="report",
|
||||
target_id=record.id,
|
||||
detail=json.dumps({
|
||||
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||
"report_type": req.report_type,
|
||||
"period": period,
|
||||
"trigger_type": req.trigger_type,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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模板库"],
|
||||
@@ -75,7 +76,9 @@ def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name})
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -174,13 +177,12 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
|
||||
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})
|
||||
_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):
|
||||
import json
|
||||
log = OperationLog(user_id=user_id, action=action, target_type=target_type,
|
||||
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user