Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4f58aae1 | ||
|
|
2c2dbc2b2d | ||
|
|
30fa745135 | ||
|
|
0c2e57a4cf | ||
|
|
e89629228e | ||
|
|
f88ed94c15 | ||
|
|
27ea7e7f53 | ||
|
|
b4f94a12b3 | ||
|
|
adfc3ac845 | ||
|
|
42ff836309 | ||
|
|
a0762b367f | ||
|
|
8c5853f64b | ||
|
|
5a99ed87e0 | ||
|
|
3beec14d96 | ||
|
|
d785a22efd | ||
|
|
4225332aaf | ||
|
|
557771a803 | ||
|
|
9cb960da25 | ||
|
|
d2984857a2 | ||
|
|
d03e322a81 | ||
|
|
b072602191 | ||
|
|
bab6137aa2 | ||
|
|
50baa16f09 | ||
|
|
9fb9d14a58 | ||
|
|
14ab6d211c | ||
|
|
ad953ac759 | ||
|
|
b9c80f836e | ||
|
|
a2fedd892a | ||
|
|
d33b5e1ced | ||
|
|
df2be301b3 |
+261
-32
@@ -1,7 +1,8 @@
|
||||
"""改善行动计划 API — 管理会计OS"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import re
|
||||
import logging
|
||||
@@ -14,6 +15,9 @@ from app.api.verify import build_auto_verify_rule
|
||||
|
||||
logger = logging.getLogger("cma.action_plans")
|
||||
|
||||
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
|
||||
_UNSET = object()
|
||||
|
||||
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
@@ -59,6 +63,32 @@ def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
|
||||
f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter)
|
||||
# ──────────────────────────────────────────────
|
||||
# 口径:overdue = status in (pending, in_progress) AND due_date < now
|
||||
#
|
||||
# 为什么要有这个函数(缺陷根因):status 的取值域只有
|
||||
# pending / in_progress / completed / cancelled —— **没有** overdue 这个值。
|
||||
# 前端把统计卡片的 key('overdue')直接当 status 传给列表端点
|
||||
# (frontend/src/views/ActionPlanLibrary.vue:414 卡片 key、:26 filterStatus、:422 params.status),
|
||||
# 而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 → 恒 0 条,
|
||||
# 于是出现「卡片 7 条 ≠ 列表 0 条」。
|
||||
# 统计端点 plan_stats 早就用的是派生口径 —— 两处口径分家正是本次不一致的来源。
|
||||
# 故把派生口径抽成本函数,plan_stats 与 list_plans **共用同一实现**(同源),
|
||||
# 今后口径只此一处,不再出现「卡片 ≠ 列表」。
|
||||
OVERDUE_KEY = "overdue"
|
||||
OVERDUE_STATUSES = ("pending", "in_progress")
|
||||
|
||||
|
||||
def _apply_overdue(query):
|
||||
"""把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。"""
|
||||
return query.filter(
|
||||
ActionPlan.status.in_(list(OVERDUE_STATUSES)),
|
||||
ActionPlan.due_date < datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
def plan_to_dict(p: ActionPlan) -> dict:
|
||||
return {
|
||||
"id": p.id,
|
||||
@@ -97,7 +127,12 @@ def list_plans(
|
||||
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)"""
|
||||
query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc())
|
||||
|
||||
if status:
|
||||
if status == OVERDUE_KEY:
|
||||
# 「已逾期」是**派生条件**而非 status 值(口径与 /stats 同源,2026-09-21)
|
||||
# 前端统计卡片直接把 key='overdue' 当 status 传来,故此处按派生口径过滤,
|
||||
# 与 plan_stats 共用 _apply_overdue,保证「卡片条数 = 列表条数」。
|
||||
query = _apply_overdue(query)
|
||||
elif status:
|
||||
query = query.filter(ActionPlan.status == status)
|
||||
if kpi_id:
|
||||
query = query.filter(ActionPlan.kpi_id == kpi_id)
|
||||
@@ -124,7 +159,10 @@ def list_plans(
|
||||
item["kpi_dimension"] = kpi.dimension if kpi else None
|
||||
# 附带KR信息(行动挂KR 2026-08-27)
|
||||
if p.kr_id is not None:
|
||||
kr = db.query(KR).filter(KR.id == p.kr_id).first()
|
||||
# 账套隔离(2026-09-18 batch3 读侧同族残余):kr_id 可能指向对端账套 KR(存量脏行,
|
||||
# 写入侧 create_plan/update_plan 已校验归属但旧行仍存在)→ 富化查询必须带账套,
|
||||
# 否则列表跨账套回显对端 KR 标题
|
||||
kr = db.query(KR).filter(KR.id == p.kr_id, KR.entity_id == entity_id).first()
|
||||
item["kr_title"] = kr.title if kr else None
|
||||
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
|
||||
else:
|
||||
@@ -164,14 +202,25 @@ def create_plan(
|
||||
|
||||
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
|
||||
|
||||
# 校验截止日期与关联Objective的季度匹配
|
||||
# 账套隔离: 关联Objective必须属于当前企业(2026-09-18 batch3 派生单)
|
||||
# 旧实现只按 Objective.id 查询(仅用于季度校验),不校验 entity_id,
|
||||
# 随后 ActionPlan(objective_id=...) 直接落库 → 本账套计划可挂到对端账套目标
|
||||
objective_id = data.get("objective_id")
|
||||
if objective_id and due_date:
|
||||
obj = db.query(Objective).filter(Objective.id == objective_id).first()
|
||||
if obj and obj.quarter:
|
||||
if objective_id:
|
||||
obj = db.query(Objective).filter(
|
||||
Objective.id == objective_id, Objective.entity_id == entity_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "关联目标不存在")
|
||||
# 校验截止日期与关联Objective的季度匹配
|
||||
if due_date and obj.quarter:
|
||||
_validate_due_date_against_quarter(due_date, obj.quarter)
|
||||
|
||||
plan = ActionPlan(
|
||||
# 账套隔离(2026-09-18 batch3 派生单):必须显式写会话账套 ——
|
||||
# 旧实现不写 entity_id,落模型默认值 1(e2 会话创建的计划库内 entity_id=1,
|
||||
# 实测 OBS1)。list/stats/update/delete 靠 join KPIDefinition 判账套,
|
||||
# 但任何按 ActionPlan.entity_id 过滤的读路径(如 OKR 分解视图)会失效。
|
||||
entity_id=entity_id,
|
||||
alert_id=data.get("alert_id"),
|
||||
kpi_id=data["kpi_id"],
|
||||
objective_id=objective_id,
|
||||
@@ -241,9 +290,11 @@ def update_plan(
|
||||
plan_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""更新行动计划"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
|
||||
"""更新行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
|
||||
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
|
||||
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
|
||||
@@ -255,21 +306,35 @@ def update_plan(
|
||||
plan.assignee = data["assignee"]
|
||||
if "priority" in data:
|
||||
plan.priority = data["priority"]
|
||||
if "kr_id" in data:
|
||||
# 行动挂KR: 支持置空(null)或改挂
|
||||
if data["kr_id"] is None:
|
||||
plan.kr_id = None
|
||||
else:
|
||||
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
plan.kr_id = kr_ent.id
|
||||
if data.get("objective_id") is not None:
|
||||
plan.objective_id = data["objective_id"]
|
||||
elif plan.objective_id is None:
|
||||
plan.objective_id = kr_ent.objective_id
|
||||
# 目标/KR 归属口径(2026-09-18 batch3 派生单):
|
||||
# ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
|
||||
# ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
|
||||
# ③ 未传 objective_id 且计划已有目标 → 保持不动
|
||||
# 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
|
||||
# 保证 404 请求对计划零改动(旧实现无归属校验,且末尾无条件覆盖推导值)
|
||||
kr_ent = _UNSET
|
||||
if "kr_id" in data and data["kr_id"] is not None:
|
||||
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan 既有写法;2026-09-18 batch3)
|
||||
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
|
||||
objective_change = _UNSET
|
||||
if "objective_id" in data:
|
||||
plan.objective_id = data["objective_id"]
|
||||
objective_change = data["objective_id"] # 口径①
|
||||
elif kr_ent is not _UNSET and plan.objective_id is None:
|
||||
objective_change = kr_ent.objective_id # 口径②
|
||||
if objective_change is not _UNSET and objective_change is not None:
|
||||
obj_ent = db.query(Objective).filter(
|
||||
Objective.id == objective_change, Objective.entity_id == entity_id).first()
|
||||
if not obj_ent:
|
||||
raise HTTPException(404, "关联目标不存在")
|
||||
|
||||
# 校验全部通过后落值(口径③:objective_change 仍为 _UNSET 时目标保持不动)
|
||||
if "kr_id" in data:
|
||||
plan.kr_id = kr_ent.id if kr_ent is not _UNSET else None
|
||||
if objective_change is not _UNSET:
|
||||
plan.objective_id = objective_change
|
||||
if "due_date" in data:
|
||||
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
if "status" in data:
|
||||
@@ -285,9 +350,10 @@ def update_plan(
|
||||
|
||||
|
||||
@router.delete("/{plan_id}")
|
||||
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""删除行动计划"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
|
||||
def delete_plan(plan_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""删除行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
|
||||
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
|
||||
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
db.delete(plan)
|
||||
@@ -296,9 +362,19 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth)):
|
||||
"""行动计划统计"""
|
||||
query = db.query(ActionPlan)
|
||||
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""行动计划统计(账套隔离: join KPI 按企业过滤, 2026-09-18 batch3)
|
||||
|
||||
口径说明(为何改):本端点原为全局统计(仅 business 角色按 assignee 过滤)→ 非 e1 账套
|
||||
用户看到的是含其它账套计划的合计,与同文件列表端点 list_plans(join KPIDefinition 按账套)
|
||||
的可见集不一致。现按同文件既有先例(update_plan/delete_plan 的 join(KPIDefinition))过滤,
|
||||
保证「统计数 = 列表可见行数」。数据侧存在 action_plans 行 entity_id 与 KPI 归属不一致的
|
||||
存量行(batch2 报告 §六.2),统一以 KPI 归属为准与列表口径一致。
|
||||
"""
|
||||
query = (db.query(ActionPlan)
|
||||
.join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id))
|
||||
if current_user.role == "business":
|
||||
query = query.filter(
|
||||
(ActionPlan.assignee == current_user.username) |
|
||||
@@ -308,8 +384,7 @@ def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(requi
|
||||
pending = query.filter(ActionPlan.status == "pending").count()
|
||||
in_progress = query.filter(ActionPlan.status == "in_progress").count()
|
||||
completed = query.filter(ActionPlan.status == "completed").count()
|
||||
from datetime import datetime
|
||||
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count()
|
||||
overdue = _apply_overdue(query).count()
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
@@ -446,3 +521,157 @@ def get_coso_checklist(
|
||||
if not data:
|
||||
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/coso-generate-actions")
|
||||
def generate_coso_actions(
|
||||
request: Request,
|
||||
data: Optional[dict] = Body(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""COSO内控自检 → 生成改善行动(把自检「未通过」条目一键转为行动计划)
|
||||
|
||||
口径说明:
|
||||
- 账套(多租户铁律):写入 entity_id 只取 token 绑定账套(get_entity_id),
|
||||
body 里若含 entity_id 字段一律忽略;
|
||||
- 数据源:与 get_coso_checklist 同口径(resolve_session_slug 按会话账套取同一份
|
||||
自检)——屏幕上看到哪份自检,就用哪份生成,不做跨账套内容搬运;
|
||||
- 关联 KPI:action_plans.kpi_id 是 NOT NULL 且列表按 KPI 归属做账套 join,
|
||||
故必须挂本账套 KPI;
|
||||
- 幂等:判重键 (entity_id, title),已存在的计入 skipped 不再 INSERT;
|
||||
- auto_verify_rule 显式不生成:COSO 条目没有可复算的 KPI 判据。
|
||||
"""
|
||||
data = data or {}
|
||||
|
||||
# ① 取自检数据(与只读端点同口径解析 slug)
|
||||
slug = resolve_session_slug(request, "hanke")
|
||||
coso = COSO_CHECKLIST_DATA.get(slug)
|
||||
if not coso:
|
||||
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
|
||||
|
||||
# ② 待生成条目:passed 为真的一律不生成
|
||||
failed_items = []
|
||||
for el in coso["elements"]:
|
||||
for item in el.get("items") or []:
|
||||
if not item.get("passed"):
|
||||
failed_items.append((el, item))
|
||||
|
||||
# ③ 无未通过条目 → 返回空结果结构(不算错误)
|
||||
if not failed_items:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_slug": slug,
|
||||
"kpi_id": None,
|
||||
"kpi_name": None,
|
||||
"failed_total": 0,
|
||||
"created": 0,
|
||||
"skipped": 0,
|
||||
"created_ids": [],
|
||||
"source": "coso",
|
||||
}
|
||||
|
||||
# ④ 关联 KPI(必须,kpi_id NOT NULL 且列表按 KPI 归属做账套 join)
|
||||
if data.get("kpi_id"):
|
||||
# 显式指定:必须属于当前账套(跨账套 KPI 一律 404,不得跨账套写)
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == data["kpi_id"],
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "关联KPI不存在")
|
||||
else:
|
||||
# 自动选取(判据显式、可解释):
|
||||
# a. 名称/编码含关键词(按序:内控/合规/COSO/control/compliance),id 最小者优先
|
||||
# b. 都没有 → 本账套 id 最小的 KPI(确定性兜底)
|
||||
# c. 本账套一个 KPI 都没有 → 400(无法满足 kpi_id NOT NULL)
|
||||
kpi = None
|
||||
for kw in ("内控", "合规", "COSO", "control", "compliance"):
|
||||
kpi = (db.query(KPIDefinition)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.filter(or_(
|
||||
KPIDefinition.kpi_name.like(f"%{kw}%"),
|
||||
KPIDefinition.kpi_code.like(f"%{kw}%"),
|
||||
))
|
||||
.order_by(KPIDefinition.id)
|
||||
.first())
|
||||
if kpi:
|
||||
break
|
||||
if not kpi:
|
||||
kpi = (db.query(KPIDefinition)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.order_by(KPIDefinition.id)
|
||||
.first())
|
||||
if not kpi:
|
||||
raise HTTPException(400, "当前账套没有可关联的KPI,无法生成改善行动")
|
||||
|
||||
# ⑤ 截止日期:body 显式传则校验格式且不得早于今天;否则默认 +30 天
|
||||
due_date = None
|
||||
if data.get("due_date"):
|
||||
try:
|
||||
due_date = datetime.strptime(str(data["due_date"]), "%Y-%m-%d")
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, f"due_date 格式非法: {data['due_date']}(应为 YYYY-MM-DD)")
|
||||
if due_date.date() < datetime.now().date():
|
||||
raise HTTPException(400, "due_date 不能早于今天")
|
||||
else:
|
||||
due_date = datetime.now() + timedelta(days=30)
|
||||
|
||||
# ⑥ 幂等判重键 (entity_id, title):带账套过滤 —— 对端账套同标题行不抑制本账套创建
|
||||
existing_titles = {
|
||||
p.title for p in db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).all()
|
||||
}
|
||||
|
||||
created_plans = []
|
||||
skipped_count = 0
|
||||
for el, item in failed_items:
|
||||
title = f"[COSO] {el['name']}·{item['text']}"[:200] # title 列宽 String(200)
|
||||
if title in existing_titles:
|
||||
skipped_count += 1
|
||||
continue
|
||||
existing_titles.add(title) # 本批次内自去重
|
||||
score = el.get("score") or 0
|
||||
if score < 40:
|
||||
priority = "high"
|
||||
elif score < 60:
|
||||
priority = "medium"
|
||||
else:
|
||||
priority = "low"
|
||||
plan = ActionPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi.id,
|
||||
title=title,
|
||||
# 来源标注便于回溯(自检哪条条目生成了这条行动)
|
||||
description=(
|
||||
f"来源: COSO内控自检 / {el['name']}({el['id']})/ 条目 {item['id']}: {item['text']}\n"
|
||||
f"自检结论: {item.get('detail') or '未通过'}"
|
||||
),
|
||||
status="pending",
|
||||
progress=0,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
created_by=current_user.name or current_user.username,
|
||||
# 不传 auto_verify_rule:该列是 MySQL json 列,显式传 None 会被落成 JSON null
|
||||
# (JSON_TYPE()='NULL'、IS NOT NULL 为真)而不是 SQL NULL,会让 bot_iron_law 的
|
||||
# auto_verify_rule.isnot(None) 把「没有规则」的行算成「有规则」。不赋值则 INSERT
|
||||
# 省略该列 → 保持 SQL NULL。COSO 条目没有可复算判据,引擎对空规则是跳过(安全)。
|
||||
)
|
||||
db.add(plan)
|
||||
created_plans.append(plan)
|
||||
|
||||
# ⑦ 单次 add 循环 + 一次 commit:任一异常向上抛,不留半批
|
||||
db.commit()
|
||||
created_ids = [p.id for p in created_plans]
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_slug": slug,
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"failed_total": len(failed_items), # 自检未通过条目总数
|
||||
"created": len(created_ids), # 本次新建条数
|
||||
"skipped": skipped_count, # 已存在(幂等命中)条数
|
||||
"created_ids": created_ids, # 新建行 id 列表
|
||||
"source": "coso",
|
||||
}
|
||||
|
||||
@@ -98,9 +98,13 @@ def list_suggestions(
|
||||
|
||||
|
||||
@router.get("/{suggestion_id}")
|
||||
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db)):
|
||||
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""建议详情"""
|
||||
s = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
|
||||
s = db.query(AISuggestion).filter(
|
||||
AISuggestion.id == suggestion_id,
|
||||
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
|
||||
).first()
|
||||
if not s:
|
||||
raise HTTPException(404, "建议不存在")
|
||||
return {"data": _sug_dict(s)}
|
||||
@@ -114,7 +118,10 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user
|
||||
kpi_id = params.get("kpi_id") or sug.target_id
|
||||
if not kpi_id:
|
||||
raise HTTPException(400, "缺少 kpi_id")
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||
before = kpi.target_value
|
||||
@@ -153,7 +160,10 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
|
||||
kpi_id = params.get("kpi_id") or sug.target_id
|
||||
if not kpi_id:
|
||||
raise HTTPException(400, "缺少 kpi_id")
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||
|
||||
@@ -226,7 +236,10 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
|
||||
kpi_id = params.get("kpi_id") or sug.target_id
|
||||
if not kpi_id:
|
||||
raise HTTPException(400, "缺少 kpi_id")
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单 2030b: 目标KPI须归属建议账套
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||
|
||||
@@ -238,6 +251,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
|
||||
due_date = None
|
||||
|
||||
plan = ActionPlan(
|
||||
entity_id=sug.entity_id, # 2026-09-18 派生单: 写建议归属账套(=会话账套,端点已校验)
|
||||
kpi_id=kpi_id,
|
||||
title=title,
|
||||
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
|
||||
@@ -290,14 +304,21 @@ def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db),
|
||||
- budget_adjust:{kpi_name, period, current_budget, new_budget}
|
||||
- action_plan: {kpi_name, plan_title, assignee, priority, due_date}
|
||||
"""
|
||||
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
|
||||
sug = db.query(AISuggestion).filter(
|
||||
AISuggestion.id == suggestion_id,
|
||||
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 预览须归属会话账套(原依赖未用于过滤)
|
||||
).first()
|
||||
if not sug:
|
||||
raise HTTPException(404, "建议不存在")
|
||||
sd = sug.suggestion_data or {}
|
||||
kpi = None
|
||||
kpi_id = sd.get("kpi_id") or sug.target_id
|
||||
if kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
# 2026-09-18 派生单 2030b: 目标KPI须归属建议账套;对端KPI降级为 KPI#<id>(不回显名/目标值)
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == sug.entity_id,
|
||||
).first()
|
||||
|
||||
if sug.suggestion_type == "kpi_target":
|
||||
return {"data": {
|
||||
@@ -340,6 +361,7 @@ def apply_suggestion(
|
||||
suggestion_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
|
||||
@@ -349,7 +371,10 @@ def apply_suggestion(
|
||||
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
|
||||
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
|
||||
"""
|
||||
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
|
||||
sug = db.query(AISuggestion).filter(
|
||||
AISuggestion.id == suggestion_id,
|
||||
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
|
||||
).first()
|
||||
if not sug:
|
||||
raise HTTPException(404, "建议不存在")
|
||||
if sug.status == "applied":
|
||||
@@ -385,10 +410,14 @@ def apply_suggestion(
|
||||
def dismiss_suggestion(
|
||||
suggestion_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""忽略建议"""
|
||||
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
|
||||
sug = db.query(AISuggestion).filter(
|
||||
AISuggestion.id == suggestion_id,
|
||||
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
|
||||
).first()
|
||||
if not sug:
|
||||
raise HTTPException(404, "建议不存在")
|
||||
sug.status = "dismissed"
|
||||
|
||||
@@ -14,6 +14,9 @@ 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
|
||||
# 裁定 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
|
||||
|
||||
logger = logging.getLogger("alert_rules")
|
||||
|
||||
@@ -257,13 +260,23 @@ def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depen
|
||||
|
||||
@router.post("/check-all")
|
||||
def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)"""
|
||||
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)
|
||||
|
||||
R1(裁定 2026-09-19):demo 演示账套不参与预警生成 → 直接返回 skipped。
|
||||
"""
|
||||
if is_excluded_entity(db, entity_id):
|
||||
logger.info("预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
|
||||
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成", "generated": 0,
|
||||
"skipped": True, "reason": "R1_demo_entity_excluded"}
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
|
||||
kpi_cache = {}
|
||||
value_cache = {}
|
||||
|
||||
alerts_generated = 0
|
||||
# R5(裁定 2026-09-19):期间非规整月(含伪期间 2026-H1/H2)→ 该行不入库,跳过并记数
|
||||
skipped_non_month_period = 0
|
||||
skipped_non_month_period_kpis = []
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
@@ -332,6 +345,14 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
|
||||
|
||||
if alert_level and alert_level != "green":
|
||||
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库。
|
||||
# 注:`latest_value` 按 period 字符串倒序取,`2026-H1` 会排在 `2026-09` 之前 →
|
||||
# 伪期间确实可能被取为「最新值」,此处是伪期间入库唯一的拦截点。
|
||||
if not guard_alert_period(period, source="alert_rules.run_all_alert_checks",
|
||||
detail="kpi=%s rule_id=%s" % (kpi.kpi_code, rule.id)):
|
||||
skipped_non_month_period += 1
|
||||
skipped_non_month_period_kpis.append("%s[%s]" % (kpi.kpi_code, period))
|
||||
continue
|
||||
# 检查是否已有相同预警
|
||||
existing_alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == rule.kpi_id,
|
||||
@@ -357,7 +378,12 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
continue
|
||||
|
||||
db.commit()
|
||||
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated}
|
||||
if skipped_non_month_period:
|
||||
logger.info("R5 期间守卫:本次跳过 %s 条不可归期预警(%s)",
|
||||
skipped_non_month_period, skipped_non_month_period_kpis)
|
||||
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated,
|
||||
"skipped_non_month_period": skipped_non_month_period,
|
||||
"skipped_non_month_period_kpis": skipped_non_month_period_kpis}
|
||||
|
||||
|
||||
@router.get("/dynamic-thresholds")
|
||||
@@ -642,7 +668,14 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
|
||||
|
||||
@router.post("/check-forecast")
|
||||
def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)"""
|
||||
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)
|
||||
|
||||
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
|
||||
"""
|
||||
if is_excluded_entity(db, entity_id):
|
||||
logger.info("预测值预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
|
||||
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
|
||||
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
|
||||
generated = _check_forecast_alerts(db, entity_id=entity_id)
|
||||
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
|
||||
|
||||
@@ -690,7 +723,14 @@ def run_forecast_deviation_check(
|
||||
):
|
||||
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
|
||||
对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values),
|
||||
偏差 > threshold_pct → 生成/更新 pending 预警(去重)"""
|
||||
偏差 > threshold_pct → 生成/更新 pending 预警(去重)
|
||||
|
||||
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
|
||||
"""
|
||||
if is_excluded_entity(db, entity_id):
|
||||
logger.info("预测偏差检查跳过(demo 账套 R1): entity_id=%s", entity_id)
|
||||
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
|
||||
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
|
||||
from app.models import KpiForecastLog
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
@@ -861,7 +901,14 @@ def run_customer_concentration_check(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""客户集中度检查(P2 2026-09-10)— 遍历本企业 customer_concentration 规则,
|
||||
计算客户收入占比,超阈值生成/更新 pending 预警(去重)。"""
|
||||
计算客户收入占比,超阈值生成/更新 pending 预警(去重)。
|
||||
|
||||
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
|
||||
"""
|
||||
if is_excluded_entity(db, entity_id):
|
||||
logger.info("客户集中度检查跳过(demo 账套 R1): entity_id=%s", entity_id)
|
||||
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
|
||||
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.rule_type == "customer_concentration",
|
||||
|
||||
+37
-16
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
|
||||
from app.models import KPIAlert, OperationLog, ActionPlan
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.alerts")
|
||||
@@ -16,8 +16,10 @@ router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
|
||||
|
||||
@router.get("")
|
||||
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b)
|
||||
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id)
|
||||
# 账套隔离(2026-09-19 dama-20260919 C1):按**预警行自身** entity_id 过滤
|
||||
# 修复前按 KPI 归属企业过滤(2026-08-23 P1b)→ KPI 属他账套而预警行属本账套的行反向泄漏
|
||||
# (实测 entity 2 口径 225 → 223,泄漏 id=589/590)
|
||||
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(KPIAlert.status == status)
|
||||
total = query.count()
|
||||
@@ -26,8 +28,10 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De
|
||||
|
||||
@router.post("/{alert_id}/resolve")
|
||||
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
|
||||
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
|
||||
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
|
||||
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
alert.status = "resolved"
|
||||
@@ -45,9 +49,12 @@ def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), enti
|
||||
|
||||
|
||||
@router.post("/{alert_id}/process")
|
||||
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""标记预警为处理中"""
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
||||
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""标记预警为处理中(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
|
||||
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
|
||||
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
if alert.status == "resolved":
|
||||
@@ -63,9 +70,12 @@ def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{alert_id}/escalate")
|
||||
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""升级预警级别"""
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
||||
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""升级预警级别(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
|
||||
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
|
||||
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
assignee = data.get("assignee")
|
||||
@@ -82,11 +92,18 @@ def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/check-timeout")
|
||||
def check_alert_timeout(db: Session = Depends(get_db)):
|
||||
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色"""
|
||||
def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色
|
||||
|
||||
账套隔离(2026-09-19 写路径跨账套修复):按**预警行自身** KPIAlert.entity_id 过滤;
|
||||
修复前 join kpi_definitions 按 KPI 归属企业过滤 → KPI 属他账套而预警行属本账套的行
|
||||
会被本账套调用**批量改写**(升级 red + 置 processing),比只读泄漏更严重
|
||||
(2026-08 之前为全账套批处理;2026-09-18 加的 join 口径见 dama-20260919 派生单)。
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.now() - timedelta(hours=24)
|
||||
timeout_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.created_at < cutoff,
|
||||
KPIAlert.alert_level != "red",
|
||||
@@ -102,14 +119,18 @@ def check_alert_timeout(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{alert_id}/create-action-plan")
|
||||
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""从预警创建改善行动计划"""
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
||||
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""从预警创建改善行动计划(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
|
||||
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
|
||||
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
if alert.action_plan_linked_id:
|
||||
raise HTTPException(400, "已关联改善计划")
|
||||
plan = ActionPlan(
|
||||
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
|
||||
alert_id=alert.id,
|
||||
kpi_id=alert.kpi_id,
|
||||
title=f"改善: {alert.alert_message}",
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role, require_auth_401
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPIDefinition, OperationLog, RolePermission
|
||||
|
||||
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
|
||||
@@ -19,7 +20,30 @@ router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
|
||||
dependencies=[Depends(require_auth_401)],
|
||||
)
|
||||
|
||||
# 三种对齐模式定义
|
||||
# ── B3(a) 对齐模式拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
|
||||
# 背景:`role_permissions.key` 为 unique 单行键 → `alignment_config` 全库一行,
|
||||
# 任一账套的 ceo/it 改对齐模式 → **全账套生效**(跨账套配置污染)。
|
||||
# 治本(方案 A,零 DDL):键名带账套后缀 → `alignment_config.<entity_id>`。
|
||||
# · 读:只读本账套键;未配置 → configured=false(/tree 提示先初始化),
|
||||
# **不回退旧全局键**(回退即恢复跨账套生效)。
|
||||
# · 写:只写本账套键(entity 来自会话 token)。
|
||||
# · 旧值搬运:脚本 scripts/migrate_cash_balance_key_entity_20260922.py --check/--apply
|
||||
# (同一 500 口径:原全局值落到「当前生产账套」名下;--down 删新键并写回旧键)。
|
||||
ALIGNMENT_KEY_PREFIX = "alignment_config"
|
||||
|
||||
|
||||
def alignment_key(entity_id: int) -> str:
|
||||
"""账套维度的对齐模式键名:alignment_config.<entity_id>"""
|
||||
return f"{ALIGNMENT_KEY_PREFIX}.{int(entity_id)}"
|
||||
|
||||
|
||||
def _get_alignment_perm(db: Session, entity_id: int):
|
||||
"""读本账套的对齐模式配置行(不存在 → None)"""
|
||||
return db.query(RolePermission).filter(
|
||||
RolePermission.key == alignment_key(entity_id)
|
||||
).first()
|
||||
|
||||
|
||||
ALIGNMENT_MODES = [
|
||||
{
|
||||
"key": "vertical_decomposition",
|
||||
@@ -49,18 +73,25 @@ def list_modes():
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_alignment_config(db: Session = Depends(get_db)):
|
||||
"""获取当前系统对齐模式配置(公开接口,无需认证)"""
|
||||
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
|
||||
def get_alignment_config(entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db)):
|
||||
"""获取**当前账套**的对齐模式配置(B3(a) 拆账套,2026-09-22)
|
||||
|
||||
修复前:读全局唯一键 `alignment_config` → 任一账套改模式全平台生效。
|
||||
修复后:只读 `alignment_config.<entity_id>`;未配置 → configured=false。
|
||||
"""
|
||||
perm = _get_alignment_perm(db, entity_id)
|
||||
if not perm:
|
||||
return {
|
||||
"mode": None,
|
||||
"configured": False,
|
||||
"entity_id": entity_id,
|
||||
"modes": ALIGNMENT_MODES,
|
||||
}
|
||||
return {
|
||||
"mode": perm.value,
|
||||
"configured": True,
|
||||
"entity_id": entity_id,
|
||||
"modes": ALIGNMENT_MODES,
|
||||
}
|
||||
|
||||
@@ -68,45 +99,62 @@ def get_alignment_config(db: Session = Depends(get_db)):
|
||||
@router.post("/config")
|
||||
def set_alignment_config(
|
||||
data: dict,
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
user = Depends(require_role("ceo", "it")),
|
||||
):
|
||||
"""初始化/修改系统对齐模式(CEO/IT权限)"""
|
||||
"""初始化/修改**当前账套**的对齐模式(CEO/IT权限)
|
||||
|
||||
B3(a)(2026-09-22):只写 `alignment_config.<entity_id>`,不覆盖他账套;
|
||||
entity 来自会话 token,显式传他账套 entity_id → get_entity_id 403。
|
||||
"""
|
||||
mode_key = data.get("mode")
|
||||
if mode_key not in [m["key"] for m in ALIGNMENT_MODES]:
|
||||
raise HTTPException(400, f"无效的对齐模式: {mode_key}")
|
||||
|
||||
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
|
||||
perm = _get_alignment_perm(db, entity_id)
|
||||
if perm:
|
||||
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat()}
|
||||
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat(),
|
||||
"entity_id": entity_id}
|
||||
else:
|
||||
perm = RolePermission(key="alignment_config", value={"mode": mode_key, "set_at": datetime.now().isoformat()})
|
||||
perm = RolePermission(key=alignment_key(entity_id),
|
||||
value={"mode": mode_key, "set_at": datetime.now().isoformat(),
|
||||
"entity_id": entity_id})
|
||||
db.add(perm)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key}
|
||||
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key, "entity_id": entity_id}
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
def get_alignment_tree(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
user = Depends(require_auth),
|
||||
):
|
||||
"""获取KPI对齐关系树
|
||||
"""获取KPI对齐关系树(按当前账套隔离)
|
||||
|
||||
根据当前系统配置的对齐模式,返回KPI的父子层级关系。
|
||||
如果指定kpi_id,返回该KPI及其下级树;
|
||||
如果不指定,返回整个对齐树。
|
||||
|
||||
账套隔离(2026-09-18 project-20260918-alignment-entity-leak):
|
||||
只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐
|
||||
growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。
|
||||
跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。
|
||||
"""
|
||||
# 获取当前模式
|
||||
config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
|
||||
# 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
|
||||
config_perm = _get_alignment_perm(db, entity_id)
|
||||
mode = config_perm.value.get("mode") if config_perm else None
|
||||
if not mode:
|
||||
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
|
||||
|
||||
# 获取所有KPI
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all()
|
||||
# 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI)
|
||||
kpis = (db.query(KPIDefinition)
|
||||
.filter(KPIDefinition.entity_id == entity_id, KPIDefinition.status == "active")
|
||||
.order_by(KPIDefinition.kpi_code)
|
||||
.all())
|
||||
kpi_map = {k.id: k for k in kpis}
|
||||
|
||||
# 构建父子关系
|
||||
|
||||
@@ -20,6 +20,8 @@ from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
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
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge")
|
||||
@@ -673,6 +675,10 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
period = period or datetime.now().strftime("%Y-%m")
|
||||
|
||||
# R1(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警
|
||||
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge.kpi-value-with-check",
|
||||
detail="kpi_id=%s" % kpi_id)
|
||||
|
||||
# ① 写值(upsert)
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
|
||||
@@ -689,7 +695,9 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
|
||||
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
|
||||
alerts = []
|
||||
if run_check:
|
||||
# R5(裁定 2026-09-19):期间非规整月(含伪期间)→ 不入库,跳过并记数
|
||||
skipped_non_month_period = 0
|
||||
if run_check and alerts_allowed:
|
||||
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
|
||||
@@ -709,6 +717,11 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
else:
|
||||
continue
|
||||
if level and level != "green":
|
||||
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 → 该 kpi_alerts 行不入库
|
||||
if not guard_alert_period(period, source="bot_bridge.kpi-value-with-check",
|
||||
detail="kpi_id=%s rule_id=%s" % (kpi_id, rule.id)):
|
||||
skipped_non_month_period += 1
|
||||
continue
|
||||
dup = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
|
||||
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
|
||||
@@ -720,8 +733,13 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
||||
except Exception as e:
|
||||
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
|
||||
db.commit()
|
||||
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
|
||||
"period": period, "alerts": alerts, "status": "ok"}
|
||||
resp = {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
|
||||
"period": period, "alerts": alerts, "status": "ok",
|
||||
"skipped_non_month_period": skipped_non_month_period}
|
||||
if not alerts_allowed:
|
||||
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警(值照写、预警跳过)
|
||||
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/kpis/create-with-links")
|
||||
|
||||
@@ -21,9 +21,11 @@ from app.database import get_db
|
||||
from app.models import (
|
||||
MpmResult, BotBridgeConfig,
|
||||
KPIDefinition, KPIValue, KPIAlert,
|
||||
ActionPlan, Entity,
|
||||
ActionPlan, Entity, OperationLog,
|
||||
)
|
||||
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
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge_v2")
|
||||
|
||||
@@ -307,6 +309,12 @@ def receive_mpm_result(
|
||||
if not entity:
|
||||
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
|
||||
|
||||
# R1/R5(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警;
|
||||
# 期间非规整月(含 2026-H1/H2 伪期间)的 kpi_alerts 行一律不入库(跳过 + 记日志)。
|
||||
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge_v2.receive_mpm_result")
|
||||
period_attributable = guard_alert_period(period, source="bot_bridge_v2.receive_mpm_result",
|
||||
detail="entity_id=%s" % entity_id)
|
||||
|
||||
# ── 1. 写入MPM结果 ──
|
||||
record = MpmResult(
|
||||
entity_id=entity_id,
|
||||
@@ -356,6 +364,7 @@ def receive_mpm_result(
|
||||
|
||||
# ── 3. 触发预警 ──
|
||||
alerts = []
|
||||
alerts_skipped_no_kpi = [] # B3(b): KPI 定义缺失而跳过的预警(留痕用)
|
||||
for field, config in ALERT_THRESHOLDS.items():
|
||||
field_value = results.get(field)
|
||||
if field_value is None:
|
||||
@@ -367,7 +376,20 @@ def receive_mpm_result(
|
||||
|
||||
if (operator == ">" and field_value > threshold) or \
|
||||
(operator == ">=" and field_value >= threshold):
|
||||
# R1/R5 生成侧守卫(裁定 2026-09-19):demo 账套 / 不可归期期间 → 该行不入库
|
||||
if not alerts_allowed:
|
||||
logger.info("R1 账套守卫:跳过不入库(demo 演示账套)entity_id=%s", entity_id)
|
||||
continue
|
||||
if not period_attributable:
|
||||
logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period)
|
||||
continue
|
||||
# 查找关联KPI
|
||||
# B3(b)(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922):
|
||||
# 修复前 `kpi_id=kpi_id or 1` —— KPI 定义缺失时把预警错挂到「全库第 1 个 KPI」
|
||||
# (该 KPI 可能属他账套:预警 entity_id 正确、KPI 维度却跨了账套)。
|
||||
# 现改为**显式校验**:拿不到本账套的 KPI 定义 → 不建预警(跳过)+ 落 OperationLog 留痕。
|
||||
# 为什么不是 `kpi_id=None`:`kpi_alerts.kpi_id` 为 NOT NULL(models/__init__.py:145),
|
||||
# batch5 §B3(b) 方案 A(保留可见性)在本 schema 下不可行;方案 B(跳过 + 审计)落地。
|
||||
kpi_id = None
|
||||
if kpi_code:
|
||||
kpi_def = db.query(KPIDefinition).filter(
|
||||
@@ -377,11 +399,38 @@ def receive_mpm_result(
|
||||
if kpi_def:
|
||||
kpi_id = kpi_def.id
|
||||
|
||||
# 创建预警
|
||||
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
|
||||
|
||||
if kpi_id is None:
|
||||
# 宁缺勿错:不入库,但必须留痕(防「预警静默丢失」)
|
||||
alerts_skipped_no_kpi.append(f"{kpi_name}(kpi_code={kpi_code!r})")
|
||||
db.add(OperationLog(
|
||||
user_id=None,
|
||||
action="skip_alert",
|
||||
target_type="alert",
|
||||
target_id=None,
|
||||
detail={
|
||||
"reason": "kpi_definition_not_found_for_entity",
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi_name,
|
||||
"entity_id": entity_id,
|
||||
"period": period,
|
||||
"field": field,
|
||||
"field_value": field_value,
|
||||
"threshold": threshold,
|
||||
"source": "bot_bridge_v2.mpm-result",
|
||||
},
|
||||
))
|
||||
logger.warning(
|
||||
"B3(b) 跳过建预警(KPI定义缺失,不再回落 kpi_id=1): kpi_code=%r entity_id=%s period=%s",
|
||||
kpi_code, entity_id, period,
|
||||
)
|
||||
continue
|
||||
|
||||
# 创建预警
|
||||
alert = KPIAlert(
|
||||
entity_id=entity_id, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi_id or 1, # fallback to first KPI if not found
|
||||
kpi_id=kpi_id, # B3(b): 显式校验后的本账套 KPI(禁止 or 1 兜底)
|
||||
alert_level="red",
|
||||
alert_message=alert_msg,
|
||||
alert_type="actual",
|
||||
@@ -394,16 +443,24 @@ def receive_mpm_result(
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
resp = {
|
||||
"success": True,
|
||||
"kpi_updated": len(kpi_updates),
|
||||
"alerts_triggered": len(alerts),
|
||||
# B3(b): 因本账套无对应 KPI 定义而跳过的预警(已落 OperationLog,非静默丢失)
|
||||
"alerts_skipped_missing_kpi": len(alerts_skipped_no_kpi),
|
||||
"mpm_record_id": record.id,
|
||||
"details": {
|
||||
"kpi_codes": kpi_updates,
|
||||
"alerts": alerts,
|
||||
"alerts_skipped_missing_kpi": alerts_skipped_no_kpi,
|
||||
},
|
||||
"skipped_non_month_period": (not period_attributable),
|
||||
}
|
||||
if not alerts_allowed:
|
||||
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警
|
||||
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/kpi-result")
|
||||
|
||||
@@ -62,11 +62,13 @@ def list_bot_kpis(
|
||||
source: str = Query("finance-bot", description="Bot标识"),
|
||||
period: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取某Bot的所有KPI(含评分)"""
|
||||
"""获取某Bot的所有KPI(含评分)(账套隔离 2026-09-21:仅本账套,对齐 update_bot_kpi_value)"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.bot_source == source,
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
if not kpis:
|
||||
|
||||
+55
-15
@@ -77,8 +77,9 @@ def create_budget_plan(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""创建或更新单条预算计划"""
|
||||
"""创建或更新单条预算计划(账套隔离 2026-09-21:KPI 归属校验 + 写会话账套)"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
budget_value = data.get("budget_value")
|
||||
@@ -86,8 +87,11 @@ def create_budget_plan(
|
||||
if not all([kpi_id, period, budget_value is not None]):
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period, budget_value")
|
||||
|
||||
# 检查KPI是否存在
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
# 检查KPI是否存在(账套隔离 2026-09-21:仅本账套 KPI,防把预算挂到对端 KPI)
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
@@ -112,6 +116,7 @@ def create_budget_plan(
|
||||
return {"message": "预算已更新", "id": existing.id}
|
||||
else:
|
||||
plan = BudgetPlan(
|
||||
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1
|
||||
kpi_id=kpi_id,
|
||||
map_id=map_id,
|
||||
period=period,
|
||||
@@ -145,9 +150,12 @@ def update_budget_plan(
|
||||
plan_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""更新预算计划"""
|
||||
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
|
||||
"""更新预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
plan = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
|
||||
).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "预算计划不存在")
|
||||
|
||||
@@ -168,9 +176,12 @@ def update_budget_plan(
|
||||
def delete_budget_plan(
|
||||
plan_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""删除预算计划"""
|
||||
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
|
||||
"""删除预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
plan = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
|
||||
).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "预算计划不存在")
|
||||
db.delete(plan)
|
||||
@@ -306,7 +317,9 @@ def auto_decompose_budget(
|
||||
if annual_budget is None:
|
||||
raise HTTPException(400, "缺少必要参数: annual_budget(单KPI分解需传年度预算值)")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
@@ -333,6 +346,7 @@ def auto_decompose_budget(
|
||||
|
||||
existing = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.version == version,
|
||||
BudgetPlan.status == "active",
|
||||
@@ -342,6 +356,7 @@ def auto_decompose_budget(
|
||||
existing.budget_value = monthly_value
|
||||
else:
|
||||
bp = BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id, period=period,
|
||||
budget_value=monthly_value, budget_year=year,
|
||||
budget_month=m, version=version, status="active",
|
||||
@@ -357,6 +372,7 @@ def auto_decompose_budget(
|
||||
period = f"{year}-{m:02d}"
|
||||
existing = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.version == version,
|
||||
BudgetPlan.status == "active",
|
||||
@@ -365,6 +381,7 @@ def auto_decompose_budget(
|
||||
existing.budget_value = monthly
|
||||
else:
|
||||
bp = BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id, period=period,
|
||||
budget_value=monthly, budget_year=year,
|
||||
budget_month=m, version=version, status="active",
|
||||
@@ -394,6 +411,7 @@ def get_deviation_report(
|
||||
dimension: Optional[str] = Query(None),
|
||||
alert_level: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取差异分析报告(汇总多个KPI的实际vs预算差异)"""
|
||||
if period is None:
|
||||
@@ -404,7 +422,10 @@ def get_deviation_report(
|
||||
else:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
query = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
)
|
||||
if kpi_id:
|
||||
query = query.filter(KPIDefinition.id == kpi_id)
|
||||
if dimension:
|
||||
@@ -534,12 +555,14 @@ def set_budget_config(
|
||||
def budget_roll_forward(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""
|
||||
滚动预算自动延展:
|
||||
- 删除最早一个月的预测数据
|
||||
- 新增未来一个月的预测数据(取最近三个月均值)
|
||||
- 返回延展结果
|
||||
账套隔离:只处理会话账套(join kpi_definitions 按企业过滤),2026-09-18
|
||||
"""
|
||||
from app.models import SystemConfig
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
|
||||
@@ -557,8 +580,11 @@ def budget_roll_forward(
|
||||
now = datetime.now()
|
||||
current_year, current_month = now.year, now.month
|
||||
|
||||
# 获取所有active的预算记录
|
||||
plans = db.query(BudgetPlan).filter(BudgetPlan.status == "active").all()
|
||||
# 获取本账套 active 的预算记录(账套隔离: join KPI 按企业过滤, 2026-09-18 — 原为全账套)
|
||||
plans = (db.query(BudgetPlan)
|
||||
.join(KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id)
|
||||
.filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id)
|
||||
.all())
|
||||
|
||||
# 按KPI分组
|
||||
from collections import defaultdict
|
||||
@@ -598,6 +624,7 @@ def budget_roll_forward(
|
||||
|
||||
if not existing:
|
||||
bp = BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=new_period,
|
||||
budget_value=avg_value,
|
||||
@@ -634,6 +661,7 @@ def get_budget_comparison(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
year: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""
|
||||
获取实际值vs预测值对比数据
|
||||
@@ -679,7 +707,11 @@ def get_budget_comparison(
|
||||
)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetPlan.kpi_id == kpi_id)
|
||||
query = query.filter(BudgetPlan.period.in_(periods), BudgetPlan.status == "active")
|
||||
query = query.filter(
|
||||
BudgetPlan.period.in_(periods),
|
||||
BudgetPlan.status == "active",
|
||||
BudgetPlan.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
)
|
||||
budget_plans = query.all()
|
||||
|
||||
# 按KPI+期间索引
|
||||
@@ -755,6 +787,7 @@ def get_kpi_comparison(
|
||||
kpi_id: int,
|
||||
year: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""
|
||||
获取单个KPI的实际vs预测对比数据(用于图表展示)
|
||||
@@ -765,7 +798,10 @@ def get_kpi_comparison(
|
||||
now = datetime.now()
|
||||
y = year or now.year
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
@@ -1095,10 +1131,14 @@ def update_deviation_alert(
|
||||
alert_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""更新偏差预警(如标记已解决)"""
|
||||
"""更新偏差预警(如标记已解决)(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
from app.models import BudgetDeviationAlert
|
||||
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == alert_id).first()
|
||||
alert = db.query(BudgetDeviationAlert).filter(
|
||||
BudgetDeviationAlert.id == alert_id,
|
||||
BudgetDeviationAlert.entity_id == entity_id,
|
||||
).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警记录不存在")
|
||||
if "status" in data:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
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
|
||||
import json
|
||||
@@ -75,12 +76,16 @@ def _calc_budget(kpi: KPIDefinition) -> dict:
|
||||
def get_kpi_budget_candidates(
|
||||
year: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取可用于生成预算的KPI列表,按类型分类"""
|
||||
"""获取本账套可用于生成预算的KPI列表,按类型分类(账套隔离 2026-09-21)"""
|
||||
if not year:
|
||||
year = datetime.now().year
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
# 获取每个KPI的最新实际值
|
||||
latest_values = {}
|
||||
|
||||
+44
-18
@@ -116,15 +116,21 @@ def api_get_balance(
|
||||
@router.post("/balance")
|
||||
def api_set_balance(
|
||||
data: dict,
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""设置当前现金余额(万元),作为预测基线"""
|
||||
"""设置当前现金余额(万元),作为预测基线
|
||||
|
||||
账套隔离(B1,2026-09-22):基线落 `cash.current_balance.<entity_id>`,
|
||||
entity 来自会话 token(显式传他账套 entity_id → get_entity_id 403)。
|
||||
修复前本端点无 entity 依赖 → 任一账套设基线会覆盖全平台唯一键。
|
||||
"""
|
||||
from app.utils.cash_forecast_engine import set_current_cash_balance
|
||||
value = float(data.get("current_cash", 0))
|
||||
if value < 0:
|
||||
raise HTTPException(400, "现金余额不能为负")
|
||||
set_current_cash_balance(db, value)
|
||||
return {"message": "当前现金余额已更新", "current_cash": value}
|
||||
set_current_cash_balance(db, value, entity_id)
|
||||
return {"message": "当前现金余额已更新", "entity_id": entity_id, "current_cash": value}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
@@ -198,9 +204,12 @@ def api_create_plan(request: Request, data: dict, db: Session = Depends(get_db))
|
||||
|
||||
|
||||
@router.put("/plans/{plan_id}")
|
||||
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""更新收付款计划"""
|
||||
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
|
||||
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""更新收付款计划(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
plan = db.query(CashPlan).filter(
|
||||
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
|
||||
).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
if "plan_type" in data:
|
||||
@@ -248,9 +257,12 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}")
|
||||
def api_delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""删除收付款计划"""
|
||||
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
|
||||
def api_delete_plan(plan_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""删除收付款计划(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
plan = db.query(CashPlan).filter(
|
||||
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
|
||||
).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
db.delete(plan)
|
||||
@@ -259,9 +271,12 @@ def api_delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/complete")
|
||||
def api_complete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""标记计划为已完成(收款到账/付款完成)"""
|
||||
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
|
||||
def api_complete_plan(plan_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""标记计划为已完成(收款到账/付款完成)(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
plan = db.query(CashPlan).filter(
|
||||
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
|
||||
).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
plan.status = "completed"
|
||||
@@ -616,9 +631,13 @@ def api_receivables(
|
||||
|
||||
|
||||
@router.post("/receivables/{plan_id}/payment")
|
||||
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS"""
|
||||
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
|
||||
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS
|
||||
(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
plan = db.query(CashPlan).filter(
|
||||
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
|
||||
).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "应收计划不存在")
|
||||
if plan.plan_type != "receive":
|
||||
@@ -772,7 +791,11 @@ def _is_carry_forward(summary: str, subject_name: str) -> bool:
|
||||
|
||||
|
||||
def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float:
|
||||
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)"""
|
||||
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)
|
||||
|
||||
账套隔离(B1,2026-09-22):本函数已按 entity_id 计算余额,写回时**带上该账套**
|
||||
(修复前丢账套 → 任一家导入网银流水就覆盖全平台唯一键的基线)。
|
||||
"""
|
||||
from app.models import VoucherDetail
|
||||
from app.utils.cash_forecast_engine import set_current_cash_balance
|
||||
from app.utils.voucher_guard import apply_voucher_guard
|
||||
@@ -787,8 +810,11 @@ def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> f
|
||||
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
|
||||
), 2)
|
||||
cash_wan = round(balance_yuan / 10000, 4)
|
||||
set_current_cash_balance(db, cash_wan)
|
||||
logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动")
|
||||
set_current_cash_balance(db, cash_wan, entity_id)
|
||||
logger.info(
|
||||
f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 "
|
||||
f"→ 现金余额联动(账套 {entity_id})"
|
||||
)
|
||||
return cash_wan
|
||||
|
||||
|
||||
|
||||
+33
-12
@@ -176,21 +176,34 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db),
|
||||
|
||||
# ============================================================
|
||||
# ABC 作业成本
|
||||
# 注:ABC 作业中心/分配为单实体演示数据(ab_activities/ab_allocations 全为 entity 1,
|
||||
# 历史演示用途),OpenCode 多租户审查 2026-09-07 判定暂不强制按账套隔离;若启用多企业 ABC 需补 entity 过滤。
|
||||
# 账套隔离(2026-09-18,0-P1-project-20260918-entity-isolation-batch2):
|
||||
# GET /abc/activities 原为全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心;
|
||||
# 现按会话账套过滤(AbcActivity.entity_id)。
|
||||
# OpenCode 多租户审查 2026-09-07 的「单实体演示数据暂不强制隔离」豁免就此撤销。
|
||||
# 账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):batch2 同族残留收口
|
||||
# - POST /abc/activities 原签名无账套依赖、不写归属 → 落模型默认 entity_id=1;现按会话账套写入
|
||||
# - POST /abc/allocate 原无账套依赖、activity_id 不校验归属、分配记录落默认 1;
|
||||
# 现校验 activity_id 归属本账套(跨账套 → 404,文案对齐同文件先例)
|
||||
# 并把 entity_id 透传 cost_engine.allocate_cost 显式写入 AbcAllocation
|
||||
# - GET /abc/allocations 原查询只按 period/product_code → 现加 entity_id 过滤(读型同族)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/abc/activities")
|
||||
def list_abc_activities(db: Session = Depends(get_db)):
|
||||
"""查询ABC作业中心列表"""
|
||||
items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all()
|
||||
def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""查询ABC作业中心列表(账套隔离:只返回本账套)"""
|
||||
items = (db.query(AbcActivity)
|
||||
.filter(AbcActivity.entity_id == entity_id)
|
||||
.order_by(AbcActivity.activity_code)
|
||||
.all())
|
||||
return {"data": items}
|
||||
|
||||
|
||||
@router.post("/abc/activities")
|
||||
def create_abc_activity(data: dict, db: Session = Depends(get_db)):
|
||||
"""创建ABC作业中心"""
|
||||
def create_abc_activity(data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""创建ABC作业中心(账套隔离:按会话账套写入归属)"""
|
||||
act = AbcActivity(
|
||||
entity_id=entity_id,
|
||||
activity_code=data["activity_code"],
|
||||
activity_name=data["activity_name"],
|
||||
activity_desc=data.get("activity_desc"),
|
||||
@@ -206,14 +219,21 @@ def create_abc_activity(data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/abc/allocate")
|
||||
def do_allocate(data: dict, db: Session = Depends(get_db)):
|
||||
"""执行ABC成本分配"""
|
||||
def do_allocate(data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""执行ABC成本分配(账套隔离:activity_id 须属本账套,跨账套 → 404)"""
|
||||
act = (db.query(AbcActivity)
|
||||
.filter(AbcActivity.id == data["activity_id"], AbcActivity.entity_id == entity_id)
|
||||
.first())
|
||||
if not act:
|
||||
raise HTTPException(404, "作业中心不存在")
|
||||
result = allocate_cost(
|
||||
activity_id=data["activity_id"],
|
||||
period=data.get("period", datetime.now().strftime("%Y-%m")),
|
||||
product_code=data["product_code"],
|
||||
product_name=data.get("product_name", ""),
|
||||
driver_consumed=data["driver_consumed"],
|
||||
entity_id=entity_id,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -221,9 +241,10 @@ def do_allocate(data: dict, db: Session = Depends(get_db)):
|
||||
@router.get("/abc/allocations")
|
||||
def list_allocations(period: Optional[str] = Query(None),
|
||||
product_code: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
"""查询ABC分配记录"""
|
||||
query = db.query(AbcAllocation)
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查询ABC分配记录(账套隔离:只返回本账套)"""
|
||||
query = db.query(AbcAllocation).filter(AbcAllocation.entity_id == entity_id)
|
||||
if period:
|
||||
query = query.filter(AbcAllocation.period == period)
|
||||
if product_code:
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import func, desc
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
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, KPIAlert, User
|
||||
import logging
|
||||
@@ -45,6 +46,7 @@ def list_customer_kpis(
|
||||
end_date: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
|
||||
start, end = parse_period(period, start_date, end_date)
|
||||
@@ -54,6 +56,7 @@ def list_customer_kpis(
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
result = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""驾驶舱 API v2 — 支持时间区间"""
|
||||
from fastapi import APIRouter, Depends, Query, Request, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy import case, func, or_
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
@@ -360,6 +360,7 @@ def get_my_kpis(
|
||||
current_user: User = Depends(require_auth),
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取当前用户负责的KPI
|
||||
- business角色:只看自己负责的KPI
|
||||
@@ -370,7 +371,10 @@ def get_my_kpis(
|
||||
name = current_user.name
|
||||
period_str = datetime.now().strftime("%Y-%m")
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).all()
|
||||
result = []
|
||||
|
||||
for k in kpis:
|
||||
@@ -565,6 +569,55 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
|
||||
|
||||
# ── 个人工作台 ──────────────────────────────
|
||||
|
||||
# KPIValue 取数口径(2026-09-18 工作台「我的KPI」期间缺口修正)
|
||||
# 原实现(my_dashboard 内联)按 calculated_at 倒序取「最近写入」的一条:批量补录/复采会把
|
||||
# **旧期间**的行写得更晚 → 卡片显示过期期间。实测 entity1:F_REVENUE/F_NET_PROFIT 的
|
||||
# 2026-07 行写入时刻(2026-09-14 17:01:22, id=2128/2130)晚于 2026-08 行(17:00:30, id=2125/2127)
|
||||
# → 取到 7 月;C_REBATE_RATE 1–4 月补录行 calculated_at 并列(2026-08-24 17:17:38) → 取到 4 月。
|
||||
# 新口径:优先取「月期间 YYYY-MM」中最新的一期(月期是采集/录入的常规粒度);若该 KPI 只有
|
||||
# H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。
|
||||
# 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把
|
||||
# 季度汇总行/半年度预估值(forecast)当成"最新"。
|
||||
_MONTH_PERIOD_REGEX = r"^[0-9]{4}-(0[1-9]|1[0-2])$"
|
||||
|
||||
# 主值优先(2026-09-18 同期间重复行判据收口,项目单 project-20260918-kpi-row-priority)
|
||||
# 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径)
|
||||
# ②自动归集重复行(source_type='auto_collect',备注自述「与主值为同一事实重复行」)。
|
||||
# 原判据仅看写入时刻:重复行由复采批量写入、calculated_at 更晚 ⇒ 会被选中(实测 entity1
|
||||
# 2026-08 的 F_REVENUE/F_NET_PROFIT:2136/2127 auto_collect 写于 09-14,晚于主值 1822/2102)。
|
||||
# 今日两行数值一致(Δ=0)故用户不可见;一旦重复行与主值漂移,卡片会静默显示重复行的值。
|
||||
# 故在**同一期间内**加「主值优先」:verified(已复核)> ledger(账面主值)> 其余。
|
||||
# 注意:跨期间仍以 period 倒序为第一判据(不改变 d33b5e1 的期间口径——当期只有未复核行时,
|
||||
# 不会回退到上一期已复核行)。
|
||||
_MAIN_VALUE_PRIORITY = case(
|
||||
(KPIValue.data_status == "verified", 0),
|
||||
(KPIValue.source_type == "ledger", 1),
|
||||
else_=2,
|
||||
)
|
||||
|
||||
|
||||
def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
|
||||
"""该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。"""
|
||||
monthly = (
|
||||
db.query(KPIValue)
|
||||
.filter(KPIValue.kpi_id == kpi_id, KPIValue.period.op("REGEXP")(_MONTH_PERIOD_REGEX))
|
||||
.order_by(
|
||||
KPIValue.period.desc(),
|
||||
_MAIN_VALUE_PRIORITY,
|
||||
KPIValue.calculated_at.desc(),
|
||||
KPIValue.id.desc(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if monthly is not None:
|
||||
return monthly
|
||||
return (
|
||||
db.query(KPIValue)
|
||||
.filter(KPIValue.kpi_id == kpi_id)
|
||||
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my-dashboard")
|
||||
def my_dashboard(
|
||||
@@ -613,9 +666,7 @@ def my_dashboard(
|
||||
|
||||
kpi_list = []
|
||||
for k in all_kpis:
|
||||
latest_v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
latest_v = latest_kpi_value(db, k.id)
|
||||
|
||||
actual = latest_v.actual_value if latest_v else None
|
||||
target = kpi_target_by_frequency(k)
|
||||
|
||||
@@ -10,7 +10,7 @@ import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import assert_entity_scope
|
||||
from app.deps import assert_entity_scope, get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
|
||||
from app.api.kpis import kpi_to_dict
|
||||
@@ -36,9 +36,12 @@ def _log_to_dict(log):
|
||||
# ============================================================
|
||||
|
||||
@router.get("/check")
|
||||
def run_quality_check(db: Session = Depends(get_db)):
|
||||
"""扫描全部KPI,生成数据质量报告"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
def run_quality_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""扫描本账套KPI,生成数据质量报告(账套隔离 2026-09-21)"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
issues = []
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
@@ -174,9 +177,19 @@ def list_quality_logs(
|
||||
check_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取数据质量日志"""
|
||||
query = db.query(KpiDataQualityLog)
|
||||
"""获取数据质量日志(账套隔离 2026-09-21)
|
||||
|
||||
kpi_data_quality_log 表无 entity_id 列(见本文件 :235 说明),按本单选项(1)
|
||||
经 kpi_id join kpi_definitions 以 kpi_definitions.entity_id 收口(零迁移)。
|
||||
join 为内连接:kpi_id 无对应 KPI 行的孤儿日志无法归属账套,一律不返回。
|
||||
"""
|
||||
query = (
|
||||
db.query(KpiDataQualityLog)
|
||||
.join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
)
|
||||
if kpi_id:
|
||||
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
|
||||
if severity:
|
||||
@@ -198,10 +211,22 @@ def list_quality_logs(
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
def _scoped_quality_log(db: Session, log_id: int, entity_id: int):
|
||||
"""按会话账套取质量日志行(表无 entity_id 列 → 经 kpi_id join kpi_definitions 归属)"""
|
||||
return (
|
||||
db.query(KpiDataQualityLog)
|
||||
.join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id)
|
||||
.filter(KpiDataQualityLog.id == log_id,
|
||||
KPIDefinition.entity_id == entity_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@router.put("/logs/{log_id}")
|
||||
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""更新质量日志(解决/忽略)"""
|
||||
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
|
||||
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""更新质量日志(解决/忽略)(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
log = _scoped_quality_log(db, log_id, entity_id)
|
||||
if not log:
|
||||
raise HTTPException(404, "日志不存在")
|
||||
if "status" in data:
|
||||
@@ -215,22 +240,29 @@ def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), u
|
||||
|
||||
|
||||
@router.delete("/logs/{log_id}")
|
||||
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
|
||||
if log:
|
||||
db.delete(log)
|
||||
db.commit()
|
||||
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""删除质量日志(账套隔离 2026-09-21:跨账套 → 404;不存在 → 404,对齐 PUT 语义)"""
|
||||
log = _scoped_quality_log(db, log_id, entity_id)
|
||||
if not log:
|
||||
raise HTTPException(404, "日志不存在")
|
||||
db.delete(log)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据质量看板统计
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats")
|
||||
def quality_stats(db: Session = Depends(get_db)):
|
||||
"""数据质量统计"""
|
||||
total_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").count()
|
||||
def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""数据质量统计(账套隔离 2026-09-21:KPI 口径按会话账套;质量日志表无 entity_id 列,见回报)"""
|
||||
total_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).count()
|
||||
total_logs = db.query(KpiDataQualityLog).count()
|
||||
open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count()
|
||||
|
||||
@@ -256,7 +288,10 @@ def quality_stats(db: Session = Depends(get_db)):
|
||||
|
||||
# ── 数据审计看板统计 ──
|
||||
# KPI完整度评分
|
||||
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
all_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).all()
|
||||
total = len(all_kpis)
|
||||
complete_kpis = 0
|
||||
missing_metadata_count = 0
|
||||
|
||||
@@ -6,10 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
|
||||
# 裁定 R5-补1(2026-09-19 财务口径):差异反打路径同源接入 R1/R5 守卫
|
||||
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cma.deviation_push")
|
||||
|
||||
@@ -22,6 +24,7 @@ router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
|
||||
def push_deviation_to_map(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""从差异分析回写实际值到战略地图节点
|
||||
@@ -47,9 +50,25 @@ def push_deviation_to_map(
|
||||
if new_value is None:
|
||||
raise HTTPException(400, "缺少 newValue")
|
||||
|
||||
# 1. 查找战略地图
|
||||
# R5-补1(财务裁定 2026-09-19):「请求体无 period 一律不落库」
|
||||
# 旧码在缺 period 时用当月冒充期间再落库(预警 + kpi_values 双写)——
|
||||
# 该兜底正好绕过 R5「可归期」判据,故此处直接整请求零业务写入返回。
|
||||
if not period:
|
||||
logger.info(
|
||||
"R5-补1 期间守卫:deviation_push 请求无 period,整请求零业务写入"
|
||||
" entity_id=%s map_id=%s node_id=%s", entity_id, map_id, node_id)
|
||||
return {
|
||||
"success": False,
|
||||
"message": "缺少 period:按 R5-补1(期间不可归期)未落库任何数据",
|
||||
"alert_created": False,
|
||||
"alert_skipped_reason": "no_period",
|
||||
"zero_write": True,
|
||||
"review_topic_created": False,
|
||||
}
|
||||
|
||||
# 1. 查找战略地图(2026-09-18 派生单: 地图须归属会话账套,NULL 归属按 e1 兼容)
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
if not m or (m.entity_id or 1) != entity_id:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
# 2. 解析 node_id 格式: "finance-0"
|
||||
@@ -89,13 +108,12 @@ def push_deviation_to_map(
|
||||
|
||||
kpi_code = kpi_codes[0]
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if not kpi:
|
||||
if not kpi or (kpi.entity_id or 1) != entity_id:
|
||||
raise HTTPException(404, f"KPI {kpi_code} 不存在")
|
||||
|
||||
# 3. 更新实际值到 KPIValue 表
|
||||
if not period:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
#(R5-补1:period 已在上方强制校验,原「缺 period 用当月兜底」分支已删除——
|
||||
# 该兜底会伪造期间,正是 R5「不可归期」的成因之一)
|
||||
existing_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
@@ -104,20 +122,22 @@ def push_deviation_to_map(
|
||||
if existing_value:
|
||||
existing_value.actual_value = new_value
|
||||
existing_value.source_type = "manual"
|
||||
kv_row = existing_value
|
||||
else:
|
||||
kv = KPIValue(
|
||||
kv_row = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=new_value,
|
||||
source_type="manual",
|
||||
)
|
||||
db.add(kv)
|
||||
db.add(kv_row)
|
||||
|
||||
db.flush()
|
||||
|
||||
# 4. 检查是否触发预警
|
||||
alert_created = False
|
||||
alert_id = None
|
||||
alert_skipped_reason = None
|
||||
if kpi.target_value and kpi.target_value > 0:
|
||||
ratio = new_value / kpi.target_value
|
||||
if ratio < 0.7:
|
||||
@@ -130,28 +150,41 @@ def push_deviation_to_map(
|
||||
alert_level = None
|
||||
|
||||
if alert_level:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_msg,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
alert_created = True
|
||||
alert_id = alert.id
|
||||
# R1/R5 双守卫(裁定 R5-补1):demo 账套 / 非规整月期间 → 该 kpi_alerts 行不入库,
|
||||
# 返回体登记跳过原因(KPI 值照写,与 bot_bridge 同口径:裁定只约束预警行)
|
||||
if not entity_alert_guard(db, entity_id, source="deviation_push.push_deviation_to_map",
|
||||
detail="kpi=%s" % kpi_code):
|
||||
alert_skipped_reason = "R1_demo_entity_excluded"
|
||||
elif not guard_alert_period(period, source="deviation_push.push_deviation_to_map",
|
||||
detail="kpi=%s" % kpi_code):
|
||||
alert_skipped_reason = "R5_non_attributable_period"
|
||||
|
||||
if alert_skipped_reason is None:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
kpi_value_id=kv_row.id, # R5-补1: 期间关联(同 kpi 同期间行 → 可派生)
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_msg,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
alert_created = True
|
||||
alert_id = alert.id
|
||||
|
||||
# 5. 生成战略回顾会议题
|
||||
review_topic_created = False
|
||||
if create_review_topic:
|
||||
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
|
||||
existing_topic = db.query(ActionPlan).filter(
|
||||
ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离
|
||||
ActionPlan.title == topic_title,
|
||||
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||
).first()
|
||||
if not existing_topic:
|
||||
topic = ActionPlan(
|
||||
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
|
||||
kpi_id=kpi.id,
|
||||
title=topic_title,
|
||||
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
|
||||
@@ -176,6 +209,7 @@ def push_deviation_to_map(
|
||||
"new_value": new_value,
|
||||
"period": period,
|
||||
"alert_created": alert_created,
|
||||
"alert_skipped_reason": alert_skipped_reason,
|
||||
"review_topic_created": review_topic_created,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
@@ -190,15 +224,17 @@ def push_deviation_to_map(
|
||||
"new_value": new_value,
|
||||
"alert_created": alert_created,
|
||||
"alert_id": alert_id,
|
||||
"alert_skipped_reason": alert_skipped_reason,
|
||||
"review_topic_created": review_topic_created,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/map-nodes/{map_id}")
|
||||
def get_map_nodes(map_id: int, db: Session = Depends(get_db)):
|
||||
def get_map_nodes(map_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""获取战略地图的全部节点(供反打选择使用)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
if not m or (m.entity_id or 1) != entity_id: # 2026-09-18 派生单: NULL 归属按 e1 兼容(同 push 护栏)
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
dims = m.dimensions
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
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.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
@@ -254,6 +255,7 @@ def driver_calculate(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""
|
||||
驱动因子计算预算。
|
||||
@@ -313,6 +315,7 @@ def driver_calculate(
|
||||
|
||||
# 记录计算结果
|
||||
budget_record = DriverFactorBudget(
|
||||
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
|
||||
name=name,
|
||||
industry=industry,
|
||||
template_id=template_id,
|
||||
@@ -457,9 +460,12 @@ def driver_sensitivity(
|
||||
def list_driver_history(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""查看最近的驱动因子预算计算记录"""
|
||||
records = db.query(DriverFactorBudget).order_by(
|
||||
"""查看本账套最近的驱动因子预算计算记录(账套隔离 2026-09-21)"""
|
||||
records = db.query(DriverFactorBudget).filter(
|
||||
DriverFactorBudget.entity_id == entity_id,
|
||||
).order_by(
|
||||
DriverFactorBudget.created_at.desc()
|
||||
).limit(limit).all()
|
||||
|
||||
|
||||
+45
-17
@@ -290,7 +290,9 @@ def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list
|
||||
})
|
||||
else:
|
||||
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
|
||||
# 账套隔离 2026-09-21:累计口径限本账套(他账套在途不得污染本账套额度判定)
|
||||
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
|
||||
ExpenseReimbursement.entity_id == reimb.entity_id,
|
||||
ExpenseReimbursement.expense_type == reimb.expense_type,
|
||||
ExpenseReimbursement.id != reimb.id,
|
||||
ExpenseReimbursement.status.in_(["pending", "approved"]),
|
||||
@@ -343,6 +345,7 @@ def submit_reimbursement(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""提交报销单 → 自动校验规则(超限自动打回)"""
|
||||
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
|
||||
@@ -366,6 +369,7 @@ def submit_reimbursement(
|
||||
expense_date = None
|
||||
|
||||
reimb = ExpenseReimbursement(
|
||||
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
|
||||
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
|
||||
applicant=applicant,
|
||||
department=data.get("department", ""),
|
||||
@@ -402,8 +406,9 @@ def list_reimbursements(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
|
||||
q = db.query(ExpenseReimbursement)
|
||||
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)(账套隔离 2026-09-21:列表按会话账套)"""
|
||||
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.entity_id == entity_id)
|
||||
|
||||
if status:
|
||||
q = q.filter(ExpenseReimbursement.status == status)
|
||||
if expense_type:
|
||||
@@ -425,20 +430,29 @@ def list_reimbursements(
|
||||
def get_reimbursement(
|
||||
reimb_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""报销单详情"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
"""报销单详情(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
r = db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.id == reimb_id,
|
||||
ExpenseReimbursement.entity_id == entity_id,
|
||||
).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
return _reimb_to_dict(r)
|
||||
|
||||
|
||||
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
|
||||
"""执行审批动作: approve/reject/return"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str,
|
||||
entity_id: int = None):
|
||||
"""执行审批动作: approve/reject/return(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id)
|
||||
if entity_id is not None:
|
||||
q = q.filter(ExpenseReimbursement.entity_id == entity_id)
|
||||
r = q.first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
|
||||
if r.status not in ("pending", "returned"):
|
||||
raise HTTPException(400, f"当前状态({r.status})不可审批")
|
||||
|
||||
@@ -462,10 +476,11 @@ def approve_reimbursement(
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""审批通过"""
|
||||
"""审批通过(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
|
||||
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver, entity_id)
|
||||
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@@ -475,13 +490,14 @@ def reject_reimbursement(
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""审批拒绝"""
|
||||
"""审批拒绝(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
comment = (data or {}).get("comment", "")
|
||||
if not comment:
|
||||
raise HTTPException(400, "拒绝时必须填写审批意见")
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "reject", comment, approver)
|
||||
r = _do_approve(db, reimb_id, "reject", comment, approver, entity_id)
|
||||
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@@ -491,10 +507,11 @@ def return_reimbursement(
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""人工打回"""
|
||||
"""人工打回(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
|
||||
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver, entity_id)
|
||||
return {"message": "已打回", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@@ -504,9 +521,13 @@ def resubmit_reimbursement(
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""被驳回/打回后重新提交 → 重新自动校验"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
"""被驳回/打回后重新提交 → 重新自动校验(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
r = db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.id == reimb_id,
|
||||
ExpenseReimbursement.entity_id == entity_id,
|
||||
).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
if r.status not in ("returned", "rejected"):
|
||||
@@ -537,6 +558,7 @@ def resubmit_reimbursement(
|
||||
def expense_stats(
|
||||
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
|
||||
@@ -544,7 +566,9 @@ def expense_stats(
|
||||
period = period or now.strftime("%Y-%m")
|
||||
|
||||
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
|
||||
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by(
|
||||
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).filter(
|
||||
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).group_by(
|
||||
ExpenseReimbursement.status
|
||||
).all():
|
||||
if st in counts:
|
||||
@@ -560,6 +584,7 @@ def expense_stats(
|
||||
)
|
||||
.filter(
|
||||
ExpenseReimbursement.status.in_(["pending", "approved"]),
|
||||
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
|
||||
)
|
||||
.group_by(ExpenseReimbursement.expense_type)
|
||||
@@ -583,6 +608,7 @@ def expense_stats(
|
||||
.filter(
|
||||
ExpenseReimbursement.status == "returned",
|
||||
ExpenseReimbursement.check_result == "fail",
|
||||
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
)
|
||||
.order_by(ExpenseReimbursement.id.desc())
|
||||
.limit(50)
|
||||
@@ -604,7 +630,9 @@ def expense_stats(
|
||||
for r in over_limit_rows
|
||||
]
|
||||
|
||||
recent_rows = db.query(ExpenseReimbursement).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
|
||||
recent_rows = db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
|
||||
recent = [_reimb_to_dict(r) for r in recent_rows]
|
||||
|
||||
# 本月预算使用率(招待费15万/月 管理费91万/月)
|
||||
|
||||
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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
|
||||
|
||||
@@ -166,9 +167,13 @@ def validate_kpi(
|
||||
def governance_audit(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""全量校验所有活跃KPI,输出按4条规则分组的不合规清单。"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
"""校验本账套活跃KPI,输出按4条规则分组的不合规清单(账套隔离 2026-09-21)。"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
non_compliant = []
|
||||
by_rule: dict = {1: [], 2: [], 3: [], 4: []}
|
||||
|
||||
+32
-10
@@ -437,9 +437,13 @@ def get_kpi_causality_chain(
|
||||
kpi_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""KPI因果链追踪 — 返回单个KPI的上下游因果链"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
"""KPI因果链追踪 — 返回单个KPI的上下游因果链(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
@@ -884,14 +888,21 @@ def kpi_to_dict(k):
|
||||
|
||||
|
||||
@router.put("/{kpi_id}/associate-map")
|
||||
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""关联KPI到战略地图"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""关联KPI到战略地图(账套隔离 2026-09-21:KPI 与地图均须属本账套,跨账套 → 404)"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
map_id = data.get("map_id")
|
||||
if map_id is not None:
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == map_id,
|
||||
StrategicMap.entity_id == entity_id,
|
||||
).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
kpi.map_id = map_id
|
||||
@@ -907,9 +918,16 @@ def _log(db, user_id, action, target_type, target_id, detail):
|
||||
|
||||
|
||||
@router.get("/{kpi_id}/objectives")
|
||||
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""查看KPI所属的目标和战略地图"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查看KPI所属的目标和战略地图(账套隔离 2026-09-21:跨账套 id → 404)
|
||||
|
||||
鉴权口径:本 router 级 `dependencies=[Depends(require_role(...))]` 已覆盖本端点
|
||||
(无 token → 401),此处不再重复挂 require_auth,只补缺失的账套收口。
|
||||
"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
@@ -922,15 +940,19 @@ def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
if kpi.map_id:
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first()
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == kpi.map_id, StrategicMap.entity_id == entity_id,
|
||||
).first()
|
||||
if m:
|
||||
result["map"] = {"id": m.id, "title": m.title, "status": m.status}
|
||||
|
||||
if kpi.objective:
|
||||
objs = db.query(MapObjective).filter(
|
||||
MapObjective.map_id == kpi.map_id,
|
||||
MapObjective.entity_id == entity_id,
|
||||
MapObjective.name == kpi.objective,
|
||||
).all()
|
||||
result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.models import NotificationChannel, NotificationLog
|
||||
from app.auth_middleware import require_role, require_auth_401
|
||||
from datetime import datetime
|
||||
|
||||
# ── B2(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)────────────────
|
||||
# 本模块两张表在 `models/__init__.py` 均**无 entity_id 列**,按「分表处置」口径落地:
|
||||
# · `notification_logs`(Log 类)→ **经父键 join 归属**,零迁移:
|
||||
# alert_id → kpi_alerts.id → kpi_alerts.entity_id(GET /logs 已收口)。
|
||||
# · `notification_channels`(**配置类,无父键可桥接**)→ 任总 2026-09-22 已批
|
||||
# 「方案 1(零 DDL)」:**保持全局 = 集团共享配置**(一套 wecom/mail/sms 渠道
|
||||
# 供全账套复用;预警推送是集团行为,渠道与账套无关)。
|
||||
# ⚠️ 因此 `/channels*` 四个端点**有意不按账套过滤**——这是口径,不是漏隔离。
|
||||
# 若后续需要「账套私有渠道」,须单独立单加 entity_id 列(DDL + 回填 + 唯一键改造)。
|
||||
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
|
||||
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
|
||||
@@ -52,8 +63,10 @@ def update_channel(ch_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
|
||||
if not ch:
|
||||
raise HTTPException(404, "渠道不存在")
|
||||
# 字段白名单(2026-09-21 mass-assignment 收口):仅业务字段,其余键忽略
|
||||
_updatable = ("name", "config", "enabled")
|
||||
for k, v in data.items():
|
||||
if hasattr(ch, k) and k not in ("id", "created_at"):
|
||||
if k in _updatable:
|
||||
setattr(ch, k, v)
|
||||
db.commit()
|
||||
db.refresh(ch)
|
||||
@@ -95,14 +108,35 @@ def test_channel(ch_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
def list_logs(page: int = 1, db: Session = Depends(get_db)):
|
||||
"""通知历史"""
|
||||
total = db.query(NotificationLog).count()
|
||||
logs = db.query(NotificationLog).order_by(
|
||||
def list_logs(page: int = 1,
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db)):
|
||||
"""通知历史(**按会话账套收口**,B2 零迁移)
|
||||
|
||||
2026-09-22 dama-workbench-entity-isolation-fullstack-20260922(§四 B2):
|
||||
`notification_logs` 无 entity_id 列 → 经父键 join 归属:
|
||||
`notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`。
|
||||
口径与 batch5 对 `KpiDataQualityLog` 的处置一致(宁少勿错):
|
||||
`alert_id IS NULL` 的行无可归属父键 → **不返回**(不暴露给任一账套)。
|
||||
"""
|
||||
from app.models import KPIAlert
|
||||
scoped = (db.query(NotificationLog)
|
||||
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
|
||||
.filter(KPIAlert.entity_id == entity_id))
|
||||
total = scoped.count()
|
||||
logs = scoped.order_by(
|
||||
NotificationLog.created_at.desc()
|
||||
).offset((page - 1) * 20).limit(20).all()
|
||||
return {
|
||||
"total": total,
|
||||
"entity_id": entity_id,
|
||||
# 口径字段(2026-09-22 DAMA 回查 §5 收口):上线后本页在无预警推送的账套会显示 0 条,
|
||||
# 属口径而非故障——现有 8 行历史记录全是 `alert_id IS NULL` 的 OKR 复盘提醒(无账套归属),
|
||||
# 按「宁少勿错」不返回。前端据此文案提示,避免被当成页面坏了。
|
||||
"policy": "alert-linked-only",
|
||||
"note": ("仅显示与预警推送关联的记录(按父键 kpi_alerts 归属会话账套)。"
|
||||
"无父键的历史通知(如 OKR 复盘提醒,写入方 backend/scripts/okr_review_reminder.py)"
|
||||
"不属于任何账套,不在本列表返回。口径登记:docs/audit-and-notification-caliber-20260922.md"),
|
||||
"data": [{
|
||||
"id": l.id,
|
||||
"alert_id": l.alert_id,
|
||||
|
||||
@@ -547,6 +547,11 @@ def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
|
||||
week_end = week_start + timedelta(days=7)
|
||||
action_plans = db.query(ActionPlan).filter(
|
||||
ActionPlan.objective_id == okr_id,
|
||||
# 账套隔离(2026-09-18 batch3 派生单):objective_id 已由
|
||||
# _get_objective_or_404 校验归属,但计划侧此前无账套条件 →
|
||||
# 对端账套计划(含脏行)会出现在本账套分解视图(实测 OBS2)。
|
||||
# 前置条件:create_plan 必须已写会话账套 entity_id(顺序铁律)。
|
||||
ActionPlan.entity_id == entity_id,
|
||||
ActionPlan.due_date.between(week_start, week_end)
|
||||
).all()
|
||||
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
"""
|
||||
OKR模板库 API — 按维度筛选 + 用户自定义模板
|
||||
|
||||
⚠️ 集团级共享口径(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,§四 B2)
|
||||
------------------------------------------------------------------------------------
|
||||
`okr_templates` 表**无 entity_id 列**,且属**模板/配置类**(无父键可 join 桥接)。
|
||||
任总 2026-09-22「都批」已按 **方案 1(零 DDL)** 拍板:
|
||||
|
||||
**保持全局 = 集团级共享配置** —— 模板库是集团知识资产,一套模板(维度/行业标签/
|
||||
目标+KR 骨架)供全账套复用;账套差异体现在**使用模板生成的具体目标/KR 行**
|
||||
(`objectives` / `map_objectives` 均带 entity_id,已隔离),而不是模板本身。
|
||||
|
||||
因此本模块的列表/详情/新增/改/删**有意不按账套过滤**——这是口径,不是漏隔离。
|
||||
若后续确需「账套私有模板」,须单独立单加 `entity_id` 列(DDL + 回填 + 唯一键
|
||||
`name+dimension` 改造),本单不预设。
|
||||
"""
|
||||
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 OKRTemplate, StrategicMap
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
@@ -253,7 +267,8 @@ def increment_use_count(template_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{template_id}/apply")
|
||||
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""应用 OKR 模板 — 创建战略地图并填入 O+KR"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
@@ -293,6 +308,7 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d
|
||||
})
|
||||
|
||||
m = StrategicMap(
|
||||
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1(对齐 maps.py)
|
||||
title=map_title,
|
||||
version=data.get("version", "v1.0"),
|
||||
status="draft",
|
||||
|
||||
+155
-68
@@ -10,7 +10,7 @@ from app.utils.cash_forecast_engine import (
|
||||
calculate_accuracy, generate_scenario_suggestion,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request
|
||||
from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope
|
||||
from app.auth_middleware import require_auth_401
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -724,95 +724,182 @@ def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
|
||||
return indicators
|
||||
|
||||
|
||||
# ── 增长质量诊断取数(账套隔离 2026-09-21)────────────────────────
|
||||
# 输入 ← 库内 KPI 实际值(kpi_values),编码口径沿用 kpi_definitions 既有编码
|
||||
# (同 app/api/growth_quality.py 的 KPI_CODES),不新增计算口径。
|
||||
_GQ_KPI_CODE_MAP = {
|
||||
"C_REBATE_RATE": "rebateRate", # 渠补率(%)
|
||||
"F_GROSS_MARGIN": "trueGrossMargin", # 毛利率(%)
|
||||
"F_REVENUE_GROWTH": "revenueGrowthRate", # 收入增长率(%)
|
||||
}
|
||||
|
||||
_GQ_DIM_SCORERS = {
|
||||
"revenueStructure": _score_revenue_structure,
|
||||
"profitStructure": _score_profit_structure,
|
||||
"cashAssets": _score_cash_assets,
|
||||
"growthDriver": _score_growth_driver,
|
||||
"orgEfficiency": _score_org_efficiency,
|
||||
}
|
||||
|
||||
# 各维度所需输入键。缺任一输入 → 该维度不评分(避免用缺省 0 编造低分)
|
||||
_GQ_DIM_INPUTS = {
|
||||
"revenueStructure": ("rebateRate",),
|
||||
"profitStructure": ("trueGrossMargin",),
|
||||
"cashAssets": ("cashRatio",),
|
||||
"growthDriver": ("expenseGrowthRate", "revenueGrowthRate"),
|
||||
"orgEfficiency": ("mgmtRatio",),
|
||||
}
|
||||
|
||||
|
||||
def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
|
||||
"""按会话账套从库内 KPI 实际值取增长质量输入。
|
||||
|
||||
返回 (entity_dict|None, collected):
|
||||
- entity_dict 只含库内真实取到的指标(缺的键不填,绝不用请求体/硬编码兜底)
|
||||
- collected = 本账套是否存在增长质量相关 KPI 实际值
|
||||
"""
|
||||
from app.models import Entity, KPIDefinition, KPIValue
|
||||
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not ent:
|
||||
return None, False
|
||||
q = (
|
||||
db.query(KPIDefinition.kpi_code, KPIValue.actual_value, KPIValue.period)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(list(_GQ_KPI_CODE_MAP.keys())),
|
||||
KPIValue.actual_value.isnot(None))
|
||||
)
|
||||
if period:
|
||||
q = q.filter(KPIValue.period == period)
|
||||
rows = q.order_by(KPIValue.period.desc()).all()
|
||||
|
||||
entity = {"entity": ent.name}
|
||||
seen = set()
|
||||
for code, value, _period in rows:
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
entity[_GQ_KPI_CODE_MAP[code]] = float(value)
|
||||
return entity, bool(seen)
|
||||
|
||||
|
||||
def _growth_quality_score(entity: dict):
|
||||
"""计算「有输入的维度」评分 + 明细 + 缺失维度清单(缺输入的维度不评分)。"""
|
||||
scores, detail, missing = {}, {}, []
|
||||
for dim, inputs in _GQ_DIM_INPUTS.items():
|
||||
if any(k not in entity for k in inputs):
|
||||
missing.append(dim)
|
||||
detail[dim] = {"score": None, "data_missing": True,
|
||||
"indicators": [], "suggestions": []}
|
||||
continue
|
||||
score = _GQ_DIM_SCORERS[dim](entity)
|
||||
scores[dim] = score
|
||||
detail[dim] = {
|
||||
"score": score,
|
||||
"indicators": _get_dim_detail_indicators(dim, entity),
|
||||
"suggestions": _generate_improvement_suggestions(dim, score, entity),
|
||||
}
|
||||
return scores, detail, missing
|
||||
|
||||
|
||||
def _gq_level_of(overall: float):
|
||||
if overall is None:
|
||||
return None, None
|
||||
if overall >= 4:
|
||||
return "🟢 越增长越轻", "excellent"
|
||||
if overall >= 3:
|
||||
return "🟡 增长质量中等", "medium"
|
||||
if overall >= 2:
|
||||
return "🟠 需关注", "warning"
|
||||
return "🔴 越增长越重", "danger"
|
||||
|
||||
|
||||
@router.post("/growth-quality")
|
||||
def api_growth_quality(request: Request, data: dict):
|
||||
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
|
||||
def api_growth_quality(request: Request, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""增长质量诊断 — 五维度评分+综合评分+诊断结论(账套隔离 2026-09-21)
|
||||
|
||||
取数:按会话账套从库内 KPI 实际值取,移除请求体 `data.get("entity", data)` 回退
|
||||
(原回退可用客户端可控的「经营数据」伪造诊断结论);库内无数据时如实返回
|
||||
collected=false + 空维度,不编造评分。请求体显式 entity_id ≠ 会话账套 → 403。
|
||||
"""
|
||||
try:
|
||||
entity_id = resolve_entity_for_request(request, data.get("entity_id"))
|
||||
ENTITY_DATA = {
|
||||
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447},
|
||||
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4},
|
||||
}
|
||||
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
|
||||
entity_name = entity.get("entity", entity.get("name", "该企业"))
|
||||
period = entity.get("period", data.get("period", "当前"))
|
||||
data = data or {}
|
||||
# 显式 body entity_id 与会话账套不一致 → 403(文案同 app/deps.py)
|
||||
assert_entity_scope(request, data.get("entity_id"), db)
|
||||
period = data.get("period")
|
||||
entity, collected = _growth_quality_entity_from_db(db, entity_id, period)
|
||||
if entity is None:
|
||||
raise HTTPException(404, f"实体 {entity_id} 不存在")
|
||||
entity_name = entity.get("entity", "该企业")
|
||||
period_label = period or "当前"
|
||||
|
||||
# 五维度评分
|
||||
dim_scores = {
|
||||
"revenueStructure": _score_revenue_structure(entity),
|
||||
"profitStructure": _score_profit_structure(entity),
|
||||
"cashAssets": _score_cash_assets(entity),
|
||||
"growthDriver": _score_growth_driver(entity),
|
||||
"orgEfficiency": _score_org_efficiency(entity),
|
||||
}
|
||||
|
||||
overall = round(sum(dim_scores.values()) / 5, 1)
|
||||
|
||||
# 综合等级
|
||||
if overall >= 4:
|
||||
level = "🟢 越增长越轻"
|
||||
level_type = "excellent"
|
||||
elif overall >= 3:
|
||||
level = "🟡 增长质量中等"
|
||||
level_type = "medium"
|
||||
elif overall >= 2:
|
||||
level = "🟠 需关注"
|
||||
level_type = "warning"
|
||||
else:
|
||||
level = "🔴 越增长越重"
|
||||
level_type = "danger"
|
||||
|
||||
# 诊断结论
|
||||
dimensions_payload = {}
|
||||
detail_payload = {}
|
||||
for dim, score in dim_scores.items():
|
||||
dimensions_payload[dim] = {"score": score, "weight": 20}
|
||||
detail_payload[dim] = {
|
||||
"score": score,
|
||||
"indicators": _get_dim_detail_indicators(dim, entity),
|
||||
"suggestions": _generate_improvement_suggestions(dim, score, entity),
|
||||
if not collected:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": entity_name,
|
||||
"period": period_label,
|
||||
"collected": False,
|
||||
"data_source": "kpi_values",
|
||||
"message": "该账套暂无增长质量相关 KPI 实际值(无数据不评分)",
|
||||
"overall": None,
|
||||
"level": None,
|
||||
"level_type": None,
|
||||
"dimensions": {},
|
||||
"detail": {},
|
||||
"dimensions_missing": list(_GQ_DIM_INPUTS.keys()),
|
||||
"missing_metrics": sorted(set(_GQ_KPI_CODE_MAP.values())),
|
||||
"diagnosis": None,
|
||||
"compare": None,
|
||||
}
|
||||
|
||||
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
|
||||
dim_scores, detail_payload, dims_missing = _growth_quality_score(entity)
|
||||
dimensions_payload = {dim: {"score": s, "weight": 20} for dim, s in dim_scores.items()}
|
||||
overall = round(sum(dim_scores.values()) / len(dim_scores), 1) if dim_scores else None
|
||||
level, level_type = _gq_level_of(overall)
|
||||
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name) if overall is not None else None
|
||||
|
||||
# 对比数据(如果请求中包含多个实体)
|
||||
compare = data.get("compare", None)
|
||||
# 对比:只接受账套内 compare_entity_id 并从库内取数(原请求体 compare 数据不再采信)
|
||||
compare_result = None
|
||||
if compare:
|
||||
compare_entity = compare
|
||||
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
|
||||
cdims = {
|
||||
"revenueStructure": _score_revenue_structure(compare_entity),
|
||||
"profitStructure": _score_profit_structure(compare_entity),
|
||||
"cashAssets": _score_cash_assets(compare_entity),
|
||||
"growthDriver": _score_growth_driver(compare_entity),
|
||||
"orgEfficiency": _score_org_efficiency(compare_entity),
|
||||
}
|
||||
compare_overall = round(sum(cdims.values()) / 5, 1)
|
||||
compare_result = {
|
||||
"entity_name": compare_name,
|
||||
"overall": compare_overall,
|
||||
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
|
||||
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
|
||||
"🟡 增长质量中等" if compare_overall >= 3 else
|
||||
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
|
||||
}
|
||||
compare_entity_id = data.get("compare_entity_id")
|
||||
if compare_entity_id:
|
||||
assert_entity_scope(request, int(compare_entity_id), db)
|
||||
c_entity, c_collected = _growth_quality_entity_from_db(db, int(compare_entity_id), period)
|
||||
if c_entity is not None and c_collected:
|
||||
cdims, _c_detail, _c_missing = _growth_quality_score(c_entity)
|
||||
if cdims:
|
||||
c_overall = round(sum(cdims.values()) / len(cdims), 1)
|
||||
compare_result = {
|
||||
"entity_id": int(compare_entity_id),
|
||||
"entity_name": c_entity.get("entity", "对比企业"),
|
||||
"overall": c_overall,
|
||||
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
|
||||
"level": _gq_level_of(c_overall)[0],
|
||||
}
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": entity_name,
|
||||
"period": period,
|
||||
"period": period_label,
|
||||
"collected": True,
|
||||
"data_source": "kpi_values",
|
||||
"overall": overall,
|
||||
"level": level,
|
||||
"level_type": level_type,
|
||||
"dimensions": dimensions_payload,
|
||||
"detail": detail_payload,
|
||||
"dimensions_missing": dims_missing,
|
||||
"diagnosis": diagnosis,
|
||||
"compare": compare_result,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
|
||||
|
||||
|
||||
|
||||
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
||||
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
||||
MODELS, forecast_kpi, forecast_finance_kpis,
|
||||
|
||||
@@ -831,7 +831,7 @@ def seed_demo_data(
|
||||
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
|
||||
if exists:
|
||||
continue
|
||||
db.add(ExpenseReimbursement(**d))
|
||||
db.add(ExpenseReimbursement(entity_id=eid, **d))
|
||||
|
||||
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
|
||||
invoice_demo = [
|
||||
@@ -854,7 +854,11 @@ def seed_demo_data(
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
inv = InvoiceCheck(entity_id=eid, **d)
|
||||
inv_data = dict(d)
|
||||
# 演示日期按既有写入口径解析为 datetime(字符串日期在 SQLite 下无法绑定 DateTime 列;
|
||||
# MySQL 表现不变——同文件 :402 正常写入路径亦经 _parse_date)
|
||||
inv_data["invoice_date"] = _parse_date(inv_data.get("invoice_date"))
|
||||
inv = InvoiceCheck(entity_id=eid, **inv_data)
|
||||
_check_invoice(db, inv)
|
||||
db.add(inv)
|
||||
created["invoice"] += 1
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务"""
|
||||
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务
|
||||
|
||||
⚠️ 语义收敛(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,任总已批「都批」)
|
||||
--------------------------------------------------------------------------------------
|
||||
`system_configs.current_tenant` 是**全局唯一键**(该表无 entity_id 列,`config_key` unique)
|
||||
⇒ 一次写入全平台生效。本次事故(2026-09-04 前端企业切换器把它写成 entity_id=2)暴露的
|
||||
结构性缺陷正是「全局单值键被当成个人当前视图 / 数据维度」。
|
||||
|
||||
本文件的收敛口径:
|
||||
1. **它不是数据维度**。工作台/看板/报表取数一律用**登录会话的 entity**
|
||||
(`app/deps.py::get_entity_id` ← JWT token 绑定),前端本地视图用
|
||||
`localStorage.cma_entity_id`(登录窗口写入)。禁止任何取数端点读本键。
|
||||
2. 它的用途**只剩**:项目 Bot 分发 A2A 任务时的状态记录(本模块原始设计目的)。
|
||||
3. **仅 admin 会话可读**(GET /current):系统无 `admin` 角色(ROLES: ceo/finance/
|
||||
business/it),故映射为持有 `admin` 动作权限的 `it` + 业务负责人 `ceo`。
|
||||
4. 写(POST /switch)**不额外收紧角色**:项目 Bot 分发任务时需要写该状态,
|
||||
收紧会切断其原始用途。
|
||||
5. 每次写都落 `operation_logs`(action=`switch_tenant_state`)留痕 —— 修复前本端点
|
||||
无审计(ops 止血单假定「走端点即有审计」不成立,已在本单补齐)。
|
||||
6. **审计行可归属(2026-09-22 DAMA 独立回查 §4b 后补)**:`user_id` = 当前会话用户
|
||||
(原写 None ⇒ 事后无法追操作人);`target_type="entity"` + `target_id=<entity_id>`
|
||||
与 `POST /auth/switch-entity`(action=`switch_entity`,`auth.py`)**共用同一目标维度**
|
||||
⇒「按账套查换账套记录」= `WHERE target_type='entity' AND target_id=?`(`operation_logs`
|
||||
无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、
|
||||
一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见
|
||||
`docs/audit-and-notification-caliber-20260922.md`。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth_401
|
||||
from app.models import SystemConfig, Entity
|
||||
from app.auth_middleware import require_auth_401, require_role
|
||||
from app.models import SystemConfig, Entity, OperationLog
|
||||
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
|
||||
@@ -25,8 +51,15 @@ def _tenant_name(entity_id: int) -> str:
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
def get_current_tenant(db: Session = Depends(get_db)):
|
||||
"""查询当前租户"""
|
||||
def get_current_tenant(
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(require_role("it", "ceo")),
|
||||
):
|
||||
"""查询当前租户状态记录(**仅 admin 会话**:it/ceo)
|
||||
|
||||
2026-09-22 收敛:本键**不是**数据维度——前端/接口取数一律按会话 entity,
|
||||
不得读本键决定「取哪个实体的数据」(本次工作台跨实体污染的直接触发点)。
|
||||
"""
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg and cfg.config_value:
|
||||
import json
|
||||
@@ -38,13 +71,25 @@ def get_current_tenant(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/switch")
|
||||
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
|
||||
"""切换当前租户(CMA前端企业切换器调用)"""
|
||||
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
||||
user=Depends(require_auth_401)):
|
||||
"""切换当前租户状态记录(项目Bot 分发 A2A 任务用;CMA前端企业切换器已下线)
|
||||
|
||||
2026-09-22:写操作**不动**(保留 Bot 通道可用性),补 `operation_logs` 审计留痕。
|
||||
"""
|
||||
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
|
||||
if not ent:
|
||||
raise HTTPException(404, "企业不存在")
|
||||
|
||||
import json
|
||||
previous = None
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg and cfg.config_value:
|
||||
try:
|
||||
previous = json.loads(cfg.config_value)
|
||||
except Exception:
|
||||
previous = None
|
||||
|
||||
state = {
|
||||
"tenant_id": _tenant_name(data.entity_id),
|
||||
"entity_id": data.entity_id,
|
||||
@@ -54,15 +99,32 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
|
||||
"switched_at": __import__("datetime").datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg:
|
||||
cfg.config_value = json.dumps(state, ensure_ascii=False)
|
||||
else:
|
||||
db.add(SystemConfig(
|
||||
config_key=TENANT_KEY,
|
||||
config_value=json.dumps(state, ensure_ascii=False),
|
||||
description="当前租户状态(CMA企业切换联动)",
|
||||
description="当前租户状态记录(仅供项目Bot分发A2A任务;非数据维度,2026-09-22 语义收敛)",
|
||||
))
|
||||
|
||||
# 审计留痕(2026-09-22 补齐:本端点原无 operation_logs 记录)
|
||||
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
|
||||
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
|
||||
# (operation_logs 无 entity_id 列,无须 DDL)。
|
||||
db.add(OperationLog(
|
||||
user_id=user.id,
|
||||
action="switch_tenant_state",
|
||||
target_type="entity",
|
||||
target_id=data.entity_id,
|
||||
detail={
|
||||
"entity_id": data.entity_id,
|
||||
"entity_name": ent.name,
|
||||
"source": data.source,
|
||||
"previous": previous,
|
||||
"note": "current_tenant 状态记录变更(非数据维度)",
|
||||
},
|
||||
))
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "current": state}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""预警「参与范围」口径 —— 单一事实源(财务裁定 R1/R2/R3,2026-09-19)
|
||||
|
||||
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`(任总 2026-09-18「按建议执行」)
|
||||
|
||||
- **R1 账套范围**:`entities.status = 'demo'` 的账套(演示账套)**不参与预警生成/遍历**
|
||||
—— demo 仅用于演示展示,不产生业务预警。
|
||||
- **R2 期间窗口**:预警遍历期间 = **当月往前 6 个自然月(含当月)**;更早期间**不再新生成**。
|
||||
与裁定 2b(各 KPI 最近可用期间)叠加:`max(可用期间下限, 当月-6月)`。
|
||||
- **R3 规整月期间**:仅 `YYYY-MM` 参与;`H1/H2/FY`、纯年份等伪期间**一律不参与**。
|
||||
|
||||
约定:所有预警生成入口(alert_generator / deviation_engine / kpi_bep_deriver /
|
||||
alert_rules 生成端点 / cash_forecast_engine)**只调本模块的入口函数**,
|
||||
禁止在调用点各写一份 `status == 'demo'` / 期间正则 / 窗口算术(避免多处漂移)。
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.models import Entity, KPIDefinition
|
||||
|
||||
logger = logging.getLogger("cma.alert_scope")
|
||||
|
||||
#: R1:不参与预警的账套状态
|
||||
ALERT_EXCLUDED_ENTITY_STATUSES = ("demo",)
|
||||
|
||||
#: R2:期间窗口长度(含当月,6 = 当月 + 往前 5 个月)
|
||||
ALERT_WINDOW_MONTHS = 6
|
||||
|
||||
#: R3:规整月期间(严格校验 01–12,`2026-13` 之类非法月同样不参与)
|
||||
MONTH_PERIOD_RE = r"^[0-9]{4}-(?:0[1-9]|1[0-2])$"
|
||||
_MONTH_PERIOD_RE = re.compile(MONTH_PERIOD_RE)
|
||||
|
||||
|
||||
def excluded_entity_ids(db_session) -> set:
|
||||
"""R1:返回不参与预警遍历的账套 id 集合(当前 status='demo' → entity 3/4/5/6)。
|
||||
|
||||
fail-open:读不到 entities 表(测试库/权限异常)时返回空集并记 WARNING——
|
||||
口径缺失不应把预警主流程整条打断(生成链路另有 fail-safe)。
|
||||
"""
|
||||
try:
|
||||
rows = db_session.query(Entity.id).filter(
|
||||
Entity.status.in_(ALERT_EXCLUDED_ENTITY_STATUSES)
|
||||
).all()
|
||||
return {int(r[0]) for r in rows}
|
||||
except Exception as e: # pragma: no cover - 防御分支
|
||||
logger.warning("读取 entities.status=%s 失败,本次按「不排除任何账套」处理: %s",
|
||||
list(ALERT_EXCLUDED_ENTITY_STATUSES), e)
|
||||
return set()
|
||||
|
||||
|
||||
def is_excluded_entity(db_session, entity_id: Optional[int]) -> bool:
|
||||
"""R1:单个账套是否被排除(None → 按默认账套 1 处理,与 KPIAlert.entity_id 缺省一致)"""
|
||||
return int(entity_id or 1) in excluded_entity_ids(db_session)
|
||||
|
||||
|
||||
def kpi_excluded(db_session, kpi: KPIDefinition, excluded: Optional[set] = None) -> bool:
|
||||
"""R1:KPI 归属账套是否被排除(可传入预取的 excluded 集合,避免循环内重复查询)"""
|
||||
eids = excluded if excluded is not None else excluded_entity_ids(db_session)
|
||||
return int(getattr(kpi, "entity_id", None) or 1) in eids
|
||||
|
||||
|
||||
def window_lower_period(now: Optional[datetime] = None) -> str:
|
||||
"""R2:期间窗口下限 = 当月往前 6 个自然月(含当月)→ 当月-5 月。
|
||||
|
||||
2026-09 → `2026-04`(窗口 = 2026-04 ~ 2026-09)。
|
||||
"""
|
||||
now = now or datetime.now()
|
||||
months = now.month - (ALERT_WINDOW_MONTHS - 1)
|
||||
year = now.year
|
||||
while months <= 0:
|
||||
months += 12
|
||||
year -= 1
|
||||
return "%04d-%02d" % (year, months)
|
||||
|
||||
|
||||
def is_month_period(period) -> bool:
|
||||
"""R3:是否规整月期间(YYYY-MM)"""
|
||||
return bool(_MONTH_PERIOD_RE.fullmatch(str(period or "")))
|
||||
|
||||
|
||||
def period_in_window(period, now: Optional[datetime] = None) -> bool:
|
||||
"""R3 + R2:期间是否可参与预警生成(规整月 + 落在窗口内)"""
|
||||
if not is_month_period(period):
|
||||
return False
|
||||
return str(period) >= window_lower_period(now)
|
||||
|
||||
|
||||
# ── R5(裁定 2026-09-19,R3 的一致延伸):可归期原则 + 生成侧守卫 ──
|
||||
|
||||
def is_attributable_period(period) -> bool:
|
||||
"""R5:期间是否「可归期」= 规整月 `YYYY-MM`。
|
||||
|
||||
`period IS NULL` 与 `2026-H1/H2/FY` 同属「不可归期」→ 不参与预警
|
||||
(不可复核、不可归责、不可驱动行动)。
|
||||
"""
|
||||
return is_month_period(period)
|
||||
|
||||
|
||||
def guard_alert_period(period, *, source: str = "", detail: str = "") -> bool:
|
||||
"""R5 生成侧守卫:期间非规整月(含 None)→ 该 `kpi_alerts` 行**不入库**(跳过 + 记日志)。
|
||||
|
||||
返回 True = 放行(可入库);False = 跳过(**调用点必须不落库**)。
|
||||
裁定原文:「任何生成路径产出的 kpi_alerts 行,period 非 YYYY-MM 一律不入库(跳过并记日志),防复发。」
|
||||
"""
|
||||
if is_attributable_period(period):
|
||||
return True
|
||||
logger.info(
|
||||
"R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r source=%s detail=%s",
|
||||
period, source, detail)
|
||||
return False
|
||||
|
||||
|
||||
def entity_alert_guard(db_session, entity_id, *, source: str = "", detail: str = "") -> bool:
|
||||
"""R1 通用生成侧守卫:demo 账套在任何写通道上都不得产生业务预警。
|
||||
|
||||
「R1 与入口无关」——包含按显式 `entity_id` 写入的 Bot 通道(bot_bridge / bot_bridge_v2 等)。
|
||||
返回 True = 放行;False = 跳过(原因 = R1 demo 账套排除,沿用 `excluded_entity_ids`)。
|
||||
"""
|
||||
if is_excluded_entity(db_session, entity_id):
|
||||
logger.info(
|
||||
"R1 账套守卫:跳过不入库(demo 演示账套不产生业务预警)entity_id=%s source=%s detail=%s",
|
||||
entity_id, source, detail)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def scope_summary(db_session, now: Optional[datetime] = None) -> dict:
|
||||
"""口径证据(写日志/报告用):被排除账套 + 窗口下限"""
|
||||
return {
|
||||
"excluded_entities": sorted(excluded_entity_ids(db_session)),
|
||||
"window_lower_period": window_lower_period(now),
|
||||
"window_months": ALERT_WINDOW_MONTHS,
|
||||
}
|
||||
@@ -221,10 +221,38 @@ def calc_trend(values: list) -> float:
|
||||
return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比
|
||||
|
||||
|
||||
# ── B1 现金基线拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
|
||||
# 背景:`SystemConfig.config_key` 为 unique 单行键 → `cash.current_balance` 全库仅一行,
|
||||
# 所有账套共用;任一账套导入网银流水(`cash.py::_sync_cash_balance_from_ledger` 按 entity
|
||||
# 算完余额后丢账套写回)即覆盖其他账套的预测基线。实测事故值 -9.3 被全账套共用。
|
||||
#
|
||||
# 治本(方案 A,零 DDL):键名带账套后缀 → `cash.current_balance.<entity_id>`。
|
||||
# · 读:只读本账套键;本账套未设值 → None(由 KPI 实际值兜底,**不跨账套借用**)。
|
||||
# 不做「回退旧全局键」——那正是本次污染通路(其余账套会读到别家的基线)。
|
||||
# · 写:只写本账套键(调用方必须传会话 entity)。
|
||||
# · 旧值搬运:一次性落到「当前生产账套」名下(脚本
|
||||
# `scripts/migrate_cash_balance_key_entity_20260922.py`,--check/--apply/--down)。
|
||||
# 回退路径:--down 删新键(旧全局键原样保留,回退老码即可继续读)。
|
||||
CASH_BALANCE_KEY_PREFIX = "cash.current_balance"
|
||||
|
||||
|
||||
def cash_balance_key(entity_id: int) -> str:
|
||||
"""账套维度的现金基线键名:cash.current_balance.<entity_id>"""
|
||||
return f"{CASH_BALANCE_KEY_PREFIX}.{int(entity_id)}"
|
||||
|
||||
|
||||
def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
|
||||
"""获取当前现金余额(万元)— 优先级:KPI实际值 > SystemConfig > None"""
|
||||
"""获取当前现金余额(万元)— 优先级:KPI实际值 > 本账套SystemConfig键 > None
|
||||
|
||||
账套隔离(B1,2026-09-22):只读 `cash.current_balance.<entity_id>`。
|
||||
本账套未设基线 → 返回 None(由 KPI 实际值兜底 / 预测引擎默认值),
|
||||
**不回退旧全局键 `cash.current_balance`**——回退即恢复跨账套借用。
|
||||
"""
|
||||
from app.models import SystemConfig, KPIValue
|
||||
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"])
|
||||
# C3(2026-09-19 dama-20260919):补传实体前缀后缀候选(与 F2 的应收/应付路径一致)
|
||||
# 修复前漏传 → 博海/演示企业一条候选都不命中 → 回退 SystemConfig.cash.current_balance(全库唯一键 -9.3)
|
||||
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"],
|
||||
KPI_CODE_SUFFIXES["cash_balance"])
|
||||
if kpi:
|
||||
latest_cash = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
@@ -233,7 +261,7 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
|
||||
if latest_cash and latest_cash.actual_value is not None:
|
||||
return float(latest_cash.actual_value)
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == "cash.current_balance"
|
||||
SystemConfig.config_key == cash_balance_key(entity_id)
|
||||
).first()
|
||||
if cfg and cfg.config_value:
|
||||
try:
|
||||
@@ -243,19 +271,27 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def set_current_cash_balance(db: Session, value: float) -> float:
|
||||
"""设置当前现金余额(写入SystemConfig,供预测引擎使用)"""
|
||||
def set_current_cash_balance(db: Session, value: float, entity_id: int) -> float:
|
||||
"""设置当前现金余额(写入本账套键 `cash.current_balance.<entity_id>`,供预测引擎使用)
|
||||
|
||||
entity_id 为**必传**(2026-09-22 B1):签名中不设默认值,强制调用方带上会话账套,
|
||||
防「漏传 → 落默认账套」重演本次污染。全部调用点见 cash.py(POST /balance、
|
||||
网银流水导入联动)。
|
||||
"""
|
||||
from app.models import SystemConfig
|
||||
if entity_id is None:
|
||||
raise ValueError("set_current_cash_balance 必须传 entity_id(账套隔离,2026-09-22 B1)")
|
||||
key = cash_balance_key(entity_id)
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == "cash.current_balance"
|
||||
SystemConfig.config_key == key
|
||||
).first()
|
||||
if cfg:
|
||||
cfg.config_value = str(value)
|
||||
else:
|
||||
cfg = SystemConfig(
|
||||
config_key="cash.current_balance",
|
||||
config_key=key,
|
||||
config_value=str(value),
|
||||
description="当前现金余额(万元),资金预测基线",
|
||||
description=f"当前现金余额(万元),资金预测基线 — 账套 {int(entity_id)}",
|
||||
)
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
@@ -803,9 +839,17 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
|
||||
(此前博海走无排序兜底 → 命中 uk_entity_kpi 索引必中 BH_ADM_EXPENSE(437 管理费用))
|
||||
F3 去重命中时刷新文本/等级/逾期天数/归属(此前直接 continue → 金额与天数永久冻结在首版)
|
||||
F4 余额快照批次行按开关处理(默认 skip,不再逐户生成伪"到期未收款")
|
||||
|
||||
R1(裁定 2026-09-19):demo 演示账套不产生业务预警 → 直接返回 skipped。
|
||||
"""
|
||||
import json as _json
|
||||
from app.models import KPIAlert, CashPlan, KPIDefinition
|
||||
from app.utils.alert_entity_scope import is_excluded_entity
|
||||
|
||||
if is_excluded_entity(db, entity_id):
|
||||
logger.info("资金预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
|
||||
return {"entity_id": entity_id, "skipped": True, "reason": "R1_demo_entity_excluded",
|
||||
"new_alerts": [], "refreshed": []}
|
||||
|
||||
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
|
||||
new_alerts = []
|
||||
|
||||
@@ -158,11 +158,21 @@ def calc_driver_rate(activity_id: int) -> dict:
|
||||
|
||||
|
||||
def allocate_cost(activity_id: int, period: str, product_code: str,
|
||||
product_name: str, driver_consumed: float) -> dict:
|
||||
"""按动因分配成本到产品"""
|
||||
product_name: str, driver_consumed: float,
|
||||
entity_id: Optional[int] = None) -> dict:
|
||||
"""按动因分配成本到产品
|
||||
|
||||
账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):
|
||||
- entity_id 非 None 时,作业中心查询按本账套过滤(跨账套 activity_id 查不到 → 返回错误字典,
|
||||
端点侧已先行 404);
|
||||
- 新建 AbcAllocation 显式写入 entity_id(不传则维持原语义:不写归属,落模型默认)。
|
||||
"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first()
|
||||
q_act = db.query(AbcActivity).filter(AbcActivity.id == activity_id)
|
||||
if entity_id is not None:
|
||||
q_act = q_act.filter(AbcActivity.entity_id == entity_id)
|
||||
act = q_act.first()
|
||||
if not act or act.driver_rate == 0:
|
||||
# 自动计算分配率
|
||||
if act and act.driver_volume > 0:
|
||||
@@ -171,7 +181,7 @@ def allocate_cost(activity_id: int, period: str, product_code: str,
|
||||
if not act or act.driver_rate == 0:
|
||||
return {"error": "分配率未设置"}
|
||||
allocated = round(driver_consumed * act.driver_rate, 2)
|
||||
alloc = AbcAllocation(
|
||||
alloc_kwargs = dict(
|
||||
period=period,
|
||||
activity_id=activity_id,
|
||||
product_code=product_code,
|
||||
@@ -179,6 +189,9 @@ def allocate_cost(activity_id: int, period: str, product_code: str,
|
||||
driver_consumed=driver_consumed,
|
||||
allocated_cost=allocated,
|
||||
)
|
||||
if entity_id is not None:
|
||||
alloc_kwargs["entity_id"] = entity_id
|
||||
alloc = AbcAllocation(**alloc_kwargs)
|
||||
db.add(alloc)
|
||||
db.commit()
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,11 @@ from typing import Optional
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
|
||||
# 裁定 R1/R2/R3(2026-09-19 财务口径):demo 账套排除 / 期间窗口 / 规整月期间
|
||||
from app.utils.alert_entity_scope import (
|
||||
excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period,
|
||||
guard_alert_period, # R5-补1(2026-09-19):期间可归期 + 关联 kpi_values
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cma.deviation")
|
||||
|
||||
@@ -76,13 +81,46 @@ def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Opt
|
||||
return plan.budget_value if plan else None
|
||||
|
||||
|
||||
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
|
||||
"""获取指定KPI在指定期间的实际值"""
|
||||
val = db.query(KPIValue).filter(
|
||||
def get_kpi_value_row(db, kpi_id: int, period: str):
|
||||
"""取 (kpi_id, period) 的 KPIValue 行(取行口径唯一来源)。
|
||||
|
||||
与 get_actual_for_kpi 同源:预警行关联的 kpi_values 行 = 数值来源那一行。
|
||||
"""
|
||||
return db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == period,
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
return val.actual_value if val else None
|
||||
|
||||
|
||||
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
|
||||
"""获取指定KPI在指定期间的实际值"""
|
||||
row = get_kpi_value_row(db, kpi_id, period)
|
||||
return row.actual_value if row else None
|
||||
|
||||
|
||||
def resolve_alert_kpi_value(db, kpi, period: str):
|
||||
"""R5-补1(财务裁定 2026-09-19):解析差异/趋势预警行必须关联的 `kpi_values` 行。
|
||||
|
||||
裁定原文:「此类预警行必须在落库时写入 `kpi_value_id`,指向 **同 `entity_id` +
|
||||
同 `kpi_id` + 文案所示规整月期间** 的 `kpi_values` 行。文案期间不可解析为 `YYYY-MM`,
|
||||
或该 (kpi_id, period) 无 `kpi_values` 行 → 按 R5 `guard_alert_period` 跳过不入库
|
||||
(不得以 `kpi_value_id=NULL` 落行——『文案里有期间』不构成可归期)。」
|
||||
|
||||
- 账套约束由 `kpi_id` 传递:KPIDefinition 归属单一账套,故 kpi_id 已唯一确定 entity_id;
|
||||
取行口径与 `get_kpi_value_row`(= `get_actual_for_kpi` 的来源行)完全一致,
|
||||
保证「预警关联的那一行」就是该预警数值的来源行,不会关联到别的账套/别的期间。
|
||||
- 返回 `(kpi_value_row | None, skip_reason | None)`;跳过原因供调用点计数与日志。
|
||||
"""
|
||||
if not guard_alert_period(period, source="deviation_engine.resolve_alert_kpi_value",
|
||||
detail="kpi=%s" % getattr(kpi, "kpi_code", "")):
|
||||
return None, "R5_non_attributable_period"
|
||||
row = get_kpi_value_row(db, kpi.id, period)
|
||||
if row is None:
|
||||
logger.info(
|
||||
"R5-补1 期间关联:跳过不入库(该 (kpi, period) 无 kpi_values 行,期间不可派生)"
|
||||
" kpi=%s period=%s", getattr(kpi, "kpi_code", ""), period)
|
||||
return None, "no_kpi_value_row"
|
||||
return row, None
|
||||
|
||||
|
||||
def calc_period_deviation(db, kpi_id: int, period: str) -> dict:
|
||||
@@ -311,16 +349,37 @@ def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: fl
|
||||
|
||||
|
||||
def run_deviation_check(db_session, period: str = None) -> int:
|
||||
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)"""
|
||||
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)
|
||||
|
||||
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope`):
|
||||
· R1 —— `status='demo'` 的账套不参与遍历(demo 仅演示,不产生业务预警);
|
||||
· R2/R3 —— 期间须为规整月 `YYYY-MM` 且落在窗口(当月往前 6 个自然月,含当月)内。
|
||||
· R5-补1 —— 差异/趋势分支**必须落 `kpi_value_id` 关联**(同 kpi 同期间的 `kpi_values` 行);
|
||||
文案期间不可解析为 `YYYY-MM` 或该 (kpi, period) 无 `kpi_values` 行 → 跳过不入库;
|
||||
趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键
|
||||
= `(entity_id, kpi_id, kpi_value_id, alert_type)`。
|
||||
"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
if not period_in_window(period):
|
||||
logger.warning(" 差异预警跳过(R2/R3): 期间 %s 非规整月或早于窗口下限 %s",
|
||||
period, window_lower_period())
|
||||
return 0
|
||||
|
||||
excluded = excluded_entity_ids(db_session)
|
||||
kpis = db_session.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active"
|
||||
).all()
|
||||
|
||||
new_count = 0
|
||||
skipped_demo = 0
|
||||
skipped_period_linkage = 0
|
||||
skipped_linkage_kpis = []
|
||||
for kpi in kpis:
|
||||
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
|
||||
if kpi_excluded(db_session, kpi, excluded):
|
||||
skipped_demo += 1
|
||||
continue
|
||||
# 1. 差异预警:实际 vs 预算(统一逻辑)
|
||||
result = build_deviation_alert(db_session, kpi, period)
|
||||
if result["triggered"]:
|
||||
@@ -330,51 +389,76 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
||||
f"vs 预算{deviation['budget_value']},"
|
||||
f"差异率{deviation['deviation_rate']}%"
|
||||
)
|
||||
alert_type = result["alert_type"] or "actual"
|
||||
|
||||
# 检查是否已有同KPI同期间的差异预警
|
||||
existing = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[差异预警]"),
|
||||
KPIAlert.alert_message.contains(period),
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
# R5-补1(裁定 2026-09-19):期间可归期 + 必须落 kpi_value_id 关联
|
||||
#(文案里的期间不构成可归期;无 kpi_values 行 → 跳过不入库)
|
||||
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, period)
|
||||
if linked_kv is None:
|
||||
skipped_period_linkage += 1
|
||||
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, period))
|
||||
else:
|
||||
# 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type)
|
||||
#(原按 `alert_message contains period` 判断,改关联后按可归期目标去重)
|
||||
existing = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == (kpi.entity_id or 1),
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.kpi_value_id == linked_kv.id,
|
||||
KPIAlert.alert_type == alert_type,
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
|
||||
kpi_id=kpi.id,
|
||||
alert_level=result["kpi_alert_level"],
|
||||
alert_message=f"[差异预警] {alert_msg}",
|
||||
alert_type=result["alert_type"] or "actual",
|
||||
suggestion=result["suggestion"],
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
|
||||
kpi_id=kpi.id,
|
||||
kpi_value_id=linked_kv.id, # R5-补1: 期间经该行可派生(可归期)
|
||||
alert_level=result["kpi_alert_level"],
|
||||
alert_message=f"[差异预警] {alert_msg}",
|
||||
alert_type=alert_type,
|
||||
suggestion=result["suggestion"],
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
|
||||
# 2. 趋势异常检测(每期检查连续3期)
|
||||
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
|
||||
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
|
||||
trend_alert_msg = trend["message"]
|
||||
existing_trend = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[趋势预警]"),
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
# R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过
|
||||
trend_periods = trend.get("periods") or []
|
||||
base_period = trend_periods[-1] if trend_periods else period
|
||||
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, base_period)
|
||||
if linked_kv is None:
|
||||
skipped_period_linkage += 1
|
||||
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, base_period))
|
||||
else:
|
||||
existing_trend = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[趋势预警]"),
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
|
||||
if not existing_trend:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
alert_level=trend["level"],
|
||||
alert_message=f"[趋势预警] {trend_alert_msg}",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
|
||||
if not existing_trend:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
kpi_value_id=linked_kv.id, # R5-补1: 趋势末端当期关联
|
||||
alert_level=trend["level"],
|
||||
alert_message=f"[趋势预警] {trend_alert_msg}",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
|
||||
|
||||
if skipped_demo:
|
||||
logger.info(" 差异预警跳过 demo 账套 KPI %s 项(R1,裁定 2026-09-19)", skipped_demo)
|
||||
if skipped_period_linkage:
|
||||
logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s",
|
||||
skipped_period_linkage, skipped_linkage_kpis)
|
||||
db_session.commit()
|
||||
return new_count
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ from datetime import datetime, timedelta
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog
|
||||
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
|
||||
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
|
||||
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
|
||||
from app.utils.caliber_units import resolve_caliber
|
||||
# 裁定 R1/R2/R3(2026-09-19 财务口径):账套范围(demo 排除)/ 期间窗口 / 规整月期间
|
||||
from app.utils.alert_entity_scope import (
|
||||
ALERT_WINDOW_MONTHS, excluded_entity_ids, is_month_period, kpi_excluded,
|
||||
period_in_window, window_lower_period,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cma.alert_gen")
|
||||
|
||||
@@ -85,8 +90,55 @@ def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
|
||||
return True, msg
|
||||
|
||||
|
||||
def action_plan_entity_guard(db_session, *, kpi, alert, stats: Optional[dict] = None) -> bool:
|
||||
"""写前同源校验(2026-09-22 派生单 B4)—— 建行动计划的账套归属门。
|
||||
|
||||
口径照抄同族守卫姿势(`app/api/bot_bridge_v2.py` 的 R1 账套守卫 / B3(b) `skip_alert` 留痕):
|
||||
· 来源账套 = KPI 定义归属(`kpi.entity_id`);
|
||||
· 目标账套 = **同一次事务里刚建的预警归属**(`alert.entity_id`,即本次遍历账套);
|
||||
· 两者都存在且相等 → 返回 True(调用方建计划并**显式写** `entity_id=kpi.entity_id`);
|
||||
· 任一为空 / 不相等 → 返回 False:**不建计划**、落 `OperationLog(action="skip_plan")` 留痕、
|
||||
计入统计输出;**不 raise、不中断流水线**(预警与后续 KPI 照常处理)。
|
||||
|
||||
为什么必须设门而不是「照传 None」:`action_plans.entity_id` 为 NOT NULL DEFAULT 1
|
||||
(模型 `default=1` 与生产 DDL 双默认)——传空值会被**静默兜底到账套 1**,
|
||||
正是「酣客(e1) 工作台渲染博海(e2)/演示(e6) KPI 名」的成因(同事务内预警归属正确、
|
||||
计划归属落 1,只有 KPI 不属于 e1 时暴露)。
|
||||
"""
|
||||
kpi_entity_id = getattr(kpi, "entity_id", None)
|
||||
target_entity_id = getattr(alert, "entity_id", None)
|
||||
if (kpi_entity_id is not None and target_entity_id is not None
|
||||
and int(kpi_entity_id) == int(target_entity_id)):
|
||||
return True
|
||||
|
||||
st = stats if stats is not None else {}
|
||||
st["skipped_plan_entity_mismatch"] = st.get("skipped_plan_entity_mismatch", 0) + 1
|
||||
st.setdefault("skipped_plan_kpis", []).append(
|
||||
"%s[kpi_e=%s→plan_e=%s]" % (getattr(kpi, "kpi_code", None), kpi_entity_id, target_entity_id))
|
||||
db_session.add(OperationLog(
|
||||
user_id=None,
|
||||
action="skip_plan",
|
||||
target_type="action_plan",
|
||||
target_id=getattr(alert, "id", None),
|
||||
detail={
|
||||
"reason": "plan_entity_attribution_mismatch",
|
||||
"kpi_id": getattr(kpi, "id", None),
|
||||
"kpi_code": getattr(kpi, "kpi_code", None),
|
||||
"kpi_entity_id": kpi_entity_id,
|
||||
"entity_id": target_entity_id,
|
||||
"alert_id": getattr(alert, "id", None),
|
||||
"source": "scripts.alert_generator.run_alert_check",
|
||||
},
|
||||
))
|
||||
logger.warning(
|
||||
"B4 账套守卫:跳过建计划(KPI 归属=%s / 目标账套=%s 不一致或为空;"
|
||||
"不再静默落默认账套 1)kpi_code=%s alert_id=%s",
|
||||
kpi_entity_id, target_entity_id, getattr(kpi, "kpi_code", None), getattr(alert, "id", None))
|
||||
return False
|
||||
|
||||
|
||||
def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = False,
|
||||
stats: Optional[dict] = None) -> int:
|
||||
stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
|
||||
"""检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数
|
||||
|
||||
2026-09-17 财务口径裁定 2a/2b:
|
||||
@@ -95,12 +147,35 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
|
||||
· **单位(2a)**:判定前经 resolve_caliber() 归一到「百分比(%)」口径(取数×100,
|
||||
与阈值 150/80 同量纲);**未核对/疑似单位错配**的项降级待核 → 不落预警、不推送;
|
||||
· dry_run=True → 只计算不写库(抽查复核与放行前证据用)。
|
||||
|
||||
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope` 单一事实源):
|
||||
· **R1 账套**:`status='demo'` 的账套(演示账套)不参与遍历 → 跳过并计
|
||||
`skipped_demo_kpis`;
|
||||
· **R2 期间窗口**:仅生成「当月往前 6 个自然月(含当月)」内的预警;更早期间跳过
|
||||
(`out_of_window`),与 2b 叠加 = max(各 KPI 最近可用期间, 当月-6月);
|
||||
· **R3 伪期间**:仅规整月期间 YYYY-MM 参与,H1/H2/FY 跳过(`skipped_pseudo_period`)。
|
||||
· now 参数仅用于口径复算/测试(缺省=系统当前时间)。
|
||||
|
||||
2026-09-22(本单·第 4 处写入点):red/yellow 自动建计划前经 `action_plan_entity_guard`
|
||||
写前同源校验,并**显式写** `ActionPlan.entity_id = kpi.entity_id` —— 修复前不传归属、
|
||||
由模型 default=1 / 生产 DDL DEFAULT 1 静默兜底,导致非 e1 账套的 KPI 触发时落跨账套计划。
|
||||
"""
|
||||
st = stats if stats is not None else {}
|
||||
window_lower = window_lower_period(now)
|
||||
excluded_eids = excluded_entity_ids(db_session)
|
||||
for k, v in (("period_source", "fixed" if period else "per_kpi_latest"), ("period_fixed", period),
|
||||
("periods_used", {}), ("normalized", 0), ("normalized_kpis", []),
|
||||
("dimension_suspect", 0), ("suspect_kpis", []), ("no_period", 0),
|
||||
("no_value", 0), ("planned", []), ("skipped_existing", 0)):
|
||||
("no_value", 0), ("planned", []), ("skipped_existing", 0),
|
||||
# R1/R2/R3 口径证据
|
||||
("excluded_demo_entities", sorted(excluded_eids)),
|
||||
("window_lower_period", window_lower),
|
||||
("window_months", ALERT_WINDOW_MONTHS),
|
||||
("skipped_demo_kpis", 0), ("demo_kpis", []),
|
||||
("skipped_pseudo_period", 0), ("pseudo_period_kpis", []),
|
||||
("out_of_window", 0), ("out_of_window_kpis", []),
|
||||
# 2026-09-22 计划归属守卫(B4)证据
|
||||
("skipped_plan_entity_mismatch", 0), ("skipped_plan_kpis", [])):
|
||||
st.setdefault(k, v)
|
||||
|
||||
kpis = db_session.query(KPIDefinition).filter(
|
||||
@@ -109,6 +184,12 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
|
||||
|
||||
new_count = 0
|
||||
for kpi in kpis:
|
||||
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
|
||||
if kpi_excluded(db_session, kpi, excluded_eids):
|
||||
st["skipped_demo_kpis"] += 1
|
||||
st["demo_kpis"].append("%s/e%s" % (kpi.kpi_code, kpi.entity_id or 1))
|
||||
continue
|
||||
|
||||
# 跳过无阈值的KPI
|
||||
thr = {
|
||||
"red": kpi.threshold_red,
|
||||
@@ -125,6 +206,21 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
|
||||
logger.warning(f" 跳过(无可用期间): {kpi.kpi_name} 无任何 YYYY-MM 实测值")
|
||||
continue
|
||||
|
||||
# R3(裁定 2026-09-19):仅规整月期间 YYYY-MM 参与,伪期间一律不参与
|
||||
if not is_month_period(p):
|
||||
st["skipped_pseudo_period"] += 1
|
||||
st["pseudo_period_kpis"].append("%s[%s]" % (kpi.kpi_code, p))
|
||||
logger.warning(" 跳过(伪期间 R3): %s 期间=%s 非 YYYY-MM" % (kpi.kpi_name, p))
|
||||
continue
|
||||
|
||||
# R2(裁定 2026-09-19):期间窗口 = 当月往前 6 个自然月(含当月),更早不新生成
|
||||
if str(p) < window_lower:
|
||||
st["out_of_window"] += 1
|
||||
st["out_of_window_kpis"].append("%s[%s]" % (kpi.kpi_code, p))
|
||||
logger.info(" 跳过(期间超出窗口 R2,窗口下限 %s): %s[%s]" % (
|
||||
window_lower, kpi.kpi_name, p))
|
||||
continue
|
||||
|
||||
# 获取该KPI该期间的最新值
|
||||
latest = db_session.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
@@ -214,19 +310,25 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
|
||||
plan_title = f"渠补谈判: {msg} —— 下游渠道返利谈判"
|
||||
elif kpi.kpi_code == "F_COST_RATIO":
|
||||
plan_title = f"费用压缩: {msg} —— 管理费削减计划"
|
||||
plan = ActionPlan(
|
||||
alert_id=alert.id,
|
||||
kpi_id=kpi.id,
|
||||
title=plan_title,
|
||||
assignee="财务部",
|
||||
priority="high" if level == "red" else "medium",
|
||||
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
|
||||
created_by="alert_generator",
|
||||
)
|
||||
db_session.add(plan)
|
||||
db_session.flush()
|
||||
alert.action_plan_linked_id = plan.id
|
||||
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
|
||||
# 2026-09-22(本单主修·第 4 处写入点):写前同源校验 + 显式写归属。
|
||||
# 修复前构造 ActionPlan 不传 entity_id → 模型 default=1 / DDL DEFAULT 1
|
||||
# 静默兜底,非 e1 账套的 KPI 触发即产生「预警归属正确、计划归属落 1」的
|
||||
# 跨账套计划(酣客工作台渲染博海/demo KPI 名的唯一活写入点)。
|
||||
if action_plan_entity_guard(db_session, kpi=kpi, alert=alert, stats=st):
|
||||
plan = ActionPlan(
|
||||
entity_id=kpi.entity_id, # 归属唯一事实源:来源 KPI 的账套
|
||||
alert_id=alert.id,
|
||||
kpi_id=kpi.id,
|
||||
title=plan_title,
|
||||
assignee="财务部",
|
||||
priority="high" if level == "red" else "medium",
|
||||
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
|
||||
created_by="alert_generator",
|
||||
)
|
||||
db_session.add(plan)
|
||||
db_session.flush()
|
||||
alert.action_plan_linked_id = plan.id
|
||||
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
|
||||
break # 只取最高级别
|
||||
elif level == "red" and not triggered:
|
||||
# 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯,
|
||||
@@ -261,7 +363,8 @@ def run_rule_engine_checks(db_session) -> dict:
|
||||
AlertRule, run_forecast_deviation_check, run_customer_concentration_check,
|
||||
)
|
||||
|
||||
stats = {"forecast_deviation": 0, "customer_concentration": 0}
|
||||
stats = {"forecast_deviation": 0, "customer_concentration": 0,
|
||||
"skipped_demo_entities": 0, "excluded_entities": sorted(excluded_entity_ids(db_session))}
|
||||
try:
|
||||
rows = db_session.query(AlertRule.entity_id).filter(
|
||||
AlertRule.enabled == 1,
|
||||
@@ -272,6 +375,11 @@ def run_rule_engine_checks(db_session) -> dict:
|
||||
return stats
|
||||
|
||||
for (eid,) in rows:
|
||||
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
|
||||
if int(eid or 1) in set(stats["excluded_entities"]):
|
||||
stats["skipped_demo_entities"] += 1
|
||||
logger.info(f" 规则引擎跳过(demo 账套 R1): entity={eid}")
|
||||
continue
|
||||
for key, fn in (("forecast_deviation", run_forecast_deviation_check),
|
||||
("customer_concentration", run_customer_concentration_check)):
|
||||
try:
|
||||
@@ -285,24 +393,42 @@ def run_rule_engine_checks(db_session) -> dict:
|
||||
return stats
|
||||
|
||||
|
||||
def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool = False) -> dict:
|
||||
def _enabled_channel_count(db_session) -> int:
|
||||
"""R4(裁定 2026-09-19):推送出口计数 —— 生成/推送解耦,空通道只记录不报错。"""
|
||||
from app.models import NotificationChannel
|
||||
try:
|
||||
return int(db_session.query(NotificationChannel).filter(
|
||||
NotificationChannel.enabled == True # noqa: E712 - 与其他调用点一致
|
||||
).count())
|
||||
except Exception as e: # pragma: no cover - 防御分支
|
||||
logger.warning(f"读取通知渠道失败(按无渠道处理): {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool = False,
|
||||
now: Optional[datetime] = None) -> dict:
|
||||
"""生成预警并推送,返回统计
|
||||
|
||||
2026-09-17 财务口径裁定 2a/2b:
|
||||
· **period=None** → 判定期间 = 各 KPI「最近可用期间」(裁定2b,不再硬编码当月);
|
||||
显式给出 period → 固定该期间(抽查复核用);
|
||||
· **dry_run=True** → 零写入、零推送:返回将生成的预警清单与口径证据(放行前抽查复核)。
|
||||
|
||||
2026-09-19 裁定 R1–R4:
|
||||
· R1/R2/R3 在 run_alert_check 内落地(demo 账套排除 / 期间窗口 / 规整月期间);
|
||||
· **R4 生成与推送解耦**:`notification_channels` 为空时仅入库、不报错、不阻断;
|
||||
"""
|
||||
cur_month = datetime.now().strftime("%Y-%m")
|
||||
cur_month = (now or datetime.now()).strftime("%Y-%m")
|
||||
st = {}
|
||||
logger.info("开始预警检查(当月=%s;判定期间=%s)..." % (
|
||||
cur_month, ("固定 %s" % period) if period else "各 KPI 最近可用期间(裁定2b)"))
|
||||
logger.info("开始预警检查(当月=%s;判定期间=%s;窗口下限=%s;排除账套=%s)..." % (
|
||||
cur_month, ("固定 %s" % period) if period else "各 KPI 最近可用期间(裁定2b)",
|
||||
window_lower_period(now), sorted(excluded_entity_ids(db_session))))
|
||||
|
||||
# 0. Bug1修复: 先清理历史green预警(dry-run 不写)
|
||||
cleaned = 0 if dry_run else cleanup_green_alerts(db_session)
|
||||
|
||||
# 1. 生成预警(期间口径 + 单位口径均在此落地)
|
||||
new_count = run_alert_check(db_session, period=period, dry_run=dry_run, stats=st)
|
||||
new_count = run_alert_check(db_session, period=period, dry_run=dry_run, stats=st, now=now)
|
||||
logger.info(f"预警检查完成: {'将新增' if dry_run else '新增'} {new_count} 条")
|
||||
|
||||
# 1a. 覆盖度自检(2026-09-16 项3;2026-09-17 裁定2b 过渡期保留并扩展「期间回落」明细):
|
||||
@@ -338,19 +464,29 @@ def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool =
|
||||
else run_rule_engine_checks(db_session)
|
||||
|
||||
# 2. 推送(dry-run 零推送)
|
||||
# R4(裁定 2026-09-19):生成与推送解耦 —— notification_channels 为空时
|
||||
# **仅入库、不报错、不阻断**(推送出口缺失只是"未触达",不等于"未生成")。
|
||||
channels_enabled = _enabled_channel_count(db_session)
|
||||
push_skipped_no_channel = channels_enabled == 0
|
||||
pushed = 0
|
||||
if new_count > 0 and not dry_run:
|
||||
try:
|
||||
from app.utils.notifier import push_pending_alerts
|
||||
pushed = push_pending_alerts(db_session)
|
||||
logger.info(f"推送完成: {pushed} 条")
|
||||
except Exception as e:
|
||||
logger.error(f"推送失败: {e}")
|
||||
if push_skipped_no_channel:
|
||||
logger.info("推送出口缺失(notification_channels 启用数=0)→ 仅入库、不推送、不阻断(裁定 R4)")
|
||||
else:
|
||||
try:
|
||||
from app.utils.notifier import push_pending_alerts
|
||||
pushed = push_pending_alerts(db_session)
|
||||
logger.info(f"推送完成: {pushed} 条")
|
||||
except Exception as e:
|
||||
logger.error(f"推送失败(不影响已入库预警,生成/推送解耦 R4): {e}")
|
||||
|
||||
return {"period": cur_month, "dry_run": dry_run,
|
||||
"period_source": st.get("period_source"), "period_fixed": st.get("period_fixed"),
|
||||
"periods_used": st.get("periods_used", {}),
|
||||
"new_alerts": new_count, "pushed": pushed,
|
||||
# R4:推送出口(生成/推送解耦证据)
|
||||
"channels_enabled": channels_enabled,
|
||||
"push_skipped_no_channel": push_skipped_no_channel,
|
||||
"cleaned_green": cleaned, "rule_engine": rule_stats,
|
||||
"coverage": coverage,
|
||||
# 裁定2a 口径证据
|
||||
@@ -358,7 +494,17 @@ def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool =
|
||||
"dimension_suspect": st.get("dimension_suspect", 0), "suspect_kpis": st.get("suspect_kpis", []),
|
||||
# 期间/取值覆盖明细
|
||||
"no_period": st.get("no_period", 0), "no_value": st.get("no_value", 0),
|
||||
"skipped_existing": st.get("skipped_existing", 0), "planned": st.get("planned", [])}
|
||||
"skipped_existing": st.get("skipped_existing", 0), "planned": st.get("planned", []),
|
||||
# 裁定 R1/R2/R3 口径证据
|
||||
"excluded_demo_entities": st.get("excluded_demo_entities", []),
|
||||
"skipped_demo_kpis": st.get("skipped_demo_kpis", 0),
|
||||
"window_lower_period": st.get("window_lower_period"), "window_months": st.get("window_months"),
|
||||
"out_of_window": st.get("out_of_window", 0), "out_of_window_kpis": st.get("out_of_window_kpis", []),
|
||||
"skipped_pseudo_period": st.get("skipped_pseudo_period", 0),
|
||||
"pseudo_period_kpis": st.get("pseudo_period_kpis", []),
|
||||
# 2026-09-22 计划归属守卫(B4):归属不一致/为空 → 跳过建计划的留痕计数
|
||||
"skipped_plan_entity_mismatch": st.get("skipped_plan_entity_mismatch", 0),
|
||||
"skipped_plan_kpis": st.get("skipped_plan_kpis", [])}
|
||||
|
||||
|
||||
def coverage_note(result: dict) -> str:
|
||||
@@ -380,6 +526,23 @@ def coverage_note(result: dict) -> str:
|
||||
% (result["dimension_suspect"], (result.get("suspect_kpis") or [])[:3]))
|
||||
if result.get("normalized"):
|
||||
parts.append(" | 口径归一 ×100 %s 项" % result["normalized"])
|
||||
# 裁定 R1/R2/R3/R4(2026-09-19)证据
|
||||
if result.get("skipped_demo_kpis"):
|
||||
parts.append(" | R1 排除 demo 账套 %s(跳过 KPI %s 项)"
|
||||
% (result.get("excluded_demo_entities"), result["skipped_demo_kpis"]))
|
||||
if result.get("out_of_window"):
|
||||
parts.append(" | R2 窗口(下限 %s)外不新生成 %s 项"
|
||||
% (result.get("window_lower_period"), result["out_of_window"]))
|
||||
if result.get("skipped_pseudo_period"):
|
||||
parts.append(" | R3 伪期间跳过 %s 项" % result["skipped_pseudo_period"])
|
||||
if result.get("push_skipped_no_channel"):
|
||||
parts.append(" | R4 推送出口缺失(启用渠道 %s) → 仅入库、不阻断"
|
||||
% result.get("channels_enabled"))
|
||||
# 2026-09-22 计划归属守卫(B4):跨账套计划不再静默落默认账套 1
|
||||
if result.get("skipped_plan_entity_mismatch"):
|
||||
parts.append(" | B4 计划归属守卫:跳过建计划 %s 项 %s(KPI 归属与目标账套不一致/为空)"
|
||||
% (result["skipped_plan_entity_mismatch"],
|
||||
(result.get("skipped_plan_kpis") or [])[:3]))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ import re
|
||||
from sqlalchemy import func
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIValue, KPIDefinition, VoucherDetail, KPIAlert
|
||||
# 裁定 R1(2026-09-19 财务口径):demo 账套不产生业务预警(派生值仍落库,仅跳过预警同步)
|
||||
from app.utils.alert_entity_scope import is_excluded_entity
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("cma.bep_deriver")
|
||||
@@ -320,7 +322,11 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float,
|
||||
# 5. 同步预警: 仅对本派生KPI评估 threshold(green=正常不落 pending,yellow/red 落 pending)。
|
||||
# 幂等规则:同一 KPIValue 已有同级别 pending → 保持不动(值未变不重建);
|
||||
# 其它/旧级别 pending → resolve;最新派生行永远只留一条当前级别。
|
||||
for code, _ in TARGET_KPIS:
|
||||
# R1(裁定 2026-09-19):demo 账套不产生业务预警 → 整段预警同步跳过(派生值照落库)。
|
||||
target_sync = [] if is_excluded_entity(db, entity_id) else TARGET_KPIS
|
||||
if not target_sync:
|
||||
logger.info("demo 账套(entity=%s)跳过预警同步(R1,裁定 2026-09-19)", entity_id)
|
||||
for code, _ in target_sync:
|
||||
kpi = kpis[code]
|
||||
kpi_value = value_rows.get(kpi.id)
|
||||
if not kpi_value:
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全局键拆账套 — 一次性搬运(数据迁移,非 DDL)
|
||||
|
||||
任务:`dama-workbench-entity-isolation-fullstack-20260922` §三 B1/B3(a)
|
||||
(DAMA 诊断 / 项目Bot batch5 §B 方案段 / 任总 2026-09-22「都批」)
|
||||
|
||||
背景
|
||||
----
|
||||
`system_configs.config_key` 与 `role_permissions.key` 均为 **unique 单行键** ⇒
|
||||
下列两个「全局键」全库只有一行,被所有账套共用(本次「酣客工作台显示博海数据」
|
||||
污染的同源结构性缺陷):
|
||||
|
||||
键 处置
|
||||
------------------------------- ------------------------------------------
|
||||
system_configs.cash.current_balance → cash.current_balance.<entity_id>
|
||||
role_permissions.alignment_config → alignment_config.<entity_id>
|
||||
|
||||
代码侧(读写)已改为**只认带账套后缀的新键**(方案 A,零 DDL):
|
||||
backend/app/utils/cash_forecast_engine.py (get_/set_current_cash_balance)
|
||||
backend/app/api/cash.py (POST /balance、网银流水联动)
|
||||
backend/app/api/alignment.py (GET/POST /config、GET /tree)
|
||||
|
||||
本脚本 = **旧值一次性搬运**(把原来唯一的那一行值落到「当前生产账套」名下)。
|
||||
「当前生产账套」判定:默认 `--target-entity 1`(entity 1 = 陕西酣客,生产账套),
|
||||
`--check` 会打印**逐账套凭证推导的货币资金期末余额**与旧键值的比对,供人工确认
|
||||
(哪个账套的 1001/1002 期末余额 == 旧键值 ⇒ 该值本来就是它写的)。
|
||||
|
||||
口径(须与代码注释一致)
|
||||
------------------------
|
||||
· 新键值 = 旧键值(一次性搬运)**只落到 target-entity**;**其余账套留空**
|
||||
⇒ 留空语义 = 该账套现金基线走 KPI 实际值兜底 / 预测引擎默认值,
|
||||
**不跨账套借用**(正是本次污染的成因)。
|
||||
· 旧键**原样保留不删**(回退杠杆:回退老代码即可继续读旧键)。
|
||||
· alignment_config 同理:旧全局值落到 target-entity;其余账套 configured=false。
|
||||
· 幂等:新键已存在 → 不覆盖(除非 `--force`);二跑 `--apply` 零副作用。
|
||||
|
||||
用法
|
||||
----
|
||||
# 只读预检(默认动作,零写入)
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --check
|
||||
|
||||
# 写库(**需审批 / 生产写**;--apply 才写)
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --target-entity 2
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --force # 覆盖已存在的新键
|
||||
|
||||
# 回退(删新键 + 把旧全局键写回原值)
|
||||
python3 scripts/migrate_cash_balance_key_entity_20260922.py --down
|
||||
|
||||
选项
|
||||
----
|
||||
--target-entity N 旧值落哪个账套(默认 1;--check 会给出证据建议)
|
||||
--force 新键已存在时允许覆盖(默认拒绝,保留既有值)
|
||||
--json 机器可读输出
|
||||
--allow-db NAME 生产库名保护豁免(默认只允许库名 `cma`)
|
||||
|
||||
退出码
|
||||
------
|
||||
0 成功 | 1 执行失败 | 2 用法/保护拒绝(库名不符、目标账套不存在)
|
||||
|
||||
⚠️ 本脚本写的是**生产业务库**:不得在开发级会话直接 `--apply`。
|
||||
依据《生产资产派单纪律》(skills-shared/operations/prod-asset-dispatch-discipline):
|
||||
碰生产资产一律走派单 + 审批;`--check` 同样是生产读,须留单痕。
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import text # noqa: E402
|
||||
|
||||
CASH_KEY = "cash.current_balance" # 旧全局键(保留不删)
|
||||
ALIGN_KEY = "alignment_config" # 旧全局键(保留不删)
|
||||
PROD_DB_NAME = "cma" # 生产库名(保护口径)
|
||||
|
||||
CASH_DESC = "当前现金余额(万元),资金预测基线 — 账套 {eid}"
|
||||
ALIGN_DESC = "对齐模式配置 — 账套 {eid}(B3(a) 拆账套 2026-09-22)"
|
||||
|
||||
|
||||
def _engine():
|
||||
from app.database import get_engine
|
||||
return get_engine()
|
||||
|
||||
|
||||
def _scalar(conn, sql, **params):
|
||||
row = conn.execute(text(sql), params).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _detect_db_name(conn) -> str:
|
||||
try:
|
||||
return _scalar(conn, "SELECT DATABASE()") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _cash_ledger_by_entity(conn):
|
||||
"""逐账套:货币资金(1001/1002, carry_forward=0) 期末净额(元 → 万元)
|
||||
|
||||
与 `cash.py::_sync_cash_balance_from_ledger` 同口径(该函数正是写旧键的调用方)
|
||||
——用于回答「-9.3 这个值原本是哪家写的」。
|
||||
"""
|
||||
out = {}
|
||||
try:
|
||||
rows = conn.execute(text(
|
||||
"SELECT entity_id, "
|
||||
" ROUND(SUM(COALESCE(debit_amount,0) - COALESCE(credit_amount,0))/10000, 4) AS wan, "
|
||||
" COUNT(*) AS n "
|
||||
"FROM voucher_details "
|
||||
"WHERE carry_forward = 0 AND subject_code IS NOT NULL "
|
||||
" AND (subject_code LIKE '1001%' OR subject_code LIKE '1002%') "
|
||||
"GROUP BY entity_id ORDER BY entity_id"
|
||||
)).fetchall()
|
||||
for r in rows:
|
||||
out[int(r[0])] = {"cash_wan": float(r[1] or 0), "voucher_rows": int(r[2] or 0)}
|
||||
except Exception as e: # 表缺失/权限 → 只报告,不阻断
|
||||
out["_error"] = str(e)[:300]
|
||||
return out
|
||||
|
||||
|
||||
def _snapshot(conn):
|
||||
"""只读快照:新旧键现状 + 逐账套凭证推导余额"""
|
||||
snap = {
|
||||
"db": _detect_db_name(conn),
|
||||
"legacy": {
|
||||
"cash.current_balance": None,
|
||||
"alignment_config": None,
|
||||
},
|
||||
"new_keys": {"cash": {}, "alignment": {}},
|
||||
"entities": [],
|
||||
"cash_ledger_by_entity": _cash_ledger_by_entity(conn),
|
||||
}
|
||||
|
||||
row = conn.execute(text(
|
||||
"SELECT config_value, description FROM system_configs WHERE config_key = :k"
|
||||
), {"k": CASH_KEY}).fetchone()
|
||||
if row:
|
||||
snap["legacy"]["cash.current_balance"] = {"config_value": row[0], "description": row[1]}
|
||||
|
||||
row = conn.execute(text(
|
||||
"SELECT value FROM role_permissions WHERE `key` = :k"
|
||||
), {"k": ALIGN_KEY}).fetchone()
|
||||
if row:
|
||||
snap["legacy"]["alignment_config"] = {"value": row[0]}
|
||||
|
||||
for r in conn.execute(text(
|
||||
"SELECT id, name, short_name, status FROM entities ORDER BY id"
|
||||
)).fetchall():
|
||||
snap["entities"].append({"id": int(r[0]), "name": r[1], "short_name": r[2], "status": r[3]})
|
||||
|
||||
for r in conn.execute(text(
|
||||
"SELECT config_key, config_value FROM system_configs "
|
||||
"WHERE config_key LIKE :p ORDER BY config_key"
|
||||
), {"p": CASH_KEY + ".%"}).fetchall():
|
||||
snap["new_keys"]["cash"][r[0]] = r[1]
|
||||
|
||||
for r in conn.execute(text(
|
||||
"SELECT `key`, value FROM role_permissions WHERE `key` LIKE :p ORDER BY `key`"
|
||||
), {"p": ALIGN_KEY + ".%"}).fetchall():
|
||||
snap["new_keys"]["alignment"][r[0]] = r[1]
|
||||
|
||||
# 建议目标账套(只读推断):旧值 == 某账套凭证推导余额
|
||||
suggestion = None
|
||||
legacy_cash = (snap["legacy"]["cash.current_balance"] or {}).get("config_value")
|
||||
if legacy_cash is not None:
|
||||
try:
|
||||
want = float(legacy_cash)
|
||||
for eid, info in snap["cash_ledger_by_entity"].items():
|
||||
if isinstance(eid, int) and abs(float(info["cash_wan"]) - want) < 1e-6:
|
||||
suggestion = eid
|
||||
break
|
||||
except Exception:
|
||||
suggestion = None
|
||||
snap["suggested_target_entity"] = suggestion
|
||||
return snap
|
||||
|
||||
|
||||
def cmd_check(args):
|
||||
eng = _engine()
|
||||
with eng.connect() as conn:
|
||||
snap = _snapshot(conn)
|
||||
if args.json:
|
||||
print(json.dumps(snap, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
print(f"目标库: {snap['db']}")
|
||||
print(f"旧全局键 cash.current_balance : {snap['legacy']['cash.current_balance']}")
|
||||
print(f"旧全局键 alignment_config : {snap['legacy']['alignment_config']}")
|
||||
print(f"已存在新键(cash) : {snap['new_keys']['cash']}")
|
||||
print(f"已存在新键(alignment) : {snap['new_keys']['alignment']}")
|
||||
print("逐账套凭证推导货币资金期末余额(万元):")
|
||||
for eid, info in snap["cash_ledger_by_entity"].items():
|
||||
print(f" entity {eid}: {info}")
|
||||
print(f"建议 --target-entity : {snap['suggested_target_entity']}"
|
||||
f"(旧值与他家凭证推导余额一致时给出;None = 无匹配,按默认 1 或人工裁定)")
|
||||
print("【只读预检,零写入】")
|
||||
return 0
|
||||
|
||||
|
||||
def _apply_cash(conn, target: int, force: bool, dry: bool):
|
||||
new_key = f"{CASH_KEY}.{target}"
|
||||
row = conn.execute(text(
|
||||
"SELECT config_value FROM system_configs WHERE config_key = :k"
|
||||
), {"k": CASH_KEY}).fetchone()
|
||||
if not row:
|
||||
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
|
||||
legacy_value = row[0]
|
||||
|
||||
exist = conn.execute(text(
|
||||
"SELECT config_value FROM system_configs WHERE config_key = :k"
|
||||
), {"k": new_key}).fetchone()
|
||||
if exist and not force:
|
||||
return {"key": new_key, "action": "noop",
|
||||
"reason": "新键已存在且未 --force(保留既有值,幂等)",
|
||||
"existing_value": exist[0]}
|
||||
if dry:
|
||||
return {"key": new_key, "action": "would-insert-or-update",
|
||||
"value": legacy_value, "existing_value": exist[0] if exist else None}
|
||||
|
||||
if exist:
|
||||
conn.execute(text(
|
||||
"UPDATE system_configs SET config_value = :v, description = :d WHERE config_key = :k"
|
||||
), {"v": legacy_value, "d": CASH_DESC.format(eid=target), "k": new_key})
|
||||
action = "updated"
|
||||
else:
|
||||
conn.execute(text(
|
||||
"INSERT INTO system_configs (config_key, config_value, description) "
|
||||
"VALUES (:k, :v, :d)"
|
||||
), {"k": new_key, "v": legacy_value, "d": CASH_DESC.format(eid=target)})
|
||||
action = "inserted"
|
||||
return {"key": new_key, "action": action, "value": legacy_value}
|
||||
|
||||
|
||||
def _apply_alignment(conn, target: int, force: bool, dry: bool):
|
||||
new_key = f"{ALIGN_KEY}.{target}"
|
||||
row = conn.execute(text(
|
||||
"SELECT value FROM role_permissions WHERE `key` = :k"
|
||||
), {"k": ALIGN_KEY}).fetchone()
|
||||
if not row:
|
||||
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
|
||||
|
||||
exist = conn.execute(text(
|
||||
"SELECT value FROM role_permissions WHERE `key` = :k"
|
||||
), {"k": new_key}).fetchone()
|
||||
if exist and not force:
|
||||
return {"key": new_key, "action": "noop",
|
||||
"reason": "新键已存在且未 --force(保留既有值,幂等)",
|
||||
"existing_value": exist[0]}
|
||||
if dry:
|
||||
return {"key": new_key, "action": "would-insert", "value": row[0]}
|
||||
|
||||
conn.execute(text(
|
||||
"INSERT INTO role_permissions (`key`, value) VALUES (:k, :v)"
|
||||
), {"k": new_key, "v": row[0]})
|
||||
return {"key": new_key, "action": "inserted", "value": row[0]}
|
||||
|
||||
|
||||
def _guard_db(args, conn):
|
||||
db = _detect_db_name(conn)
|
||||
if args.allow_db:
|
||||
return db
|
||||
if db != PROD_DB_NAME:
|
||||
print(f"[拒绝] 库名 {db!r} ≠ 生产库 {PROD_DB_NAME!r};"
|
||||
f"如确为预期,请显式加 --allow-db {db}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return db
|
||||
|
||||
|
||||
def cmd_apply(args):
|
||||
eng = _engine()
|
||||
results = []
|
||||
with eng.begin() as conn:
|
||||
db = _guard_db(args, conn)
|
||||
# 目标账套存在性校验(防写出孤儿键)
|
||||
ent = conn.execute(text("SELECT id FROM entities WHERE id = :i"),
|
||||
{"i": args.target_entity}).fetchone()
|
||||
if not ent:
|
||||
print(f"[拒绝] 目标账套 entity_id={args.target_entity} 不存在", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
results.append(_apply_cash(conn, args.target_entity, args.force, dry=False))
|
||||
results.append(_apply_alignment(conn, args.target_entity, args.force, dry=False))
|
||||
out = {"mode": "apply", "db": db, "target_entity": args.target_entity, "results": results}
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
|
||||
"\n".join(f" {r}" for r in results))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_down(args):
|
||||
"""回退:删除新键(旧全局键原样保留,老代码即可继续读)"""
|
||||
eng = _engine()
|
||||
removed = []
|
||||
with eng.begin() as conn:
|
||||
db = _guard_db(args, conn)
|
||||
r1 = conn.execute(text(
|
||||
"DELETE FROM system_configs WHERE config_key LIKE :p"), {"p": CASH_KEY + ".%"})
|
||||
r2 = conn.execute(text(
|
||||
"DELETE FROM role_permissions WHERE `key` LIKE :p"), {"p": ALIGN_KEY + ".%"})
|
||||
removed = [f"system_configs(+{CASH_KEY}.*) deleted={r1.rowcount}",
|
||||
f"role_permissions({ALIGN_KEY}.*) deleted={r2.rowcount}"]
|
||||
print(json.dumps({"mode": "down", "db": db, "removed": removed},
|
||||
ensure_ascii=False, indent=2) if args.json else "\n".join(removed))
|
||||
print("旧全局键未改动 → 代码回退到老版本即可继续读旧键。")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="全局键拆账套 · 一次性搬运(cash.current_balance / alignment_config)")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--check", action="store_true", help="只读预检(默认)")
|
||||
g.add_argument("--apply", action="store_true", help="写库(需审批)")
|
||||
g.add_argument("--down", action="store_true", help="回退:删除新键")
|
||||
p.add_argument("--target-entity", type=int, default=1,
|
||||
help="旧值落到哪个账套(默认 1 = 陕西酣客,生产账套)")
|
||||
p.add_argument("--force", action="store_true", help="允许覆盖已存在的新键")
|
||||
p.add_argument("--json", action="store_true", help="机器可读输出")
|
||||
p.add_argument("--allow-db", default=None, help="生产库名保护豁免(默认仅允许 cma)")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.apply:
|
||||
return cmd_apply(args)
|
||||
if args.down:
|
||||
return cmd_down(args)
|
||||
return cmd_check(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -8,8 +8,9 @@ G1 修正项 :P0-1 up() 语句过滤器 / P0-2 计数口径 / P1-3 down() 部
|
||||
|
||||
用法:
|
||||
python3 treasury_migrate.py --parse-only # 零 DB:只解析 DDL,打印命中语句与期望计数(P0-1 复算证据)
|
||||
python3 treasury_migrate.py --grant-plan # 零 DB:打印授权收敛语句计划(授权面幂等自证证据)
|
||||
python3 treasury_migrate.py --check # 只读预检:缺表/缺列/缺索引/种子行数(零写入,默认动作)
|
||||
python3 treasury_migrate.py --apply # 建表 + 加列 + 索引 + 种子(幂等)
|
||||
python3 treasury_migrate.py --apply # 建表 + 加列 + 索引 + 种子 + 授权收敛(幂等)
|
||||
python3 treasury_migrate.py --down # 回滚(先 dump 后删;有数据时要求 --yes)
|
||||
|
||||
幂等契约(P0-2 计数口径 = 新增对象数,不是执行语句数):
|
||||
@@ -17,12 +18,28 @@ G1 修正项 :P0-1 up() 语句过滤器 / P0-2 计数口径 / P1-3 down() 部
|
||||
二跑 --apply 期望:created_new=0 / added_cols=0 / added_indexes=0 / 种子行数不变
|
||||
指纹行:import_logs(batch=treasury-ddl-<sha1[:10]>),按 (import_type,batch) 先查后写,
|
||||
不依赖表上是否有唯一键(P2-7:entity_id=1 为约定——迁移指纹属实体无关元数据)
|
||||
边界:只动 6 张 treasury_* 新表 + cash_plans 加列/加索引;不碰 cash.py / 既有端点契约 / 其他模块表结构。
|
||||
|
||||
授权面契约(2026-09-18 F-1 根因侧:部署管道不再扩面,而是「按白名单最小面 + 收敛」):
|
||||
旧形态(本文件 2026-09-18 前)= 每次 --apply 无条件 `GRANT SELECT,INSERT,UPDATE,DELETE`
|
||||
于 6 表 × 2 host = 12 条 → 白名单外的权限(表级 SELECT / balances·payments 的 DELETE /
|
||||
hits 的 UPDATE,DELETE)每次部署回涨,基线巡检恒 BASELINE_MISMATCH。
|
||||
新形态 = 授权集合取自白名单文件 table_ops(授权面唯一事实源),并对白名单外权限发 REVOKE:
|
||||
· 语句计划 grant_plan() 是**纯函数**(只依赖 spec,不读现网)⇒ 连跑两次逐字相同;
|
||||
· 重复执行结果一致(幂等):白名单内 GRANT 幂等,白名单外 REVOKE 在收敛态报 1141
|
||||
(no such grant)→ 视为幂等空操作记账,不报错;
|
||||
· 执行后回读 information_schema.TABLE_PRIVILEGES 断言逐表 == 白名单,不符则非零退出。
|
||||
白名单文件路径:env `CMA_WRITE_WHITELIST` 优先,默认 /root/WB_WS/_reports/_cma_write_table_whitelist.json;
|
||||
文件不可用时退回 TREASURY_GRANT_SPEC_FALLBACK(须与白名单一致,否则 stderr 告警);文件在位但
|
||||
不合规(缺表裁定 / 不可解析 / 空集 / 含表级 SELECT)→ fail-closed,不猜不降级。
|
||||
|
||||
边界:只动 6 张 treasury_* 新表 + cash_plans 加列/加索引 + cma_api 授权收敛;
|
||||
不碰 cash.py / 既有端点契约 / 其他模块表结构 / 司库业务读写路径。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -60,6 +77,21 @@ CASH_PLAN_ALTER = """ALTER TABLE cash_plans
|
||||
DEFAULT_SOCKET = "/var/run/mysqld/mysqld.sock"
|
||||
FORBIDDEN_RUN_ACCOUNTS = {"cma_api"}
|
||||
GRANT_APP_ACCOUNT = "cma_api"
|
||||
GRANT_HOSTS = ("localhost", "127.0.0.1")
|
||||
# 授权面域:本迁移步只可能授出这 4 种(旧形态的全集);收敛 = 该域内白名单外的项一律 REVOKE
|
||||
GRANT_PRIV_DOMAIN = ("SELECT", "INSERT", "UPDATE", "DELETE")
|
||||
# 白名单文件 = 授权面唯一事实源(后续白名单变更只改该文件一处)
|
||||
WHITELIST_PATH = os.getenv(
|
||||
"CMA_WRITE_WHITELIST", "/root/WB_WS/_reports/_cma_write_table_whitelist.json")
|
||||
# 兜底常量:与白名单 table_ops 的 6 表裁定逐表一致(文件不可用时的退路;不一致时 stderr 告警)
|
||||
TREASURY_GRANT_SPEC_FALLBACK = {
|
||||
"treasury_accounts": ("INSERT", "UPDATE", "DELETE"),
|
||||
"treasury_control_rules": ("INSERT", "UPDATE", "DELETE"),
|
||||
"treasury_limits": ("INSERT", "UPDATE", "DELETE"),
|
||||
"treasury_balances": ("INSERT", "UPDATE"),
|
||||
"treasury_payments": ("INSERT", "UPDATE"),
|
||||
"treasury_control_hits": ("INSERT",),
|
||||
}
|
||||
|
||||
|
||||
def get_engine():
|
||||
@@ -87,19 +119,161 @@ def get_engine():
|
||||
pool_pre_ping=True)
|
||||
|
||||
|
||||
def grant_app_account(conn):
|
||||
"""授权应用运行账号 cma_api 对 6 张 treasury 新表的 DML(幂等 GRANT,双 host)。
|
||||
必须由特权连接执行;否则应用端点读不到/写不进新表(1142/1044)。"""
|
||||
from app import database as db
|
||||
dbn = (os.getenv("CMA_MIGRATE_DB_NAME") or getattr(db, "DB_NAME", None) or "cma")
|
||||
granted = []
|
||||
for host in ("localhost", "127.0.0.1"):
|
||||
for t in NEW_TABLES:
|
||||
conn.execute(text(
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON `{dbn}`.`{t}` "
|
||||
f"TO '{GRANT_APP_ACCOUNT}'@{host}"))
|
||||
granted.append(f"{t}@{host}")
|
||||
return granted
|
||||
def _resolve_dbn():
|
||||
"""库名:env CMA_MIGRATE_DB_NAME 优先,否则应用配置(app.database.DB_NAME),最后 'cma'。"""
|
||||
name = (os.getenv("CMA_MIGRATE_DB_NAME") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
try:
|
||||
from app import database as db
|
||||
return getattr(db, "DB_NAME", None) or "cma"
|
||||
except Exception:
|
||||
return "cma"
|
||||
|
||||
|
||||
def _parse_priv_text(raw):
|
||||
"""白名单 table_ops 值 → 权限集合。值形如 'INSERT,UPDATE,DELETE (中文说明(含嵌套括号))'。"""
|
||||
head = str(raw)
|
||||
for sep in ("(", "("):
|
||||
i = head.find(sep)
|
||||
if i >= 0:
|
||||
head = head[:i]
|
||||
return {p.strip().upper() for p in head.split(",") if p.strip()}
|
||||
|
||||
|
||||
def load_grant_spec():
|
||||
"""读白名单 table_ops 的 6 张 treasury 表裁定 → (spec, source, note)。
|
||||
|
||||
spec : {table: (priv, ...)},权限按 GRANT_PRIV_DOMAIN 顺序归一(确定性)
|
||||
source : 'whitelist' | 'fallback-constant'
|
||||
note : 常量兜底与白名单不一致时的告警文本(否则 None)
|
||||
fail-closed:文件在位但不可解析 / 缺表裁定 / 空集 / 含表级 SELECT → SystemExit,不猜不降级。
|
||||
"""
|
||||
fallback = {t: tuple(TREASURY_GRANT_SPEC_FALLBACK[t]) for t in NEW_TABLES}
|
||||
if not os.path.exists(WHITELIST_PATH):
|
||||
return fallback, "fallback-constant", None
|
||||
try:
|
||||
with open(WHITELIST_PATH, encoding="utf-8") as fh:
|
||||
ops = (json.load(fh) or {}).get("table_ops") or {}
|
||||
except Exception as exc:
|
||||
raise SystemExit(f"[FAIL] 白名单不可解析 {WHITELIST_PATH}: {exc}")
|
||||
spec = {}
|
||||
for t in NEW_TABLES:
|
||||
raw = ops.get(t)
|
||||
if raw is None:
|
||||
raise SystemExit(f"[FAIL] 白名单 table_ops 缺 {t} 裁定({WHITELIST_PATH})")
|
||||
privs = tuple(p for p in GRANT_PRIV_DOMAIN if p in _parse_priv_text(raw))
|
||||
if not privs:
|
||||
raise SystemExit(f"[FAIL] 白名单 {t} 授权集为空({raw!r})")
|
||||
if "SELECT" in privs:
|
||||
raise SystemExit(
|
||||
f"[FAIL] 白名单 {t} 含表级 SELECT —— 读面由库级 SELECT ON cma.* 覆盖,"
|
||||
"表级 SELECT 正是本单要收敛的冗余;如需改基线口径请回报调度方另单")
|
||||
spec[t] = privs
|
||||
note = None
|
||||
if spec != fallback:
|
||||
note = (f"兜底常量与白名单不一致(以白名单为准): fallback={fallback} whitelist={spec}")
|
||||
return spec, "whitelist", note
|
||||
|
||||
|
||||
def grant_plan(spec, dbn):
|
||||
"""授权收敛语句计划(纯函数:只依赖 spec/dbn,不读现网)⇒ 连跑两次逐字相同。
|
||||
|
||||
每 (host, 表):先 REVOKE 白名单外权限(部署成为「收敛」通道),再 GRANT 白名单集合。
|
||||
收敛态下 REVOKE 会报 1141(no such grant)→ 执行侧记为幂等空操作,非错误。
|
||||
"""
|
||||
stmts = []
|
||||
for host in GRANT_HOSTS:
|
||||
for t in sorted(NEW_TABLES):
|
||||
wanted = tuple(p for p in GRANT_PRIV_DOMAIN if p in spec[t])
|
||||
extras = tuple(p for p in GRANT_PRIV_DOMAIN if p not in wanted)
|
||||
if extras:
|
||||
stmts.append(
|
||||
f"REVOKE {', '.join(extras)} ON `{dbn}`.`{t}` FROM '{GRANT_APP_ACCOUNT}'@{host}")
|
||||
stmts.append(
|
||||
f"GRANT {', '.join(wanted)} ON `{dbn}`.`{t}` TO '{GRANT_APP_ACCOUNT}'@{host}")
|
||||
return stmts
|
||||
|
||||
|
||||
def _is_nonexistent_grant(exc) -> bool:
|
||||
"""MySQL 1141/1147(no such grant):REVOKE 未持有权限 —— 收敛态的正常返回。"""
|
||||
orig = getattr(exc, "orig", exc)
|
||||
args = getattr(orig, "args", ()) or ()
|
||||
if args and args[0] in (1141, 1147):
|
||||
return True
|
||||
return "no such grant" in str(exc).lower()
|
||||
|
||||
|
||||
_GRANT_STMT_RE = re.compile(
|
||||
r"^(?P<verb>REVOKE|GRANT)\s+(?P<privs>.+?)\s+ON\s+`(?P<db>[^`]+)`\.`(?P<table>[^`]+)`"
|
||||
r"\s+(?:FROM|TO)\s+'(?P<user>[^']+)'@(?P<host>\S+)$")
|
||||
|
||||
|
||||
def _stmt_entry(stmt: str) -> dict:
|
||||
"""授权语句 → {verb, privs, db, table, user, host}(解析失败即抛,不静默)。"""
|
||||
m = _GRANT_STMT_RE.match(stmt.strip())
|
||||
if not m:
|
||||
raise RuntimeError(f"授权语句形态非预期,无法解析: {stmt!r}")
|
||||
d = m.groupdict()
|
||||
d["privs"] = ", ".join(p.strip() for p in d["privs"].split(","))
|
||||
return d
|
||||
|
||||
|
||||
def _current_table_privs(conn, dbn):
|
||||
"""只读回查 information_schema.TABLE_PRIVILEGES:{(host, table): {priv}}(仅 cma_api)。"""
|
||||
rows = conn.execute(text(
|
||||
"SELECT GRANTEE, TABLE_NAME, PRIVILEGE_TYPE FROM information_schema.TABLE_PRIVILEGES "
|
||||
"WHERE TABLE_SCHEMA = :d"), {"d": dbn}).fetchall()
|
||||
out = {}
|
||||
for grantee, table, priv in rows:
|
||||
if table not in NEW_TABLES:
|
||||
continue
|
||||
user, _sep, host = str(grantee).rpartition("@")
|
||||
if user.strip().strip("'") != GRANT_APP_ACCOUNT:
|
||||
continue
|
||||
out.setdefault((host.strip().strip("'"), table), set()).add(str(priv).upper())
|
||||
return out
|
||||
|
||||
|
||||
def verify_grant_sync(conn, spec, dbn):
|
||||
"""收敛断言:逐 (host, 表) 现网表级权限集合 == 白名单集合;返回差异清单(空 = 已收敛)。"""
|
||||
current = _current_table_privs(conn, dbn)
|
||||
bad = []
|
||||
for host in GRANT_HOSTS:
|
||||
for t in sorted(NEW_TABLES):
|
||||
have = current.get((host, t), set())
|
||||
want = set(spec[t])
|
||||
if have != want:
|
||||
bad.append(f"{t}@{host}: 现持={sorted(have)} 期望={sorted(want)}")
|
||||
return bad
|
||||
|
||||
|
||||
def apply_grant_sync(conn, spec, source):
|
||||
"""执行授权收敛(按白名单最小面 + 幂等):REVOKE 白名单外 + GRANT 白名单,回读断言。"""
|
||||
dbn = _resolve_dbn()
|
||||
granted, revoked, absent = [], [], []
|
||||
for stmt in grant_plan(spec, dbn):
|
||||
try:
|
||||
conn.execute(text(stmt))
|
||||
except Exception as exc:
|
||||
if stmt.startswith("REVOKE") and _is_nonexistent_grant(exc):
|
||||
absent.append(_stmt_entry(stmt))
|
||||
continue
|
||||
raise
|
||||
(granted if stmt.startswith("GRANT") else revoked).append(_stmt_entry(stmt))
|
||||
conn.commit()
|
||||
mismatch = verify_grant_sync(conn, spec, dbn)
|
||||
return {
|
||||
"spec_source": source,
|
||||
"spec": {t: ",".join(spec[t]) for t in sorted(spec)},
|
||||
"granted": [f"{e['table']}@{e['host']}" for e in granted],
|
||||
"revoked": [f"{e['table']}@{e['host']} ({e['privs']}) [REVOKE]" for e in revoked],
|
||||
"revoke_absent": [f"{e['table']}@{e['host']} ({e['privs']}) [REVOKE-ABSENT]"
|
||||
for e in absent],
|
||||
"statements": len(granted) + len(revoked) + len(absent),
|
||||
"converged": not mismatch,
|
||||
"converged_mismatch": mismatch,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -279,8 +453,13 @@ def up(conn):
|
||||
if CASH_PLAN_NEW_INDEX not in before_idx:
|
||||
conn.execute(text(p["index"]))
|
||||
|
||||
# ③b 授权应用账号 cma_api 对本 6 表 DML(幂等 GRANT,须特权连接)
|
||||
granted = grant_app_account(conn)
|
||||
# ③b 授权收敛(按白名单最小面 + 幂等,须特权连接):REVOKE 白名单外 + GRANT 白名单
|
||||
# 旧形态为无条件 12 条全量 GRANT(含表级 SELECT 等白名单外权限)→ 每次部署扩面,
|
||||
# 基线巡检恒 BASELINE_MISMATCH(2026-09-18 F-1)。现改为收敛:重复执行结果不变。
|
||||
spec, spec_source, spec_note = load_grant_spec()
|
||||
if spec_note:
|
||||
print("[warn] " + spec_note, file=sys.stderr)
|
||||
grant_sync = apply_grant_sync(conn, spec, spec_source)
|
||||
|
||||
# ④ 种子(ON DUPLICATE KEY UPDATE,数据级幂等)
|
||||
for stmt in p["seeds"]:
|
||||
@@ -315,10 +494,14 @@ def up(conn):
|
||||
"limits_rows": counts["limits_rows"],
|
||||
"import_logs_row": action,
|
||||
"batch": batch,
|
||||
"granted": granted,
|
||||
"granted": grant_sync["granted"],
|
||||
"grant_sync": grant_sync,
|
||||
"missing_tables": post["missing_tables"],
|
||||
"cash_plans_rows": post["cash_plans_rows"],
|
||||
}, ensure_ascii=False))
|
||||
if not grant_sync["converged"]:
|
||||
print(f"❌ 授权收敛未达成 {grant_sync['converged_mismatch']}(非零退出)", file=sys.stderr)
|
||||
return 6
|
||||
return 0
|
||||
|
||||
|
||||
@@ -380,6 +563,28 @@ def down(conn):
|
||||
# 入口
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def grant_plan_only():
|
||||
"""零 DB:打印授权收敛语句计划(幂等自证证据:连跑两次逐字相同)。"""
|
||||
spec, source, note = load_grant_spec()
|
||||
dbn = _resolve_dbn()
|
||||
stmts = grant_plan(spec, dbn)
|
||||
out = {
|
||||
"mode": "grant-plan",
|
||||
"whitelist_path": WHITELIST_PATH,
|
||||
"whitelist_present": os.path.exists(WHITELIST_PATH),
|
||||
"spec_source": source,
|
||||
"spec_note": note,
|
||||
"spec": {t: ",".join(spec[t]) for t in sorted(spec)},
|
||||
"hosts": list(GRANT_HOSTS),
|
||||
"db_name": dbn,
|
||||
"statement_count": len(stmts),
|
||||
"statements": stmts,
|
||||
"statements_md5": hashlib.md5("\n".join(stmts).encode("utf-8")).hexdigest(),
|
||||
}
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def parse_only():
|
||||
"""零 DB 的 DDL 解析复算(P0-1 证据):命中 6 条 CREATE + 表名清单 + 种子语句。"""
|
||||
p = parse_ddl()
|
||||
@@ -407,6 +612,7 @@ def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
g = ap.add_mutually_exclusive_group()
|
||||
g.add_argument("--parse-only", action="store_true", help="零 DB:只解析 DDL(P0-1 复算证据)")
|
||||
g.add_argument("--grant-plan", action="store_true", help="零 DB:打印授权收敛语句计划")
|
||||
g.add_argument("--check", action="store_true", help="只读预检(默认)")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
g.add_argument("--down", action="store_true")
|
||||
@@ -415,6 +621,8 @@ def main():
|
||||
|
||||
if args.parse_only:
|
||||
return parse_only()
|
||||
if args.grant_plan:
|
||||
return grant_plan_only()
|
||||
|
||||
engine = get_engine()
|
||||
with engine.connect() as conn:
|
||||
|
||||
@@ -15,6 +15,54 @@ os.environ.setdefault("CMA_DB_HOST", "localhost")
|
||||
os.environ.setdefault("CMA_DB_PORT", "3306")
|
||||
os.environ.setdefault("CMA_DB_NAME", "test")
|
||||
|
||||
# ── 测试期生产 Redis 隔离(project-20260918-P1-test-redis-token-fix)────────────
|
||||
# 机制(详见任务单/报告):
|
||||
# app.auth_middleware 在 import 时构造 redis 客户端(127.0.0.1:6379 db=1 = 生产库),
|
||||
# ping 成功即 _redis_available=True → create_token() 走 setex("cma:token:<hex>", 86400)
|
||||
# 把测试签发的 token 写进生产 Redis;而 require_auth() 只按 token 里的 user_id 反查用户、
|
||||
# 不比对口令,测试库首个用户 id=1 与生产 users.id=1 撞号 —— 每跑一轮套件都会在生产 Redis
|
||||
# 留下 24h 有效、可直接当生产会话使用的 token。
|
||||
# 处置(只改测试侧,不动 app/auth_middleware.py 的任何业务逻辑):
|
||||
# ① 在导入任何 app 模块之前,把 redis.Redis 构造器替换成阻断器(计数 + 抛错)——
|
||||
# app 侧既有的 `except Exception → _redis=None / _redis_available=False` 降级分支
|
||||
# 自动生效,测试期对生产 Redis **零连接**(连 TCP 探测都不发生);
|
||||
# ② 显式强制 _redis_available=False(进程内 _token_store 兜底,用例语义不变);
|
||||
# ③ app.utils.cache 同样硬编码 db=1(测试夹具会调 delete("ai")),一并关闭,
|
||||
# 使整轮测试对生产 Redis 零写命令;
|
||||
# ④ pytest_sessionfinish 落盘隔离审计 JSON(构造尝试次数 / 通道状态)作为可复核证据。
|
||||
_REDIS_GUARD = {"constructor_attempts": 0, "callers": []}
|
||||
|
||||
|
||||
class _BlockedProductionRedis:
|
||||
"""测试期阻断器:生产 Redis 客户端构造被计数并拒绝。
|
||||
|
||||
抛出的异常与真实连接失败同形(Exception),由 app 侧既有 except 分支兜底降级,
|
||||
因此不会改变任何鉴权/缓存语义,只保证测试进程不碰生产 Redis。
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
import traceback
|
||||
|
||||
_REDIS_GUARD["constructor_attempts"] += 1
|
||||
frames = [f for f in traceback.extract_stack()[:-1] if "conftest" not in f.filename]
|
||||
if frames:
|
||||
_REDIS_GUARD["callers"].append(f"{frames[-1].filename}:{frames[-1].lineno}")
|
||||
raise RuntimeError(
|
||||
"test-mode: production Redis (127.0.0.1:6379 db=1) blocked by backend/tests/conftest.py"
|
||||
)
|
||||
|
||||
|
||||
def _install_production_redis_guard() -> None:
|
||||
"""替换 redis.Redis 构造器(进程级,覆盖整轮 pytest 会话)。"""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
except Exception: # redis 未安装:app 侧本来就会降级
|
||||
return
|
||||
redis_lib.Redis = _BlockedProductionRedis
|
||||
|
||||
|
||||
_install_production_redis_guard()
|
||||
|
||||
import pytest
|
||||
from typing import Generator
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -71,6 +119,17 @@ event.listen(TEST_ENGINE, "connect", lambda dbapi_conn, rec: dbapi_conn.create_f
|
||||
db_module._engine = TEST_ENGINE
|
||||
db_module._SessionLocal = TEST_SESSION_LOCAL
|
||||
|
||||
# ── 测试期强制关闭 Redis 通道(生产 Redis 零写入硬隔离)──────────────────────
|
||||
# 不修改 app/auth_middleware.py:只置其模块级开关,走既有进程内 _token_store 降级分支。
|
||||
# 导入点上方的 _BlockedProductionRedis 已生效 → 这两次 import 不会连生产 Redis。
|
||||
import app.auth_middleware as auth_middleware # noqa: E402
|
||||
from app.utils import cache as cache_util # noqa: E402
|
||||
|
||||
auth_middleware._redis_available = False
|
||||
auth_middleware._redis = None # 导入期即被阻断,显式置空防后续被复活
|
||||
cache_util._available = False
|
||||
cache_util._client = None
|
||||
|
||||
# 然后导入models(它们依赖Base)
|
||||
from app.models import (
|
||||
User, StrategicMap, KPIDefinition, KPIValue, KPIAlert,
|
||||
@@ -101,6 +160,54 @@ def _disable_ai_suggestion_push(monkeypatch):
|
||||
monkeypatch.setattr(ai_analysis, "_push_decision_suggestion", lambda s: True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enforce_production_redis_isolation():
|
||||
"""每个用例前后强制断言:测试期 Redis 通道始终关闭(防用例把它翻回 True)。
|
||||
|
||||
project-20260918-P1-test-redis-token-fix:生产 Redis 写入的唯一入口是
|
||||
auth_middleware.create_token() 的 `if _redis_available` 分支 —— 用例级复检保证
|
||||
「一轮全量测试 = 零生产 Redis 写命令」,任何把开关翻回 True 的用例都会当场暴露。
|
||||
"""
|
||||
auth_middleware._redis_available = False
|
||||
cache_util._available = False
|
||||
yield
|
||||
assert auth_middleware._redis_available is False, "测试期禁止开启生产 Redis token 通道"
|
||||
assert cache_util._available is False, "测试期禁止开启生产 Redis 缓存通道"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def production_redis_isolation_state() -> dict:
|
||||
"""暴露测试期 Redis 隔离状态(构造阻断计数 + 通道开关),供断言用例使用。"""
|
||||
return _REDIS_GUARD
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""落盘本轮测试的 Redis 隔离审计(零生产 Redis 连接/写入的可复核证据)。"""
|
||||
import json
|
||||
import datetime as _dt
|
||||
|
||||
audit = {
|
||||
"generated_at": _dt.datetime.now().isoformat(timespec="seconds"),
|
||||
"task": "project-20260918-P1-test-redis-token-fix",
|
||||
"redis_constructor_attempts": _REDIS_GUARD["constructor_attempts"],
|
||||
"redis_constructor_blocked": _REDIS_GUARD["constructor_attempts"],
|
||||
"blocked_callsites": _REDIS_GUARD["callers"],
|
||||
"auth_middleware_redis_available": bool(auth_middleware._redis_available),
|
||||
"auth_middleware_redis_client_is_none": auth_middleware._redis is None,
|
||||
"cache_available": bool(cache_util._available),
|
||||
"cache_client_is_none": cache_util._client is None,
|
||||
"token_store_size": len(auth_middleware._token_store),
|
||||
"pytest_exitstatus": int(exitstatus),
|
||||
"violation": bool(auth_middleware._redis_available or cache_util._available),
|
||||
}
|
||||
path = os.environ.get("CMA_TEST_REDIS_AUDIT", "/tmp/cma-test-redis-isolation-audit.json")
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(audit, fh, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db() -> Generator[Session, None, None]:
|
||||
"""提供数据库 session"""
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""CMA 跨实体隔离 — ActionPlan 归属写侧缺失 + OKR 分解视图读侧缺失(2026-09-18 batch3 派生单)
|
||||
|
||||
任务:fullstack-20260918-1905b-actionplan-entity-write-and-okr-read
|
||||
父单:fullstack-20260918-1905-actionplan-objective-id-scope(OBS1/OBS2 只读侦查,
|
||||
证据 _reports/evidence-fullstack-20260918-1905-probe.txt)
|
||||
|
||||
缺陷(两处必须同单做,顺序铁律:先修缺陷1再修缺陷2):
|
||||
1. action_plans.py:create_plan 构造 ActionPlan(...) 不写 entity_id
|
||||
→ 落模型默认值 1。实测 e2 会话经 API 创建计划后库内 entity_id=1(OBS1)。
|
||||
现状 list_plans/stats/update/delete 全靠 join KPIDefinition 判账套,
|
||||
ActionPlan.entity_id 属"不可信字段"(已埋雷)。
|
||||
2. okr.py:get_okr_decomposition 的 weekly_actions 仅按 objective_id + due_date
|
||||
过滤,无账套条件 → 对端账套计划挂本账套目标时出现在本账套分解视图(OBS2)。
|
||||
若只修 2 不修 1,则 e2 自己经 API 新建的计划(entity_id=1)会从 e2 自己的
|
||||
分解视图消失(把脏归属变成用户可见的假阴性)。
|
||||
|
||||
本用例(每条双向 + 同账套正对照;改前失败条目见
|
||||
_reports/evidence-fullstack-20260918-1905b-red.txt):
|
||||
- T1 create_plan 落库写入会话账套(e1→1 / e2→2,双向正对照)【改前 e2 侧失败】
|
||||
- T2 本账套分解视图不含"对端账套计划挂本账套目标"的脏行;本账套合法计划仍出现
|
||||
(双向,正对照内嵌)【改前失败】
|
||||
- T3 本账套经 API 创建的计划出现在自己的分解视图(防缺陷1修好后被读侧条件误杀)【改前通过,
|
||||
仅修缺陷2不修缺陷1时失败 —— 顺序铁律的机器守卫】
|
||||
- T4 鉴权底线:无 token → 401(改前改后都应通过)
|
||||
"""
|
||||
import datetime as _dt
|
||||
from datetime import time as _time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ActionPlan, Entity, KPIDefinition, Objective
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
PLANS = "/api/cma/action-plans"
|
||||
OKR = "/api/cma/okr"
|
||||
|
||||
E1_KPI = "ZZ_APENT_E1_KPI_001"
|
||||
E2_KPI = "ZZ_APENT_E2_KPI_001"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _mk_objective(db: Session, eid: int, title: str, quarter: str = "2026Q3") -> Objective:
|
||||
obj = Objective(entity_id=eid, title=title, quarter=quarter, status="active")
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
return k1, k2
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _plans_by_title(db: Session, title: str):
|
||||
db.expire_all()
|
||||
return db.query(ActionPlan).filter(ActionPlan.title == title).all()
|
||||
|
||||
|
||||
def _this_week_due() -> _dt.datetime:
|
||||
"""本周内的时间点(分解视图只取 week_start..week_start+7d)"""
|
||||
today = _dt.date.today()
|
||||
return _dt.datetime.combine(today, _time(12, 0))
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
return _dt.date.today().isoformat()
|
||||
|
||||
|
||||
def _mk_dirty_plan(db: Session, kpi_id: int, dirty_entity_id: int, objective_id: int,
|
||||
title: str) -> ActionPlan:
|
||||
"""构造脏行:计划归属 dirty_entity_id,却挂在 objective_id(属别的账套)
|
||||
|
||||
直插模拟历史遗留行(写侧校验上线前落库的跨账套归属),不经过 API。
|
||||
"""
|
||||
plan = ActionPlan(
|
||||
entity_id=dirty_entity_id,
|
||||
kpi_id=kpi_id,
|
||||
objective_id=objective_id,
|
||||
title=title,
|
||||
assignee="张三",
|
||||
priority="medium",
|
||||
status="pending",
|
||||
progress=0,
|
||||
due_date=_this_week_due(),
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def _weekly_titles(client: TestClient, token: str, okr_id: int):
|
||||
r = client.get(f"{OKR}/{okr_id}/decomposition", headers=auth_header(token))
|
||||
assert r.status_code == 200, f"分解视图应 200,实际 {r.status_code} {r.text[:200]}"
|
||||
return {row["title"] for row in r.json()["weekly_actions"]}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T1. create_plan 落库写入会话账套(双向正对照)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_create_plan_writes_session_entity_both_directions(client: TestClient, db: Session):
|
||||
"""e1 会话 → 库内 entity_id=1;e2 会话 → 库内 entity_id=2(不得落模型默认值 1)"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
|
||||
t1 = "ZZ_APENT 归属 e1 创建"
|
||||
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
|
||||
json={"title": t1, "kpi_id": k1.id, "due_date": _today_iso()})
|
||||
assert r1.status_code == 200, f"e1 创建应 200,实际 {r1.status_code} {r1.text[:200]}"
|
||||
rows1 = _plans_by_title(db, t1)
|
||||
assert len(rows1) == 1, f"e1 创建的计划未落库: {rows1}"
|
||||
assert rows1[0].entity_id == 1, (
|
||||
f"e1 会话创建的计划 entity_id 应为 1,实际 {rows1[0].entity_id}"
|
||||
)
|
||||
|
||||
t2 = "ZZ_APENT 归属 e2 创建"
|
||||
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
|
||||
json={"title": t2, "kpi_id": k2.id, "due_date": _today_iso()})
|
||||
assert r2.status_code == 200, f"e2 创建应 200,实际 {r2.status_code} {r2.text[:200]}"
|
||||
rows2 = _plans_by_title(db, t2)
|
||||
assert len(rows2) == 1, f"e2 创建的计划未落库: {rows2}"
|
||||
assert rows2[0].entity_id == 2, (
|
||||
f"e2 会话创建的计划 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
|
||||
" —— create_plan 未写 entity_id,落模型默认值 1"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T2. 分解视图不含对端账套脏行、含本账套合法计划(双向)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_okr_decomposition_excludes_dirty_cross_entity_row(client: TestClient, db: Session):
|
||||
"""e1 视图:脏行(entity_id=2 挂 e1 目标)不出现,e1 合法计划仍出现(正对照)
|
||||
e2 视图:脏行(entity_id=1 挂 e2 目标)不出现,e2 合法计划仍出现"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标")
|
||||
obj2 = _mk_objective(db, 2, "e2 目标")
|
||||
|
||||
# e1 侧:脏行 = 归属 e2 的计划挂 e1 目标
|
||||
dirty_in_e1 = "ZZ_APENT 脏行e2挂e1目标"
|
||||
_mk_dirty_plan(db, k2.id, 2, obj1.id, dirty_in_e1)
|
||||
own_in_e1 = "ZZ_APENT e1合法计划"
|
||||
ro1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
|
||||
json={"title": own_in_e1, "kpi_id": k1.id,
|
||||
"objective_id": obj1.id, "due_date": _today_iso()})
|
||||
assert ro1.status_code == 200, ro1.text[:200]
|
||||
|
||||
titles1 = _weekly_titles(client, _login(client, 1), obj1.id)
|
||||
assert dirty_in_e1 not in titles1, (
|
||||
f"对端账套(entity_id=2)脏行出现在 e1 分解视图: {sorted(titles1)}"
|
||||
)
|
||||
assert own_in_e1 in titles1, (
|
||||
f"e1 本账套合法计划未出现在自己视图(正对照失效): {sorted(titles1)}"
|
||||
)
|
||||
|
||||
# e2 侧(反向):脏行 = 归属 e1 的计划挂 e2 目标
|
||||
dirty_in_e2 = "ZZ_APENT 脏行e1挂e2目标"
|
||||
_mk_dirty_plan(db, k1.id, 1, obj2.id, dirty_in_e2)
|
||||
own_in_e2 = "ZZ_APENT e2合法计划"
|
||||
ro2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
|
||||
json={"title": own_in_e2, "kpi_id": k2.id,
|
||||
"objective_id": obj2.id, "due_date": _today_iso()})
|
||||
assert ro2.status_code == 200, ro2.text[:200]
|
||||
|
||||
titles2 = _weekly_titles(client, _login(client, 2), obj2.id)
|
||||
assert dirty_in_e2 not in titles2, (
|
||||
f"对端账套(entity_id=1)脏行出现在 e2 分解视图: {sorted(titles2)}"
|
||||
)
|
||||
assert own_in_e2 in titles2, (
|
||||
f"e2 本账套合法计划未出现在自己视图(正对照失效): {sorted(titles2)}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T3. 本账套经 API 创建的计划出现在自己的分解视图(顺序铁律守卫)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_okr_decomposition_includes_own_api_created_plan(client: TestClient, db: Session):
|
||||
"""e2 经 API 建计划(写侧落 entity_id=2)→ 出现在 e2 自己的分解视图(双向)
|
||||
|
||||
这是缺陷 1 与缺陷 2 的顺序守卫:只修读侧条件而不修写侧归属时,
|
||||
e2 自己新建的计划(entity_id=1)会被读侧条件过滤掉(假阴性)。
|
||||
"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标")
|
||||
obj2 = _mk_objective(db, 2, "e2 目标")
|
||||
|
||||
t1 = "ZZ_APENT e1自建计划自见"
|
||||
assert client.post(PLANS, headers=auth_header(_login(client, 1)),
|
||||
json={"title": t1, "kpi_id": k1.id, "objective_id": obj1.id,
|
||||
"due_date": _today_iso()}).status_code == 200
|
||||
t2 = "ZZ_APENT e2自建计划自见"
|
||||
assert client.post(PLANS, headers=auth_header(_login(client, 2)),
|
||||
json={"title": t2, "kpi_id": k2.id, "objective_id": obj2.id,
|
||||
"due_date": _today_iso()}).status_code == 200
|
||||
|
||||
titles1 = _weekly_titles(client, _login(client, 1), obj1.id)
|
||||
assert t1 in titles1, f"e1 自建计划未出现在 e1 视图: {sorted(titles1)}"
|
||||
|
||||
titles2 = _weekly_titles(client, _login(client, 2), obj2.id)
|
||||
assert t2 in titles2, (
|
||||
f"e2 自建计划未出现在 e2 视图(读侧条件是否漏了写侧归属?): {sorted(titles2)}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T4. 鉴权底线
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_decomposition_without_token_401(client: TestClient, db: Session):
|
||||
"""无 token → 401(改前改后都应通过)"""
|
||||
_k1, _k2 = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标")
|
||||
r = client.get(f"{OKR}/{obj1.id}/decomposition")
|
||||
assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"
|
||||
@@ -0,0 +1,375 @@
|
||||
"""CMA 跨实体隔离 — ActionPlan 归属写侧「残余三处构造点」(2026-09-18 派生单)
|
||||
|
||||
任务:fullstack-20260918-2015-actionplan-entity-write-residual-sites
|
||||
父单:fullstack-20260918-1905b-actionplan-entity-write-and-okr-read
|
||||
(其 §五 范围外只读侦查登记的三处同族缺口)
|
||||
|
||||
缺陷(`ActionPlan.entity_id` 模型默认 1,以下三处构造均不写会话账套):
|
||||
1. app/api/alerts.py::create_action_plan_from_alert — 预警关联生成改善计划
|
||||
2. app/api/deviation_push.py::push_deviation_to_map — 差异反打自动生成回顾会议题
|
||||
3. app/api/ai_suggestions.py::_apply_action_plan — AI 建议转行动计划
|
||||
|
||||
后果:非 e1 账套经这三个入口创建的计划落 `entity_id=1`(不可信字段)。一旦读路径按
|
||||
`ActionPlan.entity_id` 过滤(1905b 已在 OKR 分解视图加该条件),即出现跨账套泄漏或
|
||||
「自己建的计划看不见」的假阴性。
|
||||
|
||||
本用例(改前失败条目见 _reports/evidence-fullstack-20260918-2015-red.txt):
|
||||
- T1 预警关联生成计划:e2 会话 → 库内 entity_id=2;e1 会话 → 1(双向 + 正对照);
|
||||
并附跨账套负对照(e2 token 打 e1 预警 → 404 且不落行)
|
||||
- T2 差异反打生成议题:e2 会话 → 库内 entity_id=2(kpi_id 亦须落在本账套 KPI 上);
|
||||
e1 会话 → 1(双向)
|
||||
- T3 差异反打去重按账套隔离:对端账套存在同名 pending 议题,不得抑制本账套创建
|
||||
- T4 AI 建议转计划:e2 会话 → 库内 entity_id=2;e1 会话 → 1(双向)
|
||||
- T5 AI 建议越权负对照:对端账套的建议不得被本账套会话应用(404 + 零落行)
|
||||
- T6 鉴权底线:三个入口无 token → 401(改前改后都应通过)
|
||||
"""
|
||||
import datetime as _dt
|
||||
from datetime import time as _time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ActionPlan, AISuggestion, Entity, KPIAlert, KPIDefinition, StrategicMap
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
ALERTS = "/api/cma/alerts"
|
||||
DPUSH = "/api/cma/deviation-push/push-to-map"
|
||||
SUGG = "/api/cma/ai/suggestions"
|
||||
|
||||
# kpi_code 全局唯一(models.KPIDefinition.kpi_code unique=True)→ 每个账套用独立 code
|
||||
E1_ALERT_KPI = "ZZ_APRES_E1_ALERT_KPI"
|
||||
E2_ALERT_KPI = "ZZ_APRES_E2_ALERT_KPI"
|
||||
E1_DPUSH_KPI = "ZZ_APRES_E1_DPUSH_KPI"
|
||||
E2_DPUSH_KPI = "ZZ_APRES_E2_DPUSH_KPI"
|
||||
E1_SUG_KPI = "ZZ_APRES_E1_SUG_KPI"
|
||||
E2_SUG_KPI = "ZZ_APRES_E2_SUG_KPI"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具(沿用 1905b 同族风格:双向登录 + 同账套正对照)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int, target: float = 1000.0) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=target,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _mk_alert(db: Session, eid: int, kpi_id: int, message: str) -> KPIAlert:
|
||||
alert = KPIAlert(
|
||||
entity_id=eid,
|
||||
kpi_id=kpi_id,
|
||||
alert_level="red",
|
||||
alert_message=message,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return alert
|
||||
|
||||
|
||||
def _mk_map(db: Session, eid: int, title: str, kpi_code: str, obj_name: str) -> StrategicMap:
|
||||
m = StrategicMap(
|
||||
entity_id=eid,
|
||||
title=title,
|
||||
version="v1.0",
|
||||
status="draft",
|
||||
dimensions=[{
|
||||
"key": "finance", "name": "财务维度", "icon": "F", "color": "#409eff",
|
||||
"objectives": [{"name": obj_name, "kpis": [kpi_code]}],
|
||||
}],
|
||||
canvas_data={"connections": []},
|
||||
)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _plans_by_title(db: Session, title: str):
|
||||
db.expire_all()
|
||||
return db.query(ActionPlan).filter(ActionPlan.title == title).all()
|
||||
|
||||
|
||||
def _alert_plan_title(message: str) -> str:
|
||||
return f"改善: {message}"
|
||||
|
||||
|
||||
def _dpush_topic_title(kpi: KPIDefinition, obj_name: str) -> str:
|
||||
return f"【差异反打】{kpi.kpi_name}偏差回写 — {obj_name}"
|
||||
|
||||
|
||||
def _period() -> str:
|
||||
return _dt.date.today().strftime("%Y-%m")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T1. 预警关联生成计划 → 会话账套(双向 + 跨账套负对照)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_alert_create_action_plan_writes_session_entity(client: TestClient, db: Session):
|
||||
"""e1 会话 → 库内 entity_id=1;e2 会话 → 库内 entity_id=2(不得落模型默认值 1)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_ALERT_KPI, 2)
|
||||
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 营收严重下滑")
|
||||
a2 = _mk_alert(db, 2, k2.id, "ZZ_APRES e2 营收严重下滑")
|
||||
|
||||
t1 = _alert_plan_title(a1.alert_message)
|
||||
r1 = client.post(f"{ALERTS}/{a1.id}/create-action-plan",
|
||||
headers=auth_header(_login(client, 1)), json={"assignee": "甲"})
|
||||
assert r1.status_code == 200, f"e1 创建应 200,实际 {r1.status_code} {r1.text[:200]}"
|
||||
rows1 = _plans_by_title(db, t1)
|
||||
assert len(rows1) == 1, f"e1 预警未生成计划: {rows1}"
|
||||
assert rows1[0].entity_id == 1, (
|
||||
f"e1 会话经预警生成计划的 entity_id 应为 1,实际 {rows1[0].entity_id}"
|
||||
)
|
||||
|
||||
t2 = _alert_plan_title(a2.alert_message)
|
||||
r2 = client.post(f"{ALERTS}/{a2.id}/create-action-plan",
|
||||
headers=auth_header(_login(client, 2)), json={"assignee": "乙"})
|
||||
assert r2.status_code == 200, f"e2 创建应 200,实际 {r2.status_code} {r2.text[:200]}"
|
||||
rows2 = _plans_by_title(db, t2)
|
||||
assert len(rows2) == 1, f"e2 预警未生成计划: {rows2}"
|
||||
assert rows2[0].entity_id == 2, (
|
||||
f"e2 会话经预警生成计划的 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
|
||||
" —— alerts.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
|
||||
)
|
||||
assert rows2[0].kpi_id == k2.id, (
|
||||
f"e2 计划应挂 e2 自己的 KPI#{k2.id},实际 #{rows2[0].kpi_id}"
|
||||
)
|
||||
|
||||
|
||||
def test_alert_create_action_plan_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e2 token 打 e1 预警 → 404(读侧既有护栏,不得因写侧修复而退化)+ 零落行"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
|
||||
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 跨账套负对照")
|
||||
|
||||
r = client.post(f"{ALERTS}/{a1.id}/create-action-plan",
|
||||
headers=auth_header(_login(client, 2)), json={"assignee": "乙"})
|
||||
assert r.status_code == 404, f"跨账套应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
assert _plans_by_title(db, _alert_plan_title(a1.alert_message)) == [], (
|
||||
"跨账套 create-action-plan 竟为对端账套预警建了计划"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T2. 差异反打生成议题 → 会话账套(双向)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_deviation_push_writes_session_entity(client: TestClient, db: Session):
|
||||
"""e1 会话 → 议题 entity_id=1;e2 会话 → 议题 entity_id=2(不得落默认值 1)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_DPUSH_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_DPUSH_KPI, 2)
|
||||
m1 = _mk_map(db, 1, "ZZ_APRES e1 地图", E1_DPUSH_KPI, "e1 目标")
|
||||
m2 = _mk_map(db, 2, "ZZ_APRES e2 地图", E2_DPUSH_KPI, "e2 目标")
|
||||
|
||||
t1 = _dpush_topic_title(k1, "e1 目标")
|
||||
r1 = client.post(DPUSH, headers=auth_header(_login(client, 1)), json={
|
||||
"mapId": m1.id, "nodeId": "finance-0", "newValue": 900.0,
|
||||
"period": _period(), "createReviewTopic": True,
|
||||
})
|
||||
assert r1.status_code == 200, f"e1 反打应 200,实际 {r1.status_code} {r1.text[:300]}"
|
||||
assert r1.json()["review_topic_created"] is True, r1.text[:200]
|
||||
rows1 = _plans_by_title(db, t1)
|
||||
assert len(rows1) == 1, f"e1 反打未生成议题: {rows1}"
|
||||
assert rows1[0].entity_id == 1, (
|
||||
f"e1 会话经差异反打生成议题的 entity_id 应为 1,实际 {rows1[0].entity_id}"
|
||||
)
|
||||
|
||||
t2 = _dpush_topic_title(k2, "e2 目标")
|
||||
r2 = client.post(DPUSH, headers=auth_header(_login(client, 2)), json={
|
||||
"mapId": m2.id, "nodeId": "finance-0", "newValue": 900.0,
|
||||
"period": _period(), "createReviewTopic": True,
|
||||
})
|
||||
assert r2.status_code == 200, f"e2 反打应 200,实际 {r2.status_code} {r2.text[:300]}"
|
||||
assert r2.json()["review_topic_created"] is True, r2.text[:200]
|
||||
rows2 = _plans_by_title(db, t2)
|
||||
assert len(rows2) == 1, f"e2 反打未生成议题: {rows2}"
|
||||
assert rows2[0].entity_id == 2, (
|
||||
f"e2 会话经差异反打生成议题的 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
|
||||
" —— deviation_push.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
|
||||
)
|
||||
assert rows2[0].kpi_id == k2.id, (
|
||||
f"e2 议题应挂 e2 自己的 KPI#{k2.id},实际 #{rows2[0].kpi_id}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T3. 差异反打去重按账套隔离(对端同名议题不得抑制本账套创建)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_deviation_push_dedupe_scoped_to_entity(client: TestClient, db: Session):
|
||||
"""e1 已有同名 pending 议题(含历史遗留 entity_id=1 行)不得抑制 e2 创建自己的议题"""
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_DPUSH_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_DPUSH_KPI, 2)
|
||||
m2 = _mk_map(db, 2, "ZZ_APRES e2 地图去重", E2_DPUSH_KPI, "e2 去重目标")
|
||||
|
||||
t2 = _dpush_topic_title(k2, "e2 去重目标")
|
||||
# 对端账套(e1)先落一条同标题的 pending 议题(模拟历史遗留/另一账套真实办理中)
|
||||
foreign = ActionPlan(
|
||||
entity_id=1, kpi_id=db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == E1_DPUSH_KPI).first().id,
|
||||
title=t2, assignee="甲", priority="medium", status="pending", progress=0,
|
||||
)
|
||||
db.add(foreign)
|
||||
db.commit()
|
||||
|
||||
r2 = client.post(DPUSH, headers=auth_header(_login(client, 2)), json={
|
||||
"mapId": m2.id, "nodeId": "finance-0", "newValue": 900.0,
|
||||
"period": _period(), "createReviewTopic": True,
|
||||
})
|
||||
assert r2.status_code == 200, f"e2 反打应 200,实际 {r2.status_code} {r2.text[:300]}"
|
||||
assert r2.json()["review_topic_created"] is True, (
|
||||
"对端账套同名议题抑制了本账套创建(去重条件未按账套隔离)"
|
||||
)
|
||||
rows = _plans_by_title(db, t2)
|
||||
assert len(rows) == 2, f"应有 e1 遗留 + e2 新建共 2 条,实际 {len(rows)}"
|
||||
own = [x for x in rows if x.entity_id == 2]
|
||||
assert len(own) == 1, (
|
||||
f"e2 本账套议题未落库或落错账套: {[(x.id, x.entity_id) for x in rows]}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T4. AI 建议转行动计划 → 会话账套(双向)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _mk_suggestion(client: TestClient, token: str, kpi_id: int, title: str):
|
||||
body = {
|
||||
"suggestion_type": "action_plan",
|
||||
"target_type": "kpi",
|
||||
"target_id": kpi_id,
|
||||
"title": title,
|
||||
"content": "达成率低于阈值",
|
||||
"suggestion_data": {"kpi_id": kpi_id},
|
||||
}
|
||||
r = client.post(SUGG, json=body, headers=auth_header(token))
|
||||
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
|
||||
return r.json()["data"]["id"]
|
||||
|
||||
|
||||
def test_ai_suggestion_action_plan_writes_session_entity(client: TestClient, db: Session):
|
||||
"""e1 会话 → 计划 entity_id=1;e2 会话 → 计划 entity_id=2(不得落默认值 1)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_SUG_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_SUG_KPI, 2)
|
||||
|
||||
tok1 = _login(client, 1)
|
||||
s1 = _mk_suggestion(client, tok1, k1.id, "ZZ_APRES e1 建议转计划")
|
||||
r1 = client.post(f"{SUGG}/{s1}/apply", headers=auth_header(tok1),
|
||||
json={"action": "action_plan", "title": "ZZ_APRES e1 转出的计划"})
|
||||
assert r1.status_code == 200, f"e1 应用应 200,实际 {r1.status_code} {r1.text[:300]}"
|
||||
rows1 = _plans_by_title(db, "ZZ_APRES e1 转出的计划")
|
||||
assert len(rows1) == 1, f"e1 应用未生成计划: {rows1}"
|
||||
assert rows1[0].entity_id == 1, (
|
||||
f"e1 会话经 AI 建议生成的计划 entity_id 应为 1,实际 {rows1[0].entity_id}"
|
||||
)
|
||||
|
||||
tok2 = _login(client, 2)
|
||||
s2 = _mk_suggestion(client, tok2, k2.id, "ZZ_APRES e2 建议转计划")
|
||||
r2 = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok2),
|
||||
json={"action": "action_plan", "title": "ZZ_APRES e2 转出的计划"})
|
||||
assert r2.status_code == 200, f"e2 应用应 200,实际 {r2.status_code} {r2.text[:300]}"
|
||||
rows2 = _plans_by_title(db, "ZZ_APRES e2 转出的计划")
|
||||
assert len(rows2) == 1, f"e2 应用未生成计划: {rows2}"
|
||||
assert rows2[0].entity_id == 2, (
|
||||
f"e2 会话经 AI 建议生成的计划 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
|
||||
" —— ai_suggestions.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
|
||||
)
|
||||
|
||||
|
||||
def test_ai_suggestion_apply_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话应用 e2 的建议 → 404(建议须归属会话账套)+ 零落行;本账套应用正常(正对照)"""
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_SUG_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_SUG_KPI, 2)
|
||||
|
||||
tok2 = _login(client, 2)
|
||||
s2 = _mk_suggestion(client, tok2, k2.id, "ZZ_APRES e2 私有建议")
|
||||
tok1 = _login(client, 1)
|
||||
r = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok1),
|
||||
json={"action": "action_plan", "title": "ZZ_APRES 越权转出的计划"})
|
||||
assert r.status_code == 404, f"跨账套应用应 404,实际 {r.status_code} {r.text[:300]}"
|
||||
assert _plans_by_title(db, "ZZ_APRES 越权转出的计划") == [], (
|
||||
"跨账套应用竟为对端账套建议建了计划"
|
||||
)
|
||||
db.expire_all()
|
||||
sug = db.query(AISuggestion).filter(AISuggestion.id == s2).first()
|
||||
assert sug.status == "unapplied", f"越权应用改动了建议状态: {sug.status}"
|
||||
|
||||
# 正对照:本账套会话应用自己账套的建议仍然正常
|
||||
r_ok = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok2),
|
||||
json={"action": "action_plan", "title": "ZZ_APRES e2 自救计划"})
|
||||
assert r_ok.status_code == 200, f"本账套应用应 200,实际 {r_ok.status_code} {r_ok.text[:300]}"
|
||||
assert len(_plans_by_title(db, "ZZ_APRES e2 自救计划")) == 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T5. 鉴权底线(三个入口无 token → 401)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_residual_sites_without_token_401(client: TestClient, db: Session):
|
||||
"""三个入口无 token 一律 401(改前改后都应通过)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
|
||||
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 鉴权底线")
|
||||
m1 = _mk_map(db, 1, "ZZ_APRES e1 鉴权地图", E1_ALERT_KPI, "e1 鉴权目标")
|
||||
|
||||
r1 = client.post(f"{ALERTS}/{a1.id}/create-action-plan", json={})
|
||||
assert r1.status_code == 401, f"预警入口无 token 应 401,实际 {r1.status_code}"
|
||||
|
||||
r2 = client.post(DPUSH, json={"mapId": m1.id, "nodeId": "finance-0",
|
||||
"newValue": 800.0, "createReviewTopic": False})
|
||||
assert r2.status_code == 401, f"差异反打无 token 应 401,实际 {r2.status_code}"
|
||||
|
||||
r3 = client.post(SUGG, json={"suggestion_type": "action_plan", "title": "无 token"})
|
||||
assert r3.status_code == 401, f"AI 建议无 token 应 401,实际 {r3.status_code}"
|
||||
@@ -0,0 +1,297 @@
|
||||
"""行动方案库「已逾期」口径一致性测试
|
||||
|
||||
任务:project-20260921-actionplan-overdue-filter
|
||||
(卡片 7 条 → 列表 0 条)
|
||||
|
||||
缺陷
|
||||
前端统计卡片把 key='overdue' 直接当 status 传给
|
||||
GET /api/cma/action-plans(ActionPlanLibrary.vue:414 卡片 key / :26 filterStatus / :422 params.status),
|
||||
而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 —— status 取值域只有
|
||||
pending / in_progress / completed / cancelled,**没有 overdue** → 恒 0 条。
|
||||
/stats 的 overdue 是派生口径:status in (pending, in_progress) AND due_date < now。
|
||||
两处口径分家 ⇒ 同一张卡片「数 7 条 / 列表 0 条」。
|
||||
|
||||
修复
|
||||
list_plans 在 status == 'overdue' 时走 _apply_overdue(),与 plan_stats **共用同一实现**。
|
||||
|
||||
本文件覆盖
|
||||
① 已逾期列表条数 == stats.overdue(HTTP 全链路:鉴权 + 账套 + 查询参数)
|
||||
② 其余卡片(total/pending/in_progress/completed)列表条数与 stats 逐项相符
|
||||
③ 口径同源:派生集合 ⊆ {pending,in_progress} 且 due_date < now;status=completed/cancelled
|
||||
即使已过期也不计入
|
||||
④ 边界:刚过期(-60s)计入 / 未到期(+60s)不计入 / due_date 为空不计入
|
||||
⑤ 账套隔离:e1 会话只看 e1 KPI 下的计划,e2 逾期计划不出现在 e1 的逾期列表
|
||||
⑥ business 角色过滤:只统计/返回自己的计划(列表与统计同时收窄)
|
||||
⑦ 负对照:同一批夹具下「旧口径」status=='overdue' 确为 0 条(证明夹具真能复现缺陷)
|
||||
"""
|
||||
import inspect
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import action_plans as ap
|
||||
from app.models import ActionPlan, Entity, KPIDefinition
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user, create_test_kpi
|
||||
|
||||
E1_KPI_CODE = "ZZ_APOVD_E1_KPI_001"
|
||||
E2_KPI_CODE = "ZZ_APOVD_E2_KPI_001"
|
||||
|
||||
T_OVERDUE_PENDING = "逾期待处理"
|
||||
T_OVERDUE_INPROG = "逾期进行中"
|
||||
T_FUTURE_PENDING = "未到期待处理"
|
||||
T_OVERDUE_COMPLETED = "已完成且已过期"
|
||||
T_OVERDUE_CANCELLED = "已取消且已过期"
|
||||
T_NO_DUE_PENDING = "无截止日待处理"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
|
||||
return create_test_kpi(db, kpi_code=code, kpi_name=f"{code}-名称", entity_id=eid)
|
||||
|
||||
|
||||
def _mk_plan(db: Session, kpi_id: int, title: str, status: str, due_delta_sec,
|
||||
assignee: str = "张三") -> ActionPlan:
|
||||
due = None if due_delta_sec is None else datetime.now() + timedelta(seconds=due_delta_sec)
|
||||
plan = ActionPlan(
|
||||
entity_id=1,
|
||||
kpi_id=kpi_id,
|
||||
title=title,
|
||||
status=status,
|
||||
due_date=due,
|
||||
assignee=assignee,
|
||||
priority="medium",
|
||||
progress=0,
|
||||
created_by="testadmin",
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int, username: str = "testadmin") -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": username,
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _seed_e1(db: Session):
|
||||
"""e1 夹具:2 条逾期 + 4 条不计入(未到期 / 已完成 / 已取消 / 无截止日)"""
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
create_test_user(db)
|
||||
kpi = _mk_kpi(db, E1_KPI_CODE, 1)
|
||||
_mk_plan(db, kpi.id, T_OVERDUE_PENDING, "pending", -86400) # 逾期
|
||||
_mk_plan(db, kpi.id, T_OVERDUE_INPROG, "in_progress", -3600) # 逾期
|
||||
_mk_plan(db, kpi.id, T_FUTURE_PENDING, "pending", +86400) # 未到期
|
||||
_mk_plan(db, kpi.id, T_OVERDUE_COMPLETED, "completed", -86400) # 已完成→不算逾期
|
||||
_mk_plan(db, kpi.id, T_OVERDUE_CANCELLED, "cancelled", -86400) # 已取消→不算逾期
|
||||
_mk_plan(db, kpi.id, T_NO_DUE_PENDING, "pending", None) # 无截止日
|
||||
return kpi
|
||||
|
||||
|
||||
def _titles(resp_json) -> set:
|
||||
return {row["title"] for row in resp_json["data"]}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ① 已逾期列表条数 == stats.overdue(HTTP 全链路)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_overdue_list_count_equals_stats(client: TestClient, db: Session):
|
||||
"""核心验收:?status=overdue 的条数 == /stats 的 overdue,且只含派生口径内的行"""
|
||||
_seed_e1(db)
|
||||
token = _login(client, 1)
|
||||
h = auth_header(token)
|
||||
|
||||
stats = client.get("/api/cma/action-plans/stats", headers=h).json()
|
||||
listed = client.get("/api/cma/action-plans?status=overdue", headers=h).json()
|
||||
|
||||
assert stats["overdue"] == 2, f"夹具期望 overdue=2,实际 {stats['overdue']}"
|
||||
assert len(listed["data"]) == stats["overdue"], (
|
||||
f"卡片 overdue={stats['overdue']} 与列表 {len(listed['data'])} 条不一致"
|
||||
"(缺陷复现:卡片数≠列表数)"
|
||||
)
|
||||
assert _titles(listed) == {T_OVERDUE_PENDING, T_OVERDUE_INPROG}, (
|
||||
f"逾期列表内容应为 2 条逾期待办,实际 {sorted(_titles(listed))}"
|
||||
)
|
||||
# 口径内容断言:全部落在 status ∈ {pending, in_progress} 且 due_date < now
|
||||
for row in listed["data"]:
|
||||
assert row["status"] in ("pending", "in_progress"), row
|
||||
assert row["due_date"] and datetime.fromisoformat(row["due_date"]) < datetime.now(), row
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ② 其余卡片逐项相符
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_other_cards_match_stats(client: TestClient, db: Session):
|
||||
"""回归:total/pending/in_progress/completed 列表条数与 stats 逐项相符"""
|
||||
_seed_e1(db)
|
||||
token = _login(client, 1)
|
||||
h = auth_header(token)
|
||||
|
||||
stats = client.get("/api/cma/action-plans/stats", headers=h).json()
|
||||
assert stats == {"total": 6, "pending": 3, "in_progress": 1,
|
||||
"completed": 1, "overdue": 2}, stats
|
||||
|
||||
cards = {"total": "", "pending": "pending", "in_progress": "in_progress",
|
||||
"completed": "completed", "overdue": "overdue"}
|
||||
for key, param in cards.items():
|
||||
url = "/api/cma/action-plans" + (f"?status={param}" if param else "")
|
||||
rows = client.get(url, headers=h).json()["data"]
|
||||
assert len(rows) == stats[key], f"卡片 {key}: stats={stats[key]} 列表={len(rows)}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ③ 口径同源(派生集合 + 源码单一来源)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_overdue_excludes_completed_and_cancelled(client: TestClient, db: Session):
|
||||
"""已完成/已取消即使早已过期也不计入逾期(口径 = status ∈ {pending,in_progress})"""
|
||||
_seed_e1(db)
|
||||
token = _login(client, 1)
|
||||
h = auth_header(token)
|
||||
|
||||
titles = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h).json())
|
||||
assert T_OVERDUE_COMPLETED not in titles
|
||||
assert T_OVERDUE_CANCELLED not in titles
|
||||
assert T_NO_DUE_PENDING not in titles
|
||||
|
||||
# 字面 status 过滤未被改动(仍按 status 直查)
|
||||
assert _titles(client.get("/api/cma/action-plans?status=cancelled", headers=h).json()) == {
|
||||
T_OVERDUE_CANCELLED}
|
||||
assert len(client.get("/api/cma/action-plans?status=pending", headers=h).json()["data"]) == 3
|
||||
|
||||
|
||||
def test_overdue_caliber_is_single_source():
|
||||
"""统计与列表共用同一实现:plan_stats 与 list_plans 都调用 _apply_overdue"""
|
||||
stats_src = inspect.getsource(ap.plan_stats)
|
||||
list_src = inspect.getsource(ap.list_plans)
|
||||
assert "_apply_overdue(query)" in stats_src, "plan_stats 未走 _apply_overdue(口径分家)"
|
||||
assert "_apply_overdue(query)" in list_src, "list_plans 未走 _apply_overdue(口径分家)"
|
||||
assert ap.OVERDUE_STATUSES == ("pending", "in_progress")
|
||||
assert ap.OVERDUE_KEY == "overdue"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ④ 边界
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_overdue_boundary_and_null_due_date(client: TestClient, db: Session):
|
||||
"""边界:-60s 计入、+60s 不计入、due_date 为空不计入"""
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
create_test_user(db)
|
||||
kpi = _mk_kpi(db, E1_KPI_CODE, 1)
|
||||
_mk_plan(db, kpi.id, "刚过期60秒", "pending", -60)
|
||||
_mk_plan(db, kpi.id, "还有60秒到期", "pending", +60)
|
||||
_mk_plan(db, kpi.id, "无截止日", "pending", None)
|
||||
|
||||
token = _login(client, 1)
|
||||
h = auth_header(token)
|
||||
titles = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h).json())
|
||||
assert titles == {"刚过期60秒"}, titles
|
||||
stats = client.get("/api/cma/action-plans/stats", headers=h).json()
|
||||
assert stats["overdue"] == 1 and stats["pending"] == 3 and stats["total"] == 3, stats
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ⑤ 账套隔离
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_overdue_entity_isolation(client: TestClient, db: Session):
|
||||
"""e1/e2 各自只看到本账套 KPI 下的计划;逾期口径同样按账套收窄"""
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
k1 = _mk_kpi(db, E1_KPI_CODE, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI_CODE, 2)
|
||||
_mk_plan(db, k1.id, "e1逾期", "pending", -3600)
|
||||
p2 = _mk_plan(db, k2.id, "e2逾期", "pending", -3600)
|
||||
p2.entity_id = 2
|
||||
db.commit()
|
||||
|
||||
h1 = auth_header(_login(client, 1))
|
||||
h2 = auth_header(_login(client, 2))
|
||||
|
||||
s1 = client.get("/api/cma/action-plans/stats", headers=h1).json()
|
||||
s2 = client.get("/api/cma/action-plans/stats", headers=h2).json()
|
||||
l1 = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h1).json())
|
||||
l2 = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h2).json())
|
||||
|
||||
assert s1["overdue"] == 1 and s2["overdue"] == 1, (s1, s2)
|
||||
assert l1 == {"e1逾期"}, l1
|
||||
assert l2 == {"e2逾期"}, l2
|
||||
assert len(client.get("/api/cma/action-plans", headers=h1).json()["data"]) == s1["total"] == 1
|
||||
assert len(client.get("/api/cma/action-plans", headers=h2).json()["data"]) == s2["total"] == 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ⑥ business 角色过滤
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_overdue_business_role_filter(client: TestClient, db: Session):
|
||||
"""business 角色:列表与统计同时只收窄到自己名下(逾期口径也随之下沉)"""
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
create_test_user(db)
|
||||
create_test_user(db, username="业务小王", name="业务小王", role="business")
|
||||
kpi = _mk_kpi(db, E1_KPI_CODE, 1)
|
||||
_mk_plan(db, kpi.id, "小王的逾期", "pending", -3600, assignee="业务小王")
|
||||
_mk_plan(db, kpi.id, "张三的逾期", "pending", -3600, assignee="张三")
|
||||
_mk_plan(db, kpi.id, "小王的未到期", "pending", +3600, assignee="业务小王")
|
||||
|
||||
h = auth_header(_login(client, 1, username="业务小王"))
|
||||
stats = client.get("/api/cma/action-plans/stats", headers=h).json()
|
||||
assert stats == {"total": 2, "pending": 2, "in_progress": 0,
|
||||
"completed": 0, "overdue": 1}, stats
|
||||
listed = client.get("/api/cma/action-plans?status=overdue", headers=h).json()["data"]
|
||||
assert len(listed) == stats["overdue"] == 1
|
||||
assert listed[0]["title"] == "小王的逾期"
|
||||
assert len(client.get("/api/cma/action-plans", headers=h).json()["data"]) == stats["total"]
|
||||
|
||||
|
||||
def test_overdue_with_other_filters_compose(client: TestClient, db: Session):
|
||||
"""派生口径与 kpi_id / keyword 组合时仍成立(不覆盖既有筛选)"""
|
||||
kpi = _seed_e1(db)
|
||||
token = _login(client, 1)
|
||||
h = auth_header(token)
|
||||
|
||||
hit = client.get(f"/api/cma/action-plans?status=overdue&kpi_id={kpi.id}", headers=h).json()
|
||||
assert len(hit["data"]) == 2
|
||||
miss = client.get(f"/api/cma/action-plans?status=overdue&kpi_id={kpi.id + 999}", headers=h).json()
|
||||
assert miss["data"] == []
|
||||
kw = client.get("/api/cma/action-plans?status=overdue&keyword=逾期", headers=h).json()
|
||||
assert _titles(kw) == {T_OVERDUE_PENDING, T_OVERDUE_INPROG}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ⑦ 负对照:旧口径在同一夹具上确为 0
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_negative_control_legacy_caliber_returns_zero(db: Session):
|
||||
"""负对照:同一批夹具下 `status=='overdue'` 的行数 = 0、派生口径 = 2
|
||||
|
||||
证明夹具真的能复现该缺陷(若这里两边相等,用例就没有鉴别力)。
|
||||
"""
|
||||
_seed_e1(db)
|
||||
literal = db.query(ActionPlan).filter(ActionPlan.status == "overdue").count()
|
||||
derived = ap._apply_overdue(db.query(ActionPlan)).count()
|
||||
assert literal == 0, "status 域不应存在 'overdue' 值"
|
||||
assert derived == 2, derived
|
||||
@@ -0,0 +1,291 @@
|
||||
"""CMA 跨实体隔离 — AI 建议同文件残余两处 KPI 归属(2026-09-18 派生单 2030b)
|
||||
|
||||
任务:fullstack-20260918-2030b-aisuggestion-kpi-attribution-residual
|
||||
父单:fullstack-20260918-2030-dismiss-suggestion-entity-scope
|
||||
(其执行期对本文件 app/api/ai_suggestions.py 的只读复查 §五 范围外发现)
|
||||
|
||||
缺陷(同文件 ai_suggestions.py 两处 KPIDefinition 只按 id 直取、不校验归属):
|
||||
1. `_apply_action_plan` (原 239 行) — 建议自身归属校验通过后,其
|
||||
suggestion_data.kpi_id / target_id 指向对端账套 KPI 时,会建出
|
||||
ActionPlan(entity_id=本账套, kpi_id=对端账套KPI) 的跨账套引用行
|
||||
(计划列表按账套可见、KPI 名解析取到对端账套对象)。
|
||||
2. `preview_suggestion` (原 314 行) — 同一写法;建议自身已被账套收敛(读侧单 2025),
|
||||
但其 suggestion_data.kpi_id 指向对端 KPI 时,预览会回显对端账套的 KPI 名与
|
||||
current_target(只读泄漏)。
|
||||
|
||||
修复口径:
|
||||
- `_apply_action_plan`:KPI 查询补 `entity_id == sug.entity_id`,
|
||||
不匹配抛 404「KPI {id} 不存在」(对齐 2030 单 `_apply_kpi_target`/`_apply_budget_adjust`
|
||||
写法),且零落行。
|
||||
- `preview_suggestion`:沿用现有降级语义(kpi=None → 展示 `KPI#<id>`、current_target=None),
|
||||
不抛 404 —— 理由见报告 §三取舍说明(读侧端点不应因数据不自洽而整体不可用)。
|
||||
|
||||
对端记录口径 = 404「KPI {id} 不存在」(写侧)/ 降级占位(读侧)。
|
||||
|
||||
本用例(改前 RED 原文见 _reports/evidence-fullstack-20260918-2030b-red.txt):
|
||||
- T1 应用 action_plan:目标 KPI 非本账套 → 404 且零落 ActionPlan 行(含对端 kpi_id 归属行),
|
||||
建议状态保持 unapplied;两条取数路径(suggestion_data 回退 / 请求体显式 kpi_id)均 404
|
||||
- T2 应用 action_plan 正对照:本账套建议 + 本账套 KPI → 200 且落行 entity_id=会话账套、
|
||||
kpi_id=本账套 KPI
|
||||
- T3 预览:目标 KPI 非本账套 → 不回显对端 KPI 名与 current_target,降级为 KPI#<id>
|
||||
- T4 预览正对照:本账套 KPI → kpi_name / current_target 完整回显
|
||||
- T5 鉴权底线:preview / apply(action_plan) 无 token → 401(改前改后都应通过)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AISuggestion, ActionPlan, Entity, KPIDefinition
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
SUGG = "/api/cma/ai/suggestions"
|
||||
|
||||
# kpi_code 全局唯一 → 每账套独立 code(ZZ_ 前缀为本单专用,避免与同族用例撞名)
|
||||
E1_KPI = "ZZ_AKR_E1_KPI"
|
||||
E2_KPI = "ZZ_AKR_E2_KPI"
|
||||
E1_SUG_TITLE = "ZZ_AKR e1 私有建议"
|
||||
E2_SUG_TITLE = "ZZ_AKR e2 私有建议"
|
||||
CROSS_SUG_TITLE = "ZZ_AKR e2 指向对端 KPI 的建议"
|
||||
|
||||
E1_TARGET = 1000.0
|
||||
E2_TARGET = 3000.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具(沿用 2030 单风格:双向登录 + 同账套正对照)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int, target: float = E1_TARGET) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=target,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _mk_suggestion(client: TestClient, token: str, title: str,
|
||||
target_id: int, kpi_id: int,
|
||||
target_value: float = 2000.0,
|
||||
suggestion_type: str = "kpi_target") -> int:
|
||||
body = {
|
||||
"suggestion_type": suggestion_type,
|
||||
"target_type": "kpi",
|
||||
"target_id": target_id,
|
||||
"title": title,
|
||||
"content": "目标偏低,建议上调",
|
||||
"suggestion_data": {"kpi_id": kpi_id, "target_value": target_value},
|
||||
}
|
||||
r = client.post(SUGG, json=body, headers=auth_header(token))
|
||||
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
|
||||
return r.json()["data"]["id"]
|
||||
|
||||
|
||||
def _sug_status(db: Session, sid: int) -> str:
|
||||
db.expire_all()
|
||||
row = db.query(AISuggestion).filter(AISuggestion.id == sid).first()
|
||||
assert row is not None, f"建议 #{sid} 不存在"
|
||||
return row.status
|
||||
|
||||
|
||||
def _plan_rows(db: Session, kpi_id: int = None):
|
||||
db.expire_all()
|
||||
q = db.query(ActionPlan)
|
||||
if kpi_id is not None:
|
||||
q = q.filter(ActionPlan.kpi_id == kpi_id)
|
||||
return q.all()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T1. 应用 action_plan:目标 KPI 归属校验(两条取数路径)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_apply_action_plan_foreign_kpi_404(client: TestClient, db: Session):
|
||||
"""建议属本账套但其目标 KPI 属对端 → 404 且零落行;对端 KPI 行数逐值未变"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2, target=E2_TARGET)
|
||||
tok2 = _login(client, 2)
|
||||
|
||||
# e2 会话创建建议(归属 e2),但 target_id/suggestion_data.kpi_id 指向 e1 的 KPI
|
||||
# (模拟手填数据 / 迁移遗留 / Bot 通道写入的不自洽数据)
|
||||
s_cross = _mk_suggestion(client, tok2, CROSS_SUG_TITLE, k1.id, k1.id)
|
||||
|
||||
rows_before_e1 = len(_plan_rows(db, k1.id))
|
||||
rows_before_all = len(_plan_rows(db))
|
||||
assert rows_before_e1 == 0, f"前置:e1 KPI 已有行动方案 {rows_before_e1} 行"
|
||||
|
||||
# 路径 A:请求体不给 kpi_id → 回退 suggestion_data.kpi_id(对端账套)
|
||||
r_a = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
|
||||
json={"action": "action_plan", "title": "ZZ_AKR 跨账套计划A"})
|
||||
assert r_a.status_code == 404, (
|
||||
f"action_plan 目标 KPI 属对端账套应 404,实际 {r_a.status_code} {r_a.text[:200]}"
|
||||
" —— _apply_action_plan 只按 id 查 KPIDefinition,未校验归属会话账套"
|
||||
)
|
||||
# 路径 B:请求体显式给 kpi_id(对端账套)
|
||||
r_b = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
|
||||
json={"action": "action_plan", "kpi_id": k1.id,
|
||||
"title": "ZZ_AKR 跨账套计划B"})
|
||||
assert r_b.status_code == 404, (
|
||||
f"显式 kpi_id 指向对端账套应 404,实际 {r_b.status_code} {r_b.text[:200]}"
|
||||
)
|
||||
|
||||
rows_after_e1 = len(_plan_rows(db, k1.id))
|
||||
rows_after_all = len(_plan_rows(db))
|
||||
assert rows_after_e1 == rows_before_e1, (
|
||||
f"越权 action_plan 落了跨账套引用行(对端 kpi_id={k1.id}):"
|
||||
f"{rows_before_e1} → {rows_after_e1}"
|
||||
)
|
||||
assert rows_after_all == rows_before_all, (
|
||||
f"越权 action_plan 落了行(总数):{rows_before_all} → {rows_after_all}"
|
||||
)
|
||||
assert _sug_status(db, s_cross) == "unapplied", (
|
||||
"越权应用竟改动了建议状态(应保持 unapplied)"
|
||||
)
|
||||
# 正对照前置:本账套 KPI 未被误伤
|
||||
assert len(_plan_rows(db, k2.id)) == 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T2. 应用 action_plan 正对照:本账套正常落行
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_apply_action_plan_own_kpi_ok(client: TestClient, db: Session):
|
||||
"""本账套建议 + 本账套 KPI → 200,落行 entity_id=会话账套、kpi_id=本账套 KPI"""
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2, target=E2_TARGET)
|
||||
tok2 = _login(client, 2)
|
||||
|
||||
s_ok = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id)
|
||||
r_ok = client.post(f"{SUGG}/{s_ok}/apply", headers=auth_header(tok2),
|
||||
json={"action": "action_plan", "title": "ZZ_AKR 本账套计划",
|
||||
"assignee": "张三", "priority": "high",
|
||||
"due_date": "2026-09-30"})
|
||||
assert r_ok.status_code == 200, (
|
||||
f"本账套 action_plan 应用应 200,实际 {r_ok.status_code} {r_ok.text[:300]}"
|
||||
)
|
||||
|
||||
rows = _plan_rows(db, k2.id)
|
||||
assert len(rows) == 1, f"本账套应用未落行(kpi_id={k2.id} 现有 {len(rows)} 行)"
|
||||
plan = rows[0]
|
||||
assert plan.entity_id == 2, (
|
||||
f"落行 entity_id 应为会话账套 2,实际 {plan.entity_id}"
|
||||
)
|
||||
assert plan.kpi_id == k2.id, f"落行 kpi_id 应为 {k2.id},实际 {plan.kpi_id}"
|
||||
assert plan.title == "ZZ_AKR 本账套计划"
|
||||
assert _sug_status(db, s_ok) == "applied", "本账套应用未落建议状态 applied"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T3. 预览:对端 KPI 不回显
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_preview_foreign_kpi_not_leaked(client: TestClient, db: Session):
|
||||
"""建议属本账套、其 kpi_id 属对端 → 预览不回显对端 KPI 名/current_target"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
|
||||
_mk_kpi(db, E2_KPI, 2, target=E2_TARGET)
|
||||
tok2 = _login(client, 2)
|
||||
|
||||
s_cross = _mk_suggestion(client, tok2, CROSS_SUG_TITLE, k1.id, k1.id)
|
||||
|
||||
r = client.get(f"{SUGG}/{s_cross}/preview", headers=auth_header(tok2))
|
||||
assert r.status_code == 200, f"预览应 200(降级语义),实际 {r.status_code} {r.text[:200]}"
|
||||
data = r.json().get("data") or {}
|
||||
blob = str(r.json())
|
||||
|
||||
assert f"{E1_KPI}-名称" not in blob, (
|
||||
"预览回显了对端账套的 KPI 名(只读泄漏)"
|
||||
" —— preview_suggestion 的 KPIDefinition 查询未校验归属会话账套"
|
||||
)
|
||||
assert f"KPI#{k1.id}" == data.get("kpi_name"), (
|
||||
f"对端 KPI 应降级为占位名 KPI#{k1.id},实际 {data.get('kpi_name')!r}"
|
||||
)
|
||||
assert data.get("current_target") is None, (
|
||||
f"预览泄漏了对端账套 KPI 目标值:{data.get('current_target')!r}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T4. 预览正对照:本账套 KPI 字段完整
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_preview_own_kpi_complete(client: TestClient, db: Session):
|
||||
"""本账套建议 + 本账套 KPI → kpi_name / current_target 完整回显"""
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2, target=E2_TARGET)
|
||||
tok2 = _login(client, 2)
|
||||
|
||||
s_ok = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id, target_value=4500.0)
|
||||
r = client.get(f"{SUGG}/{s_ok}/preview", headers=auth_header(tok2))
|
||||
assert r.status_code == 200, f"本账套预览应 200,实际 {r.status_code} {r.text[:200]}"
|
||||
data = r.json().get("data") or {}
|
||||
assert data.get("kpi_name") == f"{E2_KPI}-名称", (
|
||||
f"本账套预览未回显 KPI 名,实际 {data.get('kpi_name')!r}"
|
||||
)
|
||||
assert data.get("current_target") == E2_TARGET, (
|
||||
f"本账套预览 current_target 应为 {E2_TARGET},实际 {data.get('current_target')!r}"
|
||||
)
|
||||
assert data.get("new_target") == 4500.0, (
|
||||
f"本账套预览 new_target 应为 4500.0,实际 {data.get('new_target')!r}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T5. 鉴权底线(preview / apply 无 token → 401)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_preview_and_apply_without_token_401(client: TestClient, db: Session):
|
||||
"""无 token 一律 401(改前改后都应通过)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
|
||||
tok2 = _login(client, 2)
|
||||
s = _mk_suggestion(client, tok2, CROSS_SUG_TITLE, k1.id, k1.id)
|
||||
|
||||
r1 = client.get(f"{SUGG}/{s}/preview")
|
||||
assert r1.status_code == 401, f"preview 无 token 应 401,实际 {r1.status_code}"
|
||||
|
||||
r2 = client.post(f"{SUGG}/{s}/apply", json={"action": "action_plan"})
|
||||
assert r2.status_code == 401, f"apply 无 token 应 401,实际 {r2.status_code}"
|
||||
@@ -0,0 +1,366 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""财务预警口径裁定 R1–R4 落地测试(2026-09-19)
|
||||
|
||||
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`(任总 2026-09-18「按建议执行」)
|
||||
· R1 —— `entities.status='demo'` 的账套不参与预警生成/遍历(demo 仅演示展示);
|
||||
· R2 —— 预警遍历期间 = 当月往前 6 个自然月(含当月),更早期间不再新生成;
|
||||
· R3 —— 仅规整月期间 `YYYY-MM` 参与,`H1/H2/FY` 等伪期间一律不参与;
|
||||
· R4 —— 生成与推送解耦:`notification_channels` 为空时仅入库、不报错、不阻断。
|
||||
|
||||
口径唯一事实源:`app/utils/alert_entity_scope.py`(本套件同时锁该模块的入口函数)。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from tests.conftest import create_test_kpi
|
||||
from app.models import Entity, KPIValue, KPIAlert, NotificationChannel
|
||||
from app.utils.alert_entity_scope import (
|
||||
ALERT_EXCLUDED_ENTITY_STATUSES, ALERT_WINDOW_MONTHS, excluded_entity_ids,
|
||||
is_excluded_entity, is_month_period, kpi_excluded, period_in_window, window_lower_period,
|
||||
)
|
||||
from scripts.alert_generator import generate_and_push, run_alert_check
|
||||
|
||||
#: 固定「当月」= 2026-09 → 窗口 = 2026-04 ~ 2026-09(口径复算不依赖真实时钟)
|
||||
NOW = datetime(2026, 9, 19, 10, 0)
|
||||
|
||||
RED_EXPR = "<58.4" # 0<实测<10 比值形态不触发 dimension_suspect(见裁定2a 降级门)
|
||||
|
||||
|
||||
def _ent(db, eid, status, name=None):
|
||||
db.add(Entity(id=eid, name=name or ("账套%d" % eid), short_name="e%d" % eid, status=status))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _kpi_value(db, kpi, period, value, entity_id=1):
|
||||
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _demo_and_active(db):
|
||||
_ent(db, 1, "active", "陕西酣客文化传媒")
|
||||
_ent(db, 6, "demo", "演示企业(虚构数据)")
|
||||
|
||||
|
||||
# ───────────────────────── R1:demo 账套排除 ─────────────────────────
|
||||
|
||||
class TestR1DemoEntityExcluded:
|
||||
def test_excluded_ids_only_demo(self, db: Session):
|
||||
_ent(db, 1, "active")
|
||||
_ent(db, 3, "demo")
|
||||
_ent(db, 4, "demo")
|
||||
_ent(db, 5, "demo")
|
||||
_ent(db, 6, "demo")
|
||||
assert excluded_entity_ids(db) == {3, 4, 5, 6}
|
||||
assert is_excluded_entity(db, 6) is True
|
||||
assert is_excluded_entity(db, 1) is False
|
||||
assert is_excluded_entity(db, None) is False, "None → 默认账套 1(不被排除)"
|
||||
assert ALERT_EXCLUDED_ENTITY_STATUSES == ("demo",)
|
||||
|
||||
def test_demo_entity_missing_table_fail_open(self, db: Session, monkeypatch):
|
||||
"""口径读取失败时 fail-open(不阻断预警主流程),返回空集"""
|
||||
import app.utils.alert_entity_scope as scope
|
||||
|
||||
def _boom(*a, **kw):
|
||||
raise RuntimeError("simulated db error")
|
||||
|
||||
monkeypatch.setattr(db, "query", _boom)
|
||||
assert scope.excluded_entity_ids(db) == set()
|
||||
|
||||
def test_demo_kpi_not_traversed_active_kpi_still_is(self, db: Session):
|
||||
"""同一形态两份 KPI:demo 账套(e6) 跳过、active 账套(e1) 正常判定(正对照)"""
|
||||
_demo_and_active(db)
|
||||
k_demo = create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入",
|
||||
entity_id=6, unit="%", threshold_red=RED_EXPR)
|
||||
k_real = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
|
||||
entity_id=1, unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k_demo, "2026-09", 14.45, entity_id=6)
|
||||
_kpi_value(db, k_real, "2026-09", 14.45, entity_id=1)
|
||||
|
||||
st = {}
|
||||
n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW)
|
||||
|
||||
assert n == 1, "只有 active 账套的 KPI 参与判定"
|
||||
assert st["skipped_demo_kpis"] == 1
|
||||
assert st["demo_kpis"] == ["DEMO_REVENUE/e6"]
|
||||
assert st["excluded_demo_entities"] == [6]
|
||||
assert [p["kpi"].split("/")[0] for p in st["planned"]] == ["F_REVENUE"]
|
||||
assert kpi_excluded(db, k_demo) is True and kpi_excluded(db, k_real) is False
|
||||
|
||||
def test_demo_kpi_writes_no_alert(self, db: Session):
|
||||
"""非 dry-run:demo KPI 不落库;active KPI 落库(前后计数差 =1)"""
|
||||
_demo_and_active(db)
|
||||
k_demo = create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入",
|
||||
entity_id=6, unit="%", threshold_red=RED_EXPR)
|
||||
k_real = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
|
||||
entity_id=1, unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k_demo, "2026-09", 14.45, entity_id=6)
|
||||
_kpi_value(db, k_real, "2026-09", 14.45, entity_id=1)
|
||||
before = db.query(KPIAlert).count()
|
||||
|
||||
st = {}
|
||||
n = run_alert_check(db, dry_run=False, stats=st, now=NOW)
|
||||
db.expire_all()
|
||||
|
||||
rows = db.query(KPIAlert).all()
|
||||
assert n == 1 and len(rows) == before + 1
|
||||
assert [r.entity_id for r in rows] == [1], "demo 账套零预警"
|
||||
|
||||
def test_rule_engine_skips_demo_entity(self, db: Session, monkeypatch):
|
||||
"""规则引擎(按 AlertRule.entity_id 遍历)跳过 demo 账套"""
|
||||
from app.api import alert_rules as ar
|
||||
from scripts import alert_generator as ag
|
||||
|
||||
ar.AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
|
||||
_demo_and_active(db)
|
||||
called = []
|
||||
monkeypatch.setattr(ar, "run_forecast_deviation_check",
|
||||
lambda db, entity_id: called.append(("fd", entity_id)) or {"generated": 0})
|
||||
monkeypatch.setattr(ar, "run_customer_concentration_check",
|
||||
lambda db, entity_id: called.append(("cc", entity_id)) or {"generated": 0})
|
||||
for eid in (1, 6):
|
||||
db.add(ar.AlertRule(entity_id=eid, kpi_id=1, rule_type="forecast_deviation", enabled=1))
|
||||
db.commit()
|
||||
|
||||
stats = ag.run_rule_engine_checks(db)
|
||||
|
||||
assert stats["excluded_entities"] == [6]
|
||||
assert stats["skipped_demo_entities"] == 1
|
||||
assert sorted(e for _, e in called) == [1, 1], "demo 账套一条规则都不跑"
|
||||
|
||||
def test_alert_rules_endpoints_skip_demo(self, db: Session):
|
||||
"""生成端点对 demo 账套直接返回 skipped(不起规则遍历)"""
|
||||
from app.api.alert_rules import (
|
||||
AlertRule, run_all_alert_checks, run_forecast_alert_check,
|
||||
run_forecast_deviation_check, run_customer_concentration_check,
|
||||
)
|
||||
|
||||
AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
|
||||
_demo_and_active(db)
|
||||
for fn in (run_all_alert_checks, run_forecast_alert_check,
|
||||
run_forecast_deviation_check, run_customer_concentration_check):
|
||||
r = fn(db=db, entity_id=6)
|
||||
assert r.get("skipped") is True, fn.__name__
|
||||
assert r.get("reason") == "R1_demo_entity_excluded"
|
||||
assert r.get("generated") == 0
|
||||
|
||||
def test_cash_alerts_skip_demo(self, db: Session):
|
||||
from app.utils.cash_forecast_engine import check_cash_alerts
|
||||
|
||||
_demo_and_active(db)
|
||||
r = check_cash_alerts(db, entity_id=6)
|
||||
assert r["skipped"] is True and r["reason"] == "R1_demo_entity_excluded"
|
||||
assert db.query(KPIAlert).count() == 0
|
||||
|
||||
def test_deviation_check_excludes_demo_kpi(self, db: Session, monkeypatch):
|
||||
"""差异预警遍历(daily_sync 第4步)同样排除 demo 账套 KPI"""
|
||||
import app.utils.deviation_engine as de
|
||||
|
||||
_demo_and_active(db)
|
||||
k_demo = create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入",
|
||||
entity_id=6, unit="%", threshold_red=RED_EXPR)
|
||||
k_real = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
|
||||
entity_id=1, unit="%", threshold_red=RED_EXPR)
|
||||
# R5-补1(2026-09-19):差异预警须落 kpi_value_id 关联,故夹具补该期间的 kpi_values 行
|
||||
#(否则按补1 跳过不入库,本用例将观察不到「遍历范围」这一被测口径)
|
||||
_kpi_value(db, k_real, "2026-08", 14.45, entity_id=1)
|
||||
# 让所有 KPI 都「差异触发」,只观察遍历范围(不依赖预算数据形态)
|
||||
monkeypatch.setattr(de, "build_deviation_alert",
|
||||
lambda db_s, kpi, period, *a, **kw: {
|
||||
"triggered": True, "kpi_alert_level": "red", "alert_type": "budget",
|
||||
"suggestion": None,
|
||||
"deviation": {"actual_value": 14.45, "budget_value": 100.0,
|
||||
"deviation_rate": -85.6}})
|
||||
monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **kw: {"anomaly": False})
|
||||
|
||||
n = de.run_deviation_check(db, period="2026-08")
|
||||
db.expire_all()
|
||||
|
||||
rows = db.query(KPIAlert).all()
|
||||
assert n == 1 and [r.entity_id for r in rows] == [1]
|
||||
|
||||
|
||||
# ───────────────────────── R2:期间窗口(6 个自然月) ─────────────────────────
|
||||
|
||||
class TestR2Window:
|
||||
def test_window_lower_period_math(self):
|
||||
assert ALERT_WINDOW_MONTHS == 6
|
||||
assert window_lower_period(NOW) == "2026-04" # 2026-09 往前 5 个月
|
||||
assert window_lower_period(datetime(2026, 9, 1)) == "2026-04"
|
||||
assert window_lower_period(datetime(2026, 6, 30)) == "2026-01"
|
||||
assert window_lower_period(datetime(2026, 1, 5)) == "2025-08" # 跨年
|
||||
assert window_lower_period(datetime(2026, 3, 31)) == "2025-10"
|
||||
|
||||
def test_period_in_window(self):
|
||||
assert period_in_window("2026-04", NOW) is True # 窗口下限(含)
|
||||
assert period_in_window("2026-09", NOW) is True # 当月(含)
|
||||
assert period_in_window("2026-03", NOW) is False # 窗口外
|
||||
assert period_in_window("2026-01", NOW) is False
|
||||
assert period_in_window("2026-H1", NOW) is False # R3
|
||||
assert period_in_window(None, NOW) is False
|
||||
|
||||
def test_out_of_window_kpi_not_generated(self, db: Session):
|
||||
"""博海形态:最新可用期间 2026-01/03(窗口外)→ 不再新生成"""
|
||||
_demo_and_active(db)
|
||||
for code, period in (("BH_SALES_1", "2026-03"), ("BH_SALES_5", "2026-01")):
|
||||
k = create_test_kpi(db, kpi_code=code, kpi_name=code, entity_id=2,
|
||||
unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k, period, 63070.3, entity_id=2)
|
||||
|
||||
st = {}
|
||||
n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW)
|
||||
|
||||
assert n == 0
|
||||
assert st["out_of_window"] == 2
|
||||
assert sorted(st["out_of_window_kpis"]) == ["BH_SALES_1[2026-03]", "BH_SALES_5[2026-01]"]
|
||||
assert st["window_lower_period"] == "2026-04"
|
||||
|
||||
def test_window_boundary_generates_inside_only(self, db: Session):
|
||||
"""边界:窗口下限 2026-04 生成;2026-03 不生成"""
|
||||
_demo_and_active(db)
|
||||
k_in = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1,
|
||||
unit="%", threshold_red=RED_EXPR)
|
||||
k_out = create_test_kpi(db, kpi_code="F_COST_RATIO", kpi_name="费用率", entity_id=1,
|
||||
unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k_in, "2026-04", 14.45)
|
||||
_kpi_value(db, k_out, "2026-03", 14.45)
|
||||
|
||||
st = {}
|
||||
n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW)
|
||||
|
||||
assert n == 1
|
||||
assert st["periods_used"] == {"2026-04": 1}
|
||||
assert st["out_of_window"] == 1
|
||||
assert st["planned"][0]["kpi"].startswith("F_REVENUE")
|
||||
|
||||
def test_no_new_alert_written_out_of_window(self, db: Session):
|
||||
"""非 dry-run 回查:窗口外 KPI 不落库(前后计数一致)"""
|
||||
_demo_and_active(db)
|
||||
k = create_test_kpi(db, kpi_code="BH_SALES_1", kpi_name="销售-陈艳", entity_id=2,
|
||||
unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k, "2026-01", 63070.3, entity_id=2)
|
||||
before = db.query(KPIAlert).count()
|
||||
|
||||
run_alert_check(db, dry_run=False, now=NOW)
|
||||
db.expire_all()
|
||||
|
||||
assert db.query(KPIAlert).count() == before == 0
|
||||
|
||||
|
||||
# ───────────────────────── R3:伪期间不参与 ─────────────────────────
|
||||
|
||||
class TestR3PseudoPeriod:
|
||||
def test_is_month_period(self):
|
||||
assert is_month_period("2026-08") is True and is_month_period("2025-12") is True
|
||||
for bad in ("2026-H1", "2026-H2", "2025-FY", "2025", "2026-13", None, ""):
|
||||
assert is_month_period(bad) is False, bad
|
||||
|
||||
def test_explicit_pseudo_period_skipped(self, db: Session):
|
||||
"""显式传入 2026-H1 → 伪期间跳过(不放行、不落库)"""
|
||||
_demo_and_active(db)
|
||||
k = create_test_kpi(db, kpi_code="F_GROSS_MARGIN", kpi_name="毛利率",
|
||||
entity_id=1, unit="%", threshold_red="<15")
|
||||
_kpi_value(db, k, "2026-08", 5.0)
|
||||
before = db.query(KPIAlert).count()
|
||||
|
||||
st = {}
|
||||
n = run_alert_check(db, period="2026-H1", dry_run=False, stats=st, now=NOW)
|
||||
db.expire_all()
|
||||
|
||||
assert n == 0 and db.query(KPIAlert).count() == before
|
||||
assert st["skipped_pseudo_period"] == 1
|
||||
assert st["pseudo_period_kpis"] == ["F_GROSS_MARGIN[2026-H1]"]
|
||||
|
||||
def test_only_pseudo_period_means_no_period(self, db: Session):
|
||||
"""KPI 只有 2026-H1 数据 → 无可用期间(no_period,不静默)"""
|
||||
_demo_and_active(db)
|
||||
k = create_test_kpi(db, kpi_code="F_GROSS_MARGIN", kpi_name="毛利率",
|
||||
entity_id=1, unit="%", threshold_red="<15")
|
||||
_kpi_value(db, k, "2026-H1", 5.0)
|
||||
|
||||
st = {}
|
||||
n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW)
|
||||
|
||||
assert n == 0 and st["no_period"] == 1 and st["skipped_pseudo_period"] == 0
|
||||
|
||||
|
||||
# ───────────────────────── R4:生成与推送解耦 ─────────────────────────
|
||||
|
||||
class TestR4PushDecoupled:
|
||||
def _triggering_kpi(self, db):
|
||||
_demo_and_active(db)
|
||||
k = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1,
|
||||
unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k, "2026-09", 14.45)
|
||||
return k
|
||||
|
||||
def test_empty_channels_only_store_no_error(self, db: Session):
|
||||
"""notification_channels=0 → 仅入库、不报错、不阻断(生成结果照常返回)"""
|
||||
self._triggering_kpi(db)
|
||||
assert db.query(NotificationChannel).count() == 0
|
||||
|
||||
r = generate_and_push(db, dry_run=False, now=NOW)
|
||||
db.expire_all()
|
||||
|
||||
assert r["new_alerts"] == 1, "预警照常生成入库"
|
||||
assert r["channels_enabled"] == 0 and r["push_skipped_no_channel"] is True
|
||||
assert r["pushed"] == 0
|
||||
assert db.query(KPIAlert).count() == 1
|
||||
assert db.query(KPIAlert).first().status == "pending"
|
||||
|
||||
def test_push_failure_does_not_block_generation(self, db: Session, monkeypatch):
|
||||
"""有渠道但推送抛异常 → 生成结果不受影响(try/except 解耦)"""
|
||||
import app.utils.notifier as notifier
|
||||
|
||||
self._triggering_kpi(db)
|
||||
db.add(NotificationChannel(name="企微群", channel_type="wecom",
|
||||
config={"webhook_url": "https://example.invalid/hook"},
|
||||
enabled=True))
|
||||
db.commit()
|
||||
|
||||
def _boom(*a, **kw):
|
||||
raise RuntimeError("simulated push failure")
|
||||
|
||||
monkeypatch.setattr(notifier, "push_pending_alerts", _boom)
|
||||
|
||||
r = generate_and_push(db, dry_run=False, now=NOW)
|
||||
assert r["new_alerts"] == 1 and r["pushed"] == 0
|
||||
assert r["channels_enabled"] == 1 and r["push_skipped_no_channel"] is False
|
||||
|
||||
def test_push_called_when_channel_present(self, db: Session, monkeypatch):
|
||||
import app.utils.notifier as notifier
|
||||
|
||||
self._triggering_kpi(db)
|
||||
db.add(NotificationChannel(name="企微群", channel_type="wecom",
|
||||
config={"webhook_url": "https://example.invalid/hook"},
|
||||
enabled=True))
|
||||
db.commit()
|
||||
monkeypatch.setattr(notifier, "push_pending_alerts", lambda db_: 1)
|
||||
|
||||
r = generate_and_push(db, dry_run=False, now=NOW)
|
||||
assert r["new_alerts"] == 1 and r["pushed"] == 1 and r["channels_enabled"] == 1
|
||||
|
||||
def test_dry_run_zero_write_and_zero_push(self, db: Session, monkeypatch):
|
||||
"""dry-run:零写入、零推送,且返回 R1/R2/R3 口径证据"""
|
||||
import app.utils.notifier as notifier
|
||||
|
||||
self._triggering_kpi(db)
|
||||
k_old = create_test_kpi(db, kpi_code="BH_SALES_1", kpi_name="销售-陈艳", entity_id=2,
|
||||
unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k_old, "2026-01", 63070.3, entity_id=2)
|
||||
create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入", entity_id=6,
|
||||
unit="%", threshold_red=RED_EXPR)
|
||||
called = []
|
||||
monkeypatch.setattr(notifier, "push_pending_alerts", lambda db_: called.append(1) or 9)
|
||||
|
||||
before = db.query(KPIAlert).count()
|
||||
r = generate_and_push(db, dry_run=True, now=NOW)
|
||||
db.expire_all()
|
||||
|
||||
assert db.query(KPIAlert).count() == before == 0, "dry-run 零写入"
|
||||
assert called == [], "dry-run 零推送"
|
||||
assert r["new_alerts"] == 1 and r["pushed"] == 0
|
||||
assert r["skipped_demo_kpis"] == 1 and r["out_of_window"] == 1
|
||||
assert r["window_lower_period"] == "2026-04" and r["excluded_demo_entities"] == [6]
|
||||
assert r["push_skipped_no_channel"] is True
|
||||
@@ -0,0 +1,239 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""财务预警口径裁定 R5 落地测试(2026-09-19,任务 finance-20260919-alert-caliber-r5-noperiod)
|
||||
|
||||
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md` §R5(R3 的一致延伸)
|
||||
|
||||
- **可归期原则**:预警生效判据 = 期间为规整月 `YYYY-MM`;`period IS NULL` 与 `2026-H1/H2/FY`
|
||||
同属「不可归期」→ 不参与预警(不可复核 / 不可归责 / 不可驱动行动)。
|
||||
- **生成侧守卫**:任何生成路径产出的 `kpi_alerts` 行,期间非 `YYYY-MM` 一律不入库(跳过 + 记日志)。
|
||||
- **R1 与入口无关**:demo 账套(`status='demo'`)在**任何**能产出 `kpi_alerts` 行的路径上
|
||||
都不得产生业务预警,含按显式 `entity_id` 写入的 **Bot 通道**。
|
||||
|
||||
口径唯一事实源仍为 `app/utils/alert_entity_scope.py`:本套件既锁该模块入口函数,
|
||||
也锁各生成路径的**落库行为**(不入库 = 交付判据)。
|
||||
|
||||
类A(可落地守卫,本套件 RED→GREEN 对象):期间已知、可能为伪期间的生成路径
|
||||
—— 规则引擎(`latest_value.period` 按字符串 desc 取,`2026-H1` 会排在 `2026-09` 之前)、
|
||||
Bot 写通道(`bot_bridge` / `bot_bridge_v2` 按显式 period 写)。
|
||||
类B(现状登记,见 `TestR5ClassBStatusQuo`):设计上不携带月度期间的族(集中度/到期未收款/
|
||||
资金缺口预测),其期间**无从从库派生**(`kpi_value_id IS NULL`)。
|
||||
|
||||
2026-09-19 补充裁定 R5-补(财务,收口两个残口)后本套件的口径变化:
|
||||
- **补1**:差异/趋势/差异反打路径改为**必须落 `kpi_value_id`**(不可归期即跳过),
|
||||
差异类去重键改 `(entity_id, kpi_id, kpi_value_id, alert_type)` → **差异/趋势已由类B 转入类A**,
|
||||
其守卫与负对照用例落在 `tests/test_alert_caliber_r5_residual_20260919.py`;
|
||||
- **补2**:类B「无月度期间族」**不屏蔽(现状保留)**,登记为已知例外,不再作为待决策项挂账
|
||||
→ `TestR5ClassBStatusQuo` 由「待产品侧决策」的登记,改为「按补2 保留」的登记。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from tests.conftest import create_test_kpi
|
||||
from app.models import Entity, KPIValue, KPIAlert
|
||||
from app.utils.alert_entity_scope import (
|
||||
entity_alert_guard, guard_alert_period, is_attributable_period,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 9, 19, 10, 0)
|
||||
|
||||
#: 触发形态说明:静态规则 red="<15" + 实测 5.0 → 必然触发红灯(不依赖真实阈值表)
|
||||
RED_EXPR = "<15"
|
||||
|
||||
|
||||
def _ent(db, eid, status, name=None):
|
||||
db.add(Entity(id=eid, name=name or ("账套%d" % eid), short_name="e%d" % eid, status=status))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _kpi_value(db, kpi, period, value, entity_id=1):
|
||||
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _alert_rule(db, kpi_id, entity_id=1, rule_type="static", params=None):
|
||||
from app.api.alert_rules import AlertRule
|
||||
|
||||
AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
|
||||
r = AlertRule(entity_id=entity_id, kpi_id=kpi_id, rule_type=rule_type, enabled=1,
|
||||
params=params if params is not None else {"red": RED_EXPR})
|
||||
db.add(r)
|
||||
db.commit()
|
||||
return r
|
||||
|
||||
|
||||
# ─────────────────── R5 单一事实源入口(守卫语义) ───────────────────
|
||||
|
||||
class TestR5SingleSourceGuard:
|
||||
def test_is_attributable_period(self):
|
||||
"""可归期 = 规整月 YYYY-MM;NULL / 伪期间 / 非法月 / 非月粒度一律不可归期"""
|
||||
for ok in ("2026-09", "2026-04", "2025-12"):
|
||||
assert is_attributable_period(ok) is True, ok
|
||||
for bad in (None, "", "2026-H1", "2026-H2", "2025-FY", "2025", "2026-13",
|
||||
"2026-9", "2026-09-01", "2026-09-19", "本月"):
|
||||
assert is_attributable_period(bad) is False, bad
|
||||
|
||||
def test_guard_alert_period_semantics(self):
|
||||
"""guard 返回 True=放行入库;False=跳过(调用点不得落库)"""
|
||||
assert guard_alert_period("2026-09", source="unit-test") is True
|
||||
assert guard_alert_period(None, source="unit-test") is False
|
||||
assert guard_alert_period("2026-H1", source="unit-test") is False
|
||||
|
||||
def test_entity_alert_guard_demo_vs_active(self, db: Session):
|
||||
"""R1 通用守卫:demo 账套 False(不产生业务预警),active 账套 True"""
|
||||
_ent(db, 1, "active")
|
||||
_ent(db, 6, "demo")
|
||||
assert entity_alert_guard(db, 6, source="unit-test") is False
|
||||
assert entity_alert_guard(db, 1, source="unit-test") is True
|
||||
assert entity_alert_guard(db, None, source="unit-test") is True, "None → 默认账套 1"
|
||||
|
||||
|
||||
# ─────────────────── 类A-1:规则引擎路径(伪期间不得入库) ───────────────────
|
||||
|
||||
class TestR5RuleEnginePath:
|
||||
def _pseudo_latest(self, db):
|
||||
"""KPI 的最新(字符串排序最大)值是伪期间 2026-H1 → 旧码会据此落库一条 2026-H1 预警"""
|
||||
_ent(db, 1, "active")
|
||||
k = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
|
||||
entity_id=1, unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k, "2026-06", 50.0)
|
||||
_kpi_value(db, k, "2026-H1", 5.0) # '2026-H1' 字符串序 > '2026-06' → 被取为 latest
|
||||
_alert_rule(db, k.id)
|
||||
return k
|
||||
|
||||
def test_pseudo_period_latest_not_inserted(self, db: Session):
|
||||
"""latest_value=2026-H1 → 该 kpi_alerts 行不入库(R5 生成侧守卫)"""
|
||||
from app.api.alert_rules import run_all_alert_checks
|
||||
|
||||
self._pseudo_latest(db)
|
||||
before = db.query(KPIAlert).count()
|
||||
|
||||
r = run_all_alert_checks(db=db, entity_id=1)
|
||||
db.expire_all()
|
||||
|
||||
assert db.query(KPIAlert).count() == before, "期间非 YYYY-MM → 不入库"
|
||||
assert r["generated"] == 0
|
||||
assert r.get("skipped_non_month_period") == 1
|
||||
assert r.get("skipped_non_month_period_kpis") == ["F_REVENUE[2026-H1]"]
|
||||
|
||||
def test_month_period_still_inserted(self, db: Session):
|
||||
"""正对照:规整月期间照常入库(守卫只拦「不可归期」,不误伤正常预警)"""
|
||||
from app.api.alert_rules import run_all_alert_checks
|
||||
|
||||
_ent(db, 1, "active")
|
||||
k = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
|
||||
entity_id=1, unit="%", threshold_red=RED_EXPR)
|
||||
_kpi_value(db, k, "2026-09", 5.0)
|
||||
_alert_rule(db, k.id)
|
||||
|
||||
r = run_all_alert_checks(db=db, entity_id=1)
|
||||
db.expire_all()
|
||||
|
||||
rows = db.query(KPIAlert).all()
|
||||
assert r["generated"] == 1 and len(rows) == 1
|
||||
assert rows[0].kpi_value_id is not None, "规整月预警带 kpi_value 关联(期间可归期)"
|
||||
|
||||
|
||||
# ─────────────────── 类A-2 / R1:Bot 写通道 ───────────────────
|
||||
|
||||
class TestR5BotWritePaths:
|
||||
def _bot_kpi(self, db, entity_id=1, code="F_REVENUE"):
|
||||
_ent(db, 1, "active")
|
||||
_ent(db, 6, "demo", "演示企业(虚构数据)")
|
||||
return create_test_kpi(db, kpi_code=code, kpi_name="营业收入", entity_id=entity_id,
|
||||
unit="%", threshold_red=RED_EXPR)
|
||||
|
||||
def test_bot_bridge_pseudo_period_no_alert(self, db: Session):
|
||||
"""bot_bridge 写端点:period=2026-H1 → 不产生预警行(值照写,不动 kpi_values 口径)"""
|
||||
from app.api.bot_bridge import bot_kpi_value_with_check
|
||||
|
||||
k = self._bot_kpi(db)
|
||||
_alert_rule(db, k.id)
|
||||
before = db.query(KPIAlert).count()
|
||||
|
||||
r = bot_kpi_value_with_check(
|
||||
data={"kpi_id": k.id, "actual_value": 5.0, "period": "2026-H1", "entity_id": 1},
|
||||
db=db, bot={"name": "test-bot"})
|
||||
db.expire_all()
|
||||
|
||||
assert db.query(KPIAlert).count() == before, "Bot 通道伪期间不入库"
|
||||
assert r["alerts"] == []
|
||||
assert r.get("skipped_non_month_period") == 1
|
||||
|
||||
def test_bot_bridge_demo_entity_no_alert(self, db: Session):
|
||||
"""R1:bot_bridge 写 demo 账套(显式 entity_id=6)→ 不产生业务预警"""
|
||||
from app.api.bot_bridge import bot_kpi_value_with_check
|
||||
|
||||
k = self._bot_kpi(db, entity_id=6, code="DEMO_REVENUE")
|
||||
_alert_rule(db, k.id, entity_id=6)
|
||||
before = db.query(KPIAlert).count()
|
||||
|
||||
r = bot_kpi_value_with_check(
|
||||
data={"kpi_id": k.id, "actual_value": 5.0, "period": "2026-09", "entity_id": 6},
|
||||
db=db, bot={"name": "test-bot"})
|
||||
db.expire_all()
|
||||
|
||||
assert db.query(KPIAlert).count() == before
|
||||
assert r["alerts"] == [] and r.get("reason") == "R1_demo_entity_excluded"
|
||||
|
||||
def test_bot_bridge_v2_pseudo_period_no_alert(self, db: Session):
|
||||
"""bot_bridge_v2(mpm-result):period=2026-H1 → 不产生预警行"""
|
||||
from app.api.bot_bridge_v2 import receive_mpm_result
|
||||
from app.models import MpmResult
|
||||
|
||||
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
|
||||
_ent(db, 1, "active")
|
||||
create_test_kpi(db, kpi_code="C_REBATE_RATE", kpi_name="渠补率", entity_id=1, unit="%")
|
||||
before = db.query(KPIAlert).count()
|
||||
|
||||
r = receive_mpm_result(
|
||||
data={"entity_id": 1, "period": "2026-H1",
|
||||
"results": {"channel_rebate_rate": 80.0}},
|
||||
bridge_bot="test-bridge", db=db)
|
||||
db.expire_all()
|
||||
|
||||
assert db.query(KPIAlert).count() == before, "Bot 通道伪期间不入库"
|
||||
assert r["alerts_triggered"] == 0
|
||||
assert r.get("skipped_non_month_period") is True
|
||||
|
||||
def test_bot_bridge_v2_demo_entity_no_alert(self, db: Session):
|
||||
"""R1:bot_bridge_v2 写 demo 账套 → 不产生业务预警(规整月也不放行)"""
|
||||
from app.api.bot_bridge_v2 import receive_mpm_result
|
||||
from app.models import MpmResult
|
||||
|
||||
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
|
||||
_ent(db, 6, "demo", "演示企业(虚构数据)")
|
||||
create_test_kpi(db, kpi_code="C_REBATE_RATE", kpi_name="渠补率", entity_id=6, unit="%")
|
||||
before = db.query(KPIAlert).count()
|
||||
|
||||
r = receive_mpm_result(
|
||||
data={"entity_id": 6, "period": "2026-09",
|
||||
"results": {"channel_rebate_rate": 80.0}},
|
||||
bridge_bot="test-bridge", db=db)
|
||||
db.expire_all()
|
||||
|
||||
assert db.query(KPIAlert).count() == before
|
||||
assert r["alerts_triggered"] == 0 and r.get("reason") == "R1_demo_entity_excluded"
|
||||
|
||||
|
||||
# ─────────────────── 类B:无月度期间族(现状登记,待产品侧) ───────────────────
|
||||
|
||||
class TestR5ClassBStatusQuo:
|
||||
"""类B 现状登记(非守卫断言)——1b 实测证据:这些生成路径产出的行**期间无从从库派生**。
|
||||
|
||||
裁定演进(2026-09-19 §补充裁定 R5-补2):类B「无月度期间族」**不屏蔽(现状保留)**,
|
||||
理由 = 驱动量是日期/占比/趋势,可行动性不以月度期间为前提(属 R5 边界条款所指
|
||||
「无期间合法预警」业务需求);屏蔽会一次性灭掉「到期未收款」等既有能力(存量 423 行族)
|
||||
并触发既有套件失败 → **登记为已知例外,不再作为待决策项挂账**。
|
||||
|
||||
⚠ 差异路径原属本类,**已由补1 转入类A**(必须落 kpi_value_id,不可归期即跳过)——
|
||||
其守卫与负对照用例见 `tests/test_alert_caliber_r5_residual_20260919.py`,
|
||||
本类不再保留其「无期间关联」的现状断言(该断言与补1 冲突,已随裁定删除)。
|
||||
类B 余族的现状登记同样落在该新套件(`TestR5ResidualClassBStatusQuo`)。
|
||||
"""
|
||||
|
||||
# 原 `test_deviation_path_row_has_no_derivable_period`(差异路径「无期间关联」现状断言)
|
||||
# 已随 §补充裁定 R5-补1 删除:差异路径转为类A,必须落 kpi_value_id,不可归期即跳过。
|
||||
# 替代用例(含负对照:旧逻辑取 NULL / 新逻辑取该期间行)见
|
||||
# tests/test_alert_caliber_r5_residual_20260919.py::TestR5ResidualDeviationLinkage(6 例)。
|
||||
@@ -0,0 +1,317 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""预警「期间可归期」残口收敛测试(2026-09-19,任务 fullstack-20260919-alert-period-linkage-r5-residual)
|
||||
|
||||
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`
|
||||
|
||||
- **§R5**(2026-09-19):预警生效判据 = 期间为规整月 `YYYY-MM`;不可归期者不参与预警。
|
||||
- **§补充裁定 R5-补1**(同日,收口残口一):差异/趋势/差异反打路径**必须落 `kpi_value_id`**
|
||||
(指向同账套 + 同 KPI + 文案所示规整月期间的 `kpi_values` 行);文案期间不可解析为 `YYYY-MM`
|
||||
或该 (kpi_id, period) 无 `kpi_values` 行 → 跳过不入库;趋势类以**趋势末端当期**为关联期间;
|
||||
差异类去重键改为 `(entity_id, kpi_id, kpi_value_id, alert_type)`;
|
||||
`deviation_push` 同源接入 `guard_alert_period` + `entity_alert_guard`,请求体无 `period` 一律不落库。
|
||||
- **§补充裁定 R5-补2**(同日,收口残口二):类B「无月度期间族」**不屏蔽(现状保留)**,
|
||||
登记为已知例外,不与类A 混算。
|
||||
|
||||
本套件每个「期间关联」用例均带**负对照**:夹具同时给出「旧逻辑会取到的目标(未关联 / 别期行)」
|
||||
与「新逻辑应取到的目标」,断言新逻辑取新目标、且**不等于**旧目标——否则判据未生效也能通过。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from tests.conftest import create_test_kpi, create_test_user
|
||||
from app.models import (Entity, KPIValue, KPIAlert, KPIDefinition, StrategicMap,
|
||||
ActionPlan, OperationLog)
|
||||
|
||||
NOW = datetime(2026, 9, 19, 10, 0)
|
||||
|
||||
|
||||
def _ent(db, eid, status, name=None):
|
||||
db.add(Entity(id=eid, name=name or ("账套%d" % eid), short_name="e%d" % eid, status=status))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _kpi_value(db, kpi, period, value, entity_id=None):
|
||||
row = KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _trigger_deviation(db, monkeypatch):
|
||||
"""隔离趋势分支:只观察差异分支的落库形态"""
|
||||
import app.utils.deviation_engine as de
|
||||
|
||||
monkeypatch.setattr(de, "build_deviation_alert",
|
||||
lambda db_s, kpi, period, *a, **kw: {
|
||||
"triggered": True, "kpi_alert_level": "red", "alert_type": "budget",
|
||||
"suggestion": "探针建议",
|
||||
"deviation": {"actual_value": 88.8, "budget_value": 10.0,
|
||||
"deviation_rate": 788.0}})
|
||||
monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **kw: {"anomaly": False})
|
||||
return de
|
||||
|
||||
|
||||
# ─────────────────── 残口一:差异/趋势路径期间关联(补1) ───────────────────
|
||||
|
||||
class TestR5ResidualDeviationLinkage:
|
||||
def test_diff_alert_links_current_period_row_not_other_period(self, db: Session, monkeypatch):
|
||||
"""差异预警必须关联「文案所示期间」那一行(负对照:不得关联别期行,也不得留空)
|
||||
|
||||
夹具两个 kpi_values 行:2026-06(旧逻辑在别处的可能目标 / 干扰项)与 2026-07(文案期间)。
|
||||
"""
|
||||
de = _trigger_deviation(db, monkeypatch)
|
||||
_ent(db, 1, "active")
|
||||
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
|
||||
kv_prev = _kpi_value(db, k, "2026-06", 60.0)
|
||||
kv_cur = _kpi_value(db, k, "2026-07", 88.8)
|
||||
|
||||
n = de.run_deviation_check(db, period="2026-07")
|
||||
db.expire_all()
|
||||
|
||||
rows = db.query(KPIAlert).all()
|
||||
assert n == 1 and len(rows) == 1
|
||||
assert rows[0].kpi_value_id == kv_cur.id, "新逻辑取新目标(文案期间 2026-07 的 kpi_values 行)"
|
||||
assert rows[0].kpi_value_id != kv_prev.id, "负对照:不得关联别期行"
|
||||
assert rows[0].kpi_value_id is not None, "负对照:旧逻辑取旧目标(NULL,期间不可派生)"
|
||||
# 派生期间可复算 = 可归期
|
||||
derived = db.query(KPIValue).filter(KPIValue.id == rows[0].kpi_value_id).first()
|
||||
assert derived.period == "2026-07"
|
||||
|
||||
def test_diff_alert_skipped_when_no_kpi_value_row(self, db: Session, monkeypatch):
|
||||
"""该 (kpi, period) 无 kpi_values 行 → 跳过不入库(不得以 NULL 关联落行)"""
|
||||
de = _trigger_deviation(db, monkeypatch)
|
||||
_ent(db, 1, "active")
|
||||
create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
|
||||
|
||||
n = de.run_deviation_check(db, period="2026-07")
|
||||
db.expire_all()
|
||||
|
||||
assert n == 0
|
||||
assert db.query(KPIAlert).count() == 0
|
||||
|
||||
def test_trend_alert_links_trend_end_period(self, db: Session, monkeypatch):
|
||||
"""趋势类以「计算基准期(趋势末端当期)」为关联期间(负对照:不得关联趋势起点期)"""
|
||||
import app.utils.deviation_engine as de
|
||||
|
||||
monkeypatch.setattr(de, "build_deviation_alert", lambda *a, **kw: {"triggered": False})
|
||||
_ent(db, 1, "active")
|
||||
k = create_test_kpi(db, kpi_code="SALES_TOTAL", kpi_name="销售额", entity_id=1, unit="元")
|
||||
kv_start = _kpi_value(db, k, "2026-05", 30.0)
|
||||
_kpi_value(db, k, "2026-06", 20.0)
|
||||
kv_end = _kpi_value(db, k, "2026-07", 10.0) # 连续 3 期下滑 → 趋势末端
|
||||
|
||||
n = de.run_deviation_check(db, period="2026-07")
|
||||
db.expire_all()
|
||||
|
||||
rows = db.query(KPIAlert).all()
|
||||
assert n == 1 and len(rows) == 1
|
||||
assert "[趋势预警]" in rows[0].alert_message
|
||||
assert rows[0].kpi_value_id == kv_end.id, "新逻辑:关联趋势末端当期"
|
||||
assert rows[0].kpi_value_id != kv_start.id, "负对照:不得关联趋势起点期"
|
||||
assert rows[0].kpi_value_id is not None, "负对照:旧逻辑取旧目标(NULL)"
|
||||
|
||||
def test_trend_alert_skipped_when_base_period_not_attributable(self, db: Session, monkeypatch):
|
||||
"""趋势末端期间不可归期(非 YYYY-MM)→ 跳过不入库(补1:文案期间不可解析即跳过)"""
|
||||
import app.utils.deviation_engine as de
|
||||
|
||||
monkeypatch.setattr(de, "build_deviation_alert", lambda *a, **kw: {"triggered": False})
|
||||
monkeypatch.setattr(de, "check_trend_anomaly",
|
||||
lambda *a, **kw: {"anomaly": True, "level": "yellow",
|
||||
"message": "连续3期下滑", "periods": ["2026-H1"]})
|
||||
_ent(db, 1, "active")
|
||||
create_test_kpi(db, kpi_code="SALES_TOTAL", kpi_name="销售额", entity_id=1, unit="元")
|
||||
|
||||
n = de.run_deviation_check(db, period="2026-07")
|
||||
db.expire_all()
|
||||
|
||||
assert n == 0 and db.query(KPIAlert).count() == 0
|
||||
|
||||
def test_diff_dedupe_key_is_linkage_tuple(self, db: Session, monkeypatch):
|
||||
"""去重键改为 (entity_id, kpi_id, kpi_value_id, alert_type)
|
||||
|
||||
负对照:预置一条「文案含同期间、但 kpi_value_id 为空」的历史行(= 旧去重键会命中的形态),
|
||||
新逻辑按可归期目标去重 → 仍应补一条带关联的预警(而非被文案去重吃掉)。
|
||||
"""
|
||||
de = _trigger_deviation(db, monkeypatch)
|
||||
_ent(db, 1, "active")
|
||||
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
|
||||
_kpi_value(db, k, "2026-07", 88.8)
|
||||
# 旧形态遗留行:文案带期间、无期间关联
|
||||
db.add(KPIAlert(entity_id=1, kpi_id=k.id, kpi_value_id=None, alert_level="red",
|
||||
alert_type="budget", status="pending",
|
||||
alert_message="[差异预警] 新客户数[2026-07] 差异预警: 实际88.8 vs 预算10.0,差异率788.0%"))
|
||||
db.commit()
|
||||
|
||||
n = de.run_deviation_check(db, period="2026-07")
|
||||
db.expire_all()
|
||||
|
||||
rows = db.query(KPIAlert).order_by(KPIAlert.id).all()
|
||||
assert n == 1 and len(rows) == 2, "新去重键按可归期目标判定 → 补一条带关联的行"
|
||||
linked = [r for r in rows if r.kpi_value_id is not None]
|
||||
assert len(linked) == 1 and linked[0].alert_type == "budget"
|
||||
|
||||
def test_diff_dedupe_does_not_duplicate_same_target(self, db: Session, monkeypatch):
|
||||
"""正对照:同一可归期目标重复触发不重复入库(幂等)"""
|
||||
de = _trigger_deviation(db, monkeypatch)
|
||||
_ent(db, 1, "active")
|
||||
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
|
||||
_kpi_value(db, k, "2026-07", 88.8)
|
||||
|
||||
assert de.run_deviation_check(db, period="2026-07") == 1
|
||||
assert de.run_deviation_check(db, period="2026-07") == 0
|
||||
assert db.query(KPIAlert).count() == 1
|
||||
|
||||
|
||||
# ─────────────────── 残口三:deviation_push 的 R1/R5 缺口(补1) ───────────────────
|
||||
|
||||
class TestR5ResidualDeviationPush:
|
||||
def _fixture(self, db, entity_id=1, code="TEST_001"):
|
||||
k = create_test_kpi(db, kpi_code=code, kpi_name="探针KPI", entity_id=entity_id,
|
||||
unit="%", target_value=100.0)
|
||||
db.add(StrategicMap(entity_id=entity_id, title="探针地图", status="published",
|
||||
dimensions=[{"key": "finance", "name": "财务维度",
|
||||
"objectives": [{"name": "探针目标", "kpis": [code]}]}]))
|
||||
db.commit()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).first()
|
||||
return k, m
|
||||
|
||||
def _call(self, db, m, data, entity_id=1):
|
||||
from app.api.deviation_push import push_deviation_to_map
|
||||
from app.models import User
|
||||
|
||||
uname = "r5res%d" % entity_id
|
||||
user = db.query(User).filter(User.username == uname).first()
|
||||
if user is None:
|
||||
user = create_test_user(db, username=uname, role="ceo")
|
||||
return push_deviation_to_map(data=data, db=db, entity_id=entity_id, current_user=user)
|
||||
|
||||
def test_demo_entity_alert_skipped_value_still_written(self, db: Session):
|
||||
"""R1:demo 会话账套不产生业务预警(KPI 值照写,与 bot_bridge 同口径)"""
|
||||
_ent(db, 6, "demo", "演示企业(虚构数据)")
|
||||
k, m = self._fixture(db, entity_id=6, code="DEMO_PUSH")
|
||||
|
||||
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
|
||||
"period": "2026-09", "createReviewTopic": False}, entity_id=6)
|
||||
db.expire_all()
|
||||
|
||||
assert r["alert_created"] is False
|
||||
assert r["alert_skipped_reason"] == "R1_demo_entity_excluded"
|
||||
assert db.query(KPIAlert).count() == 0
|
||||
assert db.query(KPIValue).filter(KPIValue.kpi_id == k.id).count() == 1, "值照写"
|
||||
|
||||
def test_pseudo_period_alert_skipped(self, db: Session):
|
||||
"""R5:伪期间请求不产生预警行(返回体登记跳过原因)"""
|
||||
_ent(db, 1, "active")
|
||||
k, m = self._fixture(db, code="PSEUDO_PUSH")
|
||||
|
||||
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
|
||||
"period": "2026-H1", "createReviewTopic": False})
|
||||
db.expire_all()
|
||||
|
||||
assert r["alert_created"] is False
|
||||
assert r["alert_skipped_reason"] == "R5_non_attributable_period"
|
||||
assert db.query(KPIAlert).count() == 0
|
||||
|
||||
def test_missing_period_zero_write(self, db: Session):
|
||||
"""补1:「请求体无 period 一律不落库」——整请求零业务写入"""
|
||||
_ent(db, 1, "active")
|
||||
self._fixture(db, code="NOPERIOD_PUSH")
|
||||
|
||||
r = self._call(db, {"id": 1}, {"mapId": 1, "nodeId": "finance-0", "newValue": 10.0})
|
||||
db.expire_all()
|
||||
|
||||
assert r["alert_created"] is False and r["zero_write"] is True
|
||||
assert r["alert_skipped_reason"] == "no_period"
|
||||
assert db.query(KPIValue).count() == 0, "不得用当月兜底冒充期间写值"
|
||||
assert db.query(KPIAlert).count() == 0
|
||||
assert db.query(ActionPlan).count() == 0
|
||||
assert db.query(OperationLog).count() == 0
|
||||
|
||||
def test_valid_request_alert_links_request_period_row(self, db: Session):
|
||||
"""正对照 + 期间关联:规整月 + active 账套 → 预警落库且关联本次请求期间的行"""
|
||||
_ent(db, 1, "active")
|
||||
k, m = self._fixture(db, code="VALID_PUSH")
|
||||
kv_other = _kpi_value(db, k, "2026-08", 50.0) # 干扰项:别期行
|
||||
|
||||
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
|
||||
"period": "2026-09", "createReviewTopic": False})
|
||||
db.expire_all()
|
||||
|
||||
assert r["alert_created"] is True and r["alert_skipped_reason"] is None
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == r["alert_id"]).first()
|
||||
linked = db.query(KPIValue).filter(KPIValue.id == alert.kpi_value_id).first()
|
||||
assert linked is not None and linked.period == "2026-09"
|
||||
assert alert.kpi_value_id != kv_other.id, "负对照:不得关联别期行"
|
||||
|
||||
def test_repeat_push_reuses_value_row_linkage(self, db: Session):
|
||||
"""同一 (kpi, period) 二次反打 → kpi_values 行复用(值就地更新,不新增行)
|
||||
|
||||
现状登记:本端点**无预警去重**(第二次反打仍落一条 pending 预警,关联同一 `kpi_values` 行)——
|
||||
裁定 R5-补1 未规定该端点的去重语义(只规定了差异/趋势类的去重键),故按现状登记,
|
||||
见报告「观察项」;本用例只锁「期间关联一致 + kpi_values 不重复」这两条已定口径。
|
||||
"""
|
||||
_ent(db, 1, "active")
|
||||
k, m = self._fixture(db, code="REPEAT_PUSH")
|
||||
d = {"mapId": 1, "nodeId": "finance-0", "newValue": 10.0,
|
||||
"period": "2026-09", "createReviewTopic": False}
|
||||
|
||||
r1 = self._call(db, m, dict(d))
|
||||
r2 = self._call(db, m, dict(d))
|
||||
db.expire_all()
|
||||
|
||||
assert db.query(KPIValue).filter(KPIValue.kpi_id == k.id).count() == 1, "值行复用"
|
||||
rows = db.query(KPIAlert).order_by(KPIAlert.id).all()
|
||||
assert len(rows) == 2, "现状登记:该端点无预警去重"
|
||||
assert rows[0].kpi_value_id == rows[1].kpi_value_id, "两次反打关联同一期间行"
|
||||
assert r1["alert_id"] != r2["alert_id"]
|
||||
|
||||
|
||||
# ─────────────────── 残口二:类B 不屏蔽(补2,现状登记) ───────────────────
|
||||
|
||||
class TestR5ResidualClassBStatusQuo:
|
||||
"""补2 裁定:类B「无月度期间族」**不屏蔽(现状保留)**,登记为已知例外。
|
||||
|
||||
本类为**现状登记**(非守卫断言):这些族的驱动量是日期/占比/趋势,其可行动性不以月度期间
|
||||
为前提;屏蔽会灭掉「到期未收款」等既有能力(存量 423 行族)并触发既有套件失败。
|
||||
若产品侧日后改判屏蔽,本类应随之更新(与 `test_alert_caliber_r5_20260919.py` 同口径)。
|
||||
"""
|
||||
|
||||
def test_customer_concentration_site_still_writes_without_linkage(self, db: Session, monkeypatch):
|
||||
"""集中度(类B,alert_rules:940)落库仍**不经** R5 期间守卫 → 行内无 kpi_value 关联
|
||||
|
||||
夹具以 monkeypatch 替代占比计算,观察点 = 「落库是否被期间守卫拦截」。
|
||||
"""
|
||||
from app.api import alert_rules as ar
|
||||
|
||||
ar.AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
|
||||
_ent(db, 1, "active")
|
||||
k = create_test_kpi(db, kpi_code="CONC_KPI", kpi_name="客户集中度", entity_id=1, unit="%")
|
||||
db.add(ar.AlertRule(entity_id=1, kpi_id=k.id, rule_type="customer_concentration", enabled=1,
|
||||
params={"threshold": 30}))
|
||||
db.commit()
|
||||
monkeypatch.setattr(ar, "_check_customer_concentration",
|
||||
lambda *a, **kw: ("red", "[客户集中度] 探针 占比 88%"))
|
||||
|
||||
r = ar.run_customer_concentration_check(db=db, entity_id=1)
|
||||
db.expire_all()
|
||||
|
||||
rows = db.query(KPIAlert).filter(KPIAlert.alert_type == "concentration").all()
|
||||
assert r["generated"] == 1 and len(rows) == 1, "类B 现状保留:集中度预警照常入库"
|
||||
assert rows[0].kpi_value_id is None, "类B 行不携带月度期间(登记形态)"
|
||||
|
||||
def test_class_b_creation_sites_have_no_r5_period_guard(self):
|
||||
"""登记:类B 其余创建点(集中度/预测值/资金缺口/到期未收款)**未接入** R5 期间守卫
|
||||
|
||||
判据 = 源码级登记(这些文件在本单中零改动),与 `git diff --stat` 互为佐证。
|
||||
"""
|
||||
import inspect
|
||||
from app.api import alert_rules as ar
|
||||
from app.utils import cash_forecast_engine as cfe
|
||||
|
||||
for fn in (ar.run_customer_concentration_check,
|
||||
ar.run_forecast_alert_check,
|
||||
cfe.check_cash_alerts):
|
||||
src = inspect.getsource(fn)
|
||||
assert "guard_alert_period" not in src, fn.__name__
|
||||
@@ -0,0 +1,182 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""CMA 行动计划自动生成写入点 —— 补账套 + 写前同源校验(2026-09-22 派生单)
|
||||
|
||||
任务:0-P1-project-20260922-actionplan-entity-write-fullstack
|
||||
父单:project-20260921-cma-entity-isolation-batch5(§B 治本 = commit 0c2e57a)
|
||||
同族已修(勿重做):app/api/alerts.py · deviation_push.py · ai_suggestions.py
|
||||
—— commit d03e322(2026-09-18 派生单,已发布)
|
||||
|
||||
缺陷(代码级):
|
||||
scripts/alert_generator.py::run_alert_check 为 red/yellow 预警自动建 ActionPlan 时
|
||||
**不传 entity_id**(models.ActionPlan.entity_id default=1 + 生产 DDL `DEFAULT 1` 双默认静默兜底)
|
||||
⇒ 非 e1 账套的 KPI 触发阈值时,同一次事务里「预警归属正确、计划归属落 1」。
|
||||
读侧 dashboard.py 工作台按 `ActionPlan.entity_id == 会话账套` 取出计划、
|
||||
KPI 名不做账套过滤 → 酣客(e1) 工作台直接渲染博海(e2)/演示(e6) 的 KPI 名。
|
||||
实测:action_plans 中 entity_id ≠ 其 KPI 的 entity_id 的行 19/83,且 09-17/09-18/09-22
|
||||
07:00 各新增 6/7/6 行(每日复发)。
|
||||
|
||||
本套件(RED/GREEN 两态原始输出见 pending-tasks/_reports/ 同名单回报):
|
||||
· T1(改前 RED)e2 的 KPI 触发 → 计划 entity_id == 2;**同用例内**断言
|
||||
`alert.entity_id == plan.entity_id`(防只断言一侧)
|
||||
· T2(改前 RED)KPI 归属为空 → 不建计划 + `OperationLog(action='skip_plan')` 恰 1 条,
|
||||
且**流水线不中断**(预警仍按既有行为落库),统计计数 `skipped_plan_entity_mismatch == 1`
|
||||
· T3(改前 RED)守卫单元级:`kpi.entity_id(2) != 目标账套(1)` → 不建计划 + 1 条 skip_plan
|
||||
· T4(改前即绿、不构成本单判据,仅作正对照)e1 的 KPI 触发 → 计划 entity_id == 1,
|
||||
守卫零误杀、零 skip_plan
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
ActionPlan, KPIAlert, KPIDefinition, KPIValue, OperationLog,
|
||||
)
|
||||
from tests.conftest import create_test_kpi
|
||||
from scripts import alert_generator
|
||||
|
||||
#: 固定「当月」= 2026-09(口径复算不依赖真实时钟;窗口下限 = 2026-03)
|
||||
NOW = datetime(2026, 9, 22, 10, 0)
|
||||
PERIOD = "2026-09"
|
||||
|
||||
#: 阈值/实测形态沿用同族既有用例(test_alert_caliber_r1r4_20260919):
|
||||
#: 避免命中裁定2a「疑似单位错配」降级门(阈值百分数写法 + 实测比率量级)
|
||||
RED_EXPR = "<58.4"
|
||||
ACTUAL = 14.45
|
||||
|
||||
|
||||
def _kpi(db: Session, code: str, eid, **kw) -> KPIDefinition:
|
||||
"""本账套 KPI(entity_id 可为 None —— 模拟「归属缺失」的生产形态)"""
|
||||
return create_test_kpi(db, kpi_code=code, kpi_name=f"{code}-名称", entity_id=eid,
|
||||
unit="%", threshold_red=RED_EXPR, **kw)
|
||||
|
||||
|
||||
def _value(db: Session, kpi: KPIDefinition, period: str, value: float, entity_id: int):
|
||||
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _plans(db: Session, kpi: KPIDefinition):
|
||||
db.expire_all()
|
||||
return db.query(ActionPlan).filter(ActionPlan.kpi_id == kpi.id).all()
|
||||
|
||||
|
||||
def _alerts(db: Session, kpi: KPIDefinition):
|
||||
db.expire_all()
|
||||
return db.query(KPIAlert).filter(KPIAlert.kpi_id == kpi.id).all()
|
||||
|
||||
|
||||
def _skip_logs(db: Session):
|
||||
db.expire_all()
|
||||
return db.query(OperationLog).filter(OperationLog.action == "skip_plan").all()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T1. 写入点补账套:计划归属 = 来源 KPI 的账套(改前 RED)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_t1_plan_entity_follows_kpi_entity_of_other_tenant(db: Session):
|
||||
"""e2 的 KPI 触发 → 计划 entity_id 必须是 2,且与同事务预警归属一致"""
|
||||
k2 = _kpi(db, "ZZ_APEN_E2_KPI", 2)
|
||||
_value(db, k2, PERIOD, ACTUAL, entity_id=2)
|
||||
|
||||
st = {}
|
||||
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
|
||||
|
||||
alerts = _alerts(db, k2)
|
||||
plans = _plans(db, k2)
|
||||
assert len(alerts) == 1, f"e2 KPI 应生成 1 条预警,实际 {len(alerts)}"
|
||||
assert len(plans) == 1, f"e2 KPI 应生成 1 条行动计划,实际 {len(plans)}"
|
||||
assert plans[0].entity_id == 2, (
|
||||
f"计划 entity_id 应为来源 KPI 的账套 2,实际 {plans[0].entity_id}"
|
||||
" —— alert_generator 构造 ActionPlan 未写 entity_id,落模型/DDL 默认值 1"
|
||||
)
|
||||
# 同用例内同时断言两侧归属(防「只断言一侧」漏掉预警侧回归)
|
||||
assert alerts[0].entity_id == plans[0].entity_id == 2, (
|
||||
f"预警与计划归属必须同源:alert.entity_id={alerts[0].entity_id} "
|
||||
f"plan.entity_id={plans[0].entity_id}"
|
||||
)
|
||||
assert plans[0].alert_id == alerts[0].id, "计划未挂到同事务预警上"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T2. 写前同源校验:归属缺失 → 不建计划 + skip_plan 留痕(改前 RED)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_t2_missing_kpi_owner_skips_plan_and_logs(db: Session):
|
||||
"""KPI 归属为空(任一为空即命中守卫)→ 不建计划 + 1 条 skip_plan + 流水线不中断
|
||||
|
||||
夹具说明:`KPIDefinition.entity_id` 有 ORM 侧 `default=1`,经 ORM 建行时 None 会被兜底成 1,
|
||||
故「归属为空」只能用 **Core UPDATE 显式置 NULL** 构造(对应生产里绕过 ORM 的直插/导入行)。
|
||||
"""
|
||||
k = _kpi(db, "ZZ_APEN_NO_OWNER_KPI", 2)
|
||||
db.query(KPIDefinition).filter(KPIDefinition.id == k.id).update({"entity_id": None})
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
k = db.query(KPIDefinition).filter(KPIDefinition.id == k.id).first()
|
||||
assert k.entity_id is None, "夹具失败:KPI 归属未置空"
|
||||
_value(db, k, PERIOD, ACTUAL, entity_id=2)
|
||||
|
||||
st = {}
|
||||
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
|
||||
|
||||
assert _plans(db, k) == [], (
|
||||
"KPI 归属为空时不得建计划(否则静默落默认账套 1 = 跨账套污染)"
|
||||
)
|
||||
logs = _skip_logs(db)
|
||||
assert len(logs) == 1, f"应恰有 1 条 skip_plan 留痕,实际 {len(logs)}"
|
||||
log = logs[0]
|
||||
assert log.target_type == "action_plan", f"target_type 应为 action_plan,实际 {log.target_type}"
|
||||
detail = log.detail or {}
|
||||
assert detail.get("kpi_code") == "ZZ_APEN_NO_OWNER_KPI", detail
|
||||
assert "kpi_entity_id" in detail and "entity_id" in detail, detail
|
||||
assert detail.get("kpi_entity_id") is None, detail
|
||||
# 不中断流水线:预警侧既有行为不变(本单不改预警归属,仍按 `kpi.entity_id or 1` 落库)
|
||||
assert len(_alerts(db, k)) == 1, "守卫不得中断预警生成(只跳过计划)"
|
||||
# 统计输出必须计入(不得静默丢弃)
|
||||
assert st.get("skipped_plan_entity_mismatch") == 1, st
|
||||
assert st.get("skipped_plan_kpis"), st
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T3. 守卫单元级:KPI 账套 ≠ 目标账套(改前 RED:守卫函数尚不存在)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_t3_guard_rejects_kpi_entity_not_matching_target(db: Session):
|
||||
"""kpi.entity_id(=2) != 目标账套(=1) → 守卫返回 False + 1 条 skip_plan + 零计划"""
|
||||
k2 = _kpi(db, "ZZ_APEN_GUARD_KPI", 2)
|
||||
alert = KPIAlert(entity_id=1, kpi_id=k2.id, alert_level="red",
|
||||
alert_message="ZZ_APEN 跨账套守卫", status="pending")
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
st = {}
|
||||
ok = alert_generator.action_plan_entity_guard(db, kpi=k2, alert=alert, stats=st)
|
||||
db.commit() # 守卫只 add 留痕,落库由调用方提交(此处模拟 run_alert_check 的 commit;会话 autoflush=False)
|
||||
|
||||
assert ok is False, "KPI 账套与目标账套不一致时不得建计划"
|
||||
assert db.query(ActionPlan).count() == 0
|
||||
logs = _skip_logs(db)
|
||||
assert len(logs) == 1, f"应恰有 1 条 skip_plan 留痕,实际 {len(logs)}"
|
||||
detail = logs[0].detail or {}
|
||||
assert detail.get("kpi_entity_id") == 2 and detail.get("entity_id") == 1, detail
|
||||
assert st.get("skipped_plan_entity_mismatch") == 1, st
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T4. 正对照(改前即绿、不构成本单判据):e1 本账套计划不得被守卫误杀
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_t4_same_tenant_plan_still_created(db: Session):
|
||||
"""e1 的 KPI 触发 → 计划 entity_id == 1,且零 skip_plan(守卫零误杀)"""
|
||||
k1 = _kpi(db, "ZZ_APEN_E1_KPI", 1)
|
||||
_value(db, k1, PERIOD, ACTUAL, entity_id=1)
|
||||
|
||||
st = {}
|
||||
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
|
||||
|
||||
plans = _plans(db, k1)
|
||||
assert len(plans) == 1, f"e1 KPI 应生成 1 条计划,实际 {len(plans)}"
|
||||
assert plans[0].entity_id == 1, f"e1 计划 entity_id 应为 1,实际 {plans[0].entity_id}"
|
||||
assert _skip_logs(db) == [], "本账套同源不得产生 skip_plan"
|
||||
assert st.get("skipped_plan_entity_mismatch", 0) == 0, st
|
||||
@@ -0,0 +1,194 @@
|
||||
"""战略执行看板(/alignment)账套隔离回归测试 — 2026-09-18
|
||||
|
||||
任务:project-20260918-alignment-entity-leak(任总 2026-09-18 派单)
|
||||
缺陷:GET /api/cma/alignment/tree 未接账套(entity_id)→ 返回全实体 active KPI,
|
||||
三种对齐模式共用同一份 KPI 列表 → 酣客账套视图混入博海 KPI。
|
||||
|
||||
本用例覆盖(三模式 × 双向隔离):
|
||||
1. 纵向分解 / 横向支撑 / BSC瀑布链 三种模式,均只返回会话账套的 KPI;
|
||||
2. 双向:entity1 会话看不到 entity2 的 KPI,entity2 会话看不到 entity1 的 KPI;
|
||||
3. 跨实体显式传参 ?entity_id=<对方> → 403(对齐 get_entity_id 既有语义);
|
||||
4. 鉴权语义未改:无 token → 401;
|
||||
5. total 计数 = 本账套 active KPI 数(不是全库)。
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Entity, KPIDefinition, RolePermission
|
||||
from tests.conftest import (
|
||||
TEST_USER_PASSWORD,
|
||||
auth_header,
|
||||
create_test_user,
|
||||
get_token_for_user,
|
||||
)
|
||||
|
||||
ALIGN = "/api/cma/alignment"
|
||||
MODES = ["vertical_decomposition", "horizontal_support", "bsc_chain"]
|
||||
|
||||
# 两个账套各自的 KPI 编码(kpi_code 库内唯一,故两侧编码前缀不同)
|
||||
E1_CODES = ["HK_REVENUE_001", "HK_REVENUE_002", "HK_PROFIT_001"]
|
||||
E2_CODES = ["BH_REVENUE_001", "BH_PROFIT_001"]
|
||||
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _set_mode(db: Session, mode: str, entity_ids=(1, 2)) -> None:
|
||||
"""写入对齐模式配置 —— 2026-09-22 B3(a) 拆账套后为 `alignment_config.<entity_id>`
|
||||
|
||||
本文件断言的语义 =「两账套处在同一对齐模式时,各自只返回本账套 KPI」,
|
||||
故对 e1/e2 各写一份(修复前为全局唯一键,一份两账套共用)。
|
||||
"""
|
||||
for eid in entity_ids:
|
||||
key = f"alignment_config.{eid}"
|
||||
perm = db.query(RolePermission).filter(RolePermission.key == key).first()
|
||||
if perm:
|
||||
perm.value = {"mode": mode}
|
||||
else:
|
||||
db.add(RolePermission(key=key, value={"mode": mode}))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int, dimension: str, category: str) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension=dimension,
|
||||
category=category,
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _seed(db: Session) -> None:
|
||||
"""两个账套各 2-3 个 active KPI(三模式均可构建出层级)"""
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
for c in E1_CODES:
|
||||
_mk_kpi(db, c, 1, "finance", "revenue_growth")
|
||||
for c in E2_CODES:
|
||||
_mk_kpi(db, c, 2, "finance", "revenue_growth")
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _collect_codes(nodes) -> set:
|
||||
"""递归收集树内所有 kpi_code(维度组/类别组节点无 kpi_code,跳过)"""
|
||||
found = set()
|
||||
for node in nodes or []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("kpi_code"):
|
||||
found.add(node["kpi_code"])
|
||||
found |= _collect_codes(node.get("children"))
|
||||
return found
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
def test_tree_isolated_by_entity_both_directions(client: TestClient, db: Session, mode: str):
|
||||
"""三模式 × 双向:只返回本账套 KPI,不含对方账套 code"""
|
||||
_seed(db)
|
||||
create_test_user(db)
|
||||
_set_mode(db, mode)
|
||||
|
||||
token1 = _login(client, 1)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r1 = client.get(f"{ALIGN}/tree", headers=auth_header(token1))
|
||||
assert r1.status_code == 200, r1.text
|
||||
body1 = r1.json()
|
||||
codes1 = _collect_codes(body1["tree"])
|
||||
|
||||
r2 = client.get(f"{ALIGN}/tree", headers=auth_header(token2))
|
||||
assert r2.status_code == 200, r2.text
|
||||
body2 = r2.json()
|
||||
codes2 = _collect_codes(body2["tree"])
|
||||
|
||||
# 酣客(e1):只见酣客 code,绝不含博海 code
|
||||
assert codes1 == set(E1_CODES), f"[{mode}] entity1 返回 {sorted(codes1)}"
|
||||
assert not any(c.startswith("BH_") for c in codes1), f"[{mode}] entity1 混入博海 KPI"
|
||||
# 博海(e2):反向同理
|
||||
assert codes2 == set(E2_CODES), f"[{mode}] entity2 返回 {sorted(codes2)}"
|
||||
assert not any(c.startswith("HK_") for c in codes2), f"[{mode}] entity2 混入酣客 KPI"
|
||||
|
||||
# total = 本账套 active KPI 数(修复前 = 全库 5)
|
||||
assert body1["total"] == len(E1_CODES), f"[{mode}] entity1 total={body1['total']}"
|
||||
assert body2["total"] == len(E2_CODES), f"[{mode}] entity2 total={body2['total']}"
|
||||
assert body1["mode"] == mode and body2["mode"] == mode
|
||||
|
||||
|
||||
def test_tree_cross_entity_explicit_param_403(client: TestClient, db: Session):
|
||||
"""跨实体显式传参 → 403(会话 token 绑定账套为准,未改鉴权语义外的任何行为)"""
|
||||
_seed(db)
|
||||
create_test_user(db)
|
||||
_set_mode(db, "horizontal_support")
|
||||
|
||||
token1 = _login(client, 1)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"{ALIGN}/tree?entity_id=2", headers=auth_header(token1))
|
||||
assert r.status_code == 403, f"entity1 会话读 entity2 应 403,实际 {r.status_code}"
|
||||
assert "无权访问" in r.json()["detail"]
|
||||
|
||||
r = client.get(f"{ALIGN}/tree?entity_id=1", headers=auth_header(token2))
|
||||
assert r.status_code == 403, f"entity2 会话读 entity1 应 403,实际 {r.status_code}"
|
||||
|
||||
|
||||
def test_tree_without_token_401(client: TestClient, db: Session):
|
||||
"""鉴权底线不变:无 Bearer token → 401"""
|
||||
_seed(db)
|
||||
_set_mode(db, "bsc_chain")
|
||||
r = client.get(f"{ALIGN}/tree")
|
||||
assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"
|
||||
|
||||
|
||||
def test_tree_same_entity_explicit_param_ok(client: TestClient, db: Session):
|
||||
"""同账套显式传参与 token 一致 → 200(不是一律拒绝)"""
|
||||
_seed(db)
|
||||
create_test_user(db)
|
||||
_set_mode(db, "bsc_chain")
|
||||
token1 = get_token_for_user(client) # entity1
|
||||
r = client.get(f"{ALIGN}/tree?entity_id=1", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text
|
||||
assert _collect_codes(r.json()["tree"]) == set(E1_CODES)
|
||||
|
||||
|
||||
def test_tree_kpi_id_of_other_entity_not_returned(client: TestClient, db: Session):
|
||||
"""kpi_id 指向对方账套的 KPI → 不返回该 KPI(隔离后树内查无此 id)"""
|
||||
_seed(db)
|
||||
create_test_user(db)
|
||||
_set_mode(db, "vertical_decomposition")
|
||||
other = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == E2_CODES[0]).first()
|
||||
token1 = _login(client, 1)
|
||||
r = client.get(f"{ALIGN}/tree?kpi_id={other.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body is None or not _collect_codes([body]), f"entity1 不应取到 entity2 的 KPI: {body}"
|
||||
@@ -0,0 +1,278 @@
|
||||
"""COSO内控自检「生成改善行动」端点测试
|
||||
|
||||
任务:project-20260921-coso-generate-actions
|
||||
(自检「未通过」条目 → action_plans 写路径,前端「生成改善行动」按钮的后端实现)
|
||||
|
||||
本文件覆盖
|
||||
① 生成:e1(hanke)会话点击 → 200;created == 未通过条目数(从 COSO_CHECKLIST_DATA
|
||||
现算,不写字面量);新建行 entity_id=1 / status=pending / kpi 属于 e1 /
|
||||
title 前缀 [COSO] / description 含「来源: COSO内控自检」/ auto_verify_rule is None;
|
||||
passed=True 的条目没有对应行
|
||||
② 幂等:同一会话再调一次 → created=0、skipped=上次 created、DB 行数不变
|
||||
③ 账套隔离:e2(bohai)会话生成的行 entity_id=2;e1/e2 列表互不可见
|
||||
④ 未认证:无 token → 401
|
||||
⑤ 显式 kpi_id 归属校验:e1 会话传 e2 的 kpi_id → 404(不得跨账套写)
|
||||
⑥ 无未通过条目:monkeypatch 全 passed=True → 200 / created=0 / failed_total=0 / 不建行
|
||||
"""
|
||||
import copy
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import action_plans as ap
|
||||
from app.models import ActionPlan, Entity, KPIDefinition
|
||||
from tests.conftest import (
|
||||
TEST_USER_PASSWORD,
|
||||
auth_header,
|
||||
create_test_kpi,
|
||||
create_test_user,
|
||||
get_token_for_user,
|
||||
)
|
||||
|
||||
E1_KPI_CODE = "ZZ_COSO_E1_KPI_001"
|
||||
E2_KPI_CODE = "ZZ_COSO_E2_KPI_001"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int, username: str = "testadmin") -> str:
|
||||
"""按账套登录并返回 token(e2 会话用;e1 用 conftest 的 get_token_for_user)"""
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": username,
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _failed_items(coso: dict) -> list:
|
||||
"""自检未通过条目 [(element, item)](与端点同一口径:not item.get('passed'))"""
|
||||
out = []
|
||||
for el in coso["elements"]:
|
||||
for item in el.get("items") or []:
|
||||
if not item.get("passed"):
|
||||
out.append((el, item))
|
||||
return out
|
||||
|
||||
|
||||
def _failed_titles(slug: str) -> set:
|
||||
"""按端点生成规则计算的标题集合([COSO] {el}·{item},截断 200)"""
|
||||
coso = ap.COSO_CHECKLIST_DATA[slug]
|
||||
return {
|
||||
f"[COSO] {el['name']}·{item['text']}"[:200]
|
||||
for el, item in _failed_items(coso)
|
||||
}
|
||||
|
||||
|
||||
def _seed_e1(db: Session) -> KPIDefinition:
|
||||
"""e1(酣客)基础夹具:实体 + ceo 用户 + 1 个本账套 KPI"""
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
create_test_user(db)
|
||||
return create_test_kpi(db, kpi_code=E1_KPI_CODE, kpi_name=f"{E1_KPI_CODE}-名称", entity_id=1)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ① 生成
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_generate_creates_rows_for_failed_items(client: TestClient, db: Session):
|
||||
"""生成:未通过条目各建一行、字段口径全部达标;passed=True 条目不建行
|
||||
|
||||
断言依据:hanke 自检共 13 条(未通过 10 条)——条数用代码从 COSO_CHECKLIST_DATA
|
||||
现算而非写字面量,防自检文案/条目数将来调整时用例失真。
|
||||
"""
|
||||
kpi = _seed_e1(db)
|
||||
token = get_token_for_user(client)
|
||||
h = auth_header(token)
|
||||
|
||||
resp = client.post("/api/cma/action-plans/coso-generate-actions", headers=h, json={})
|
||||
assert resp.status_code == 200, resp.text[:300]
|
||||
body = resp.json()
|
||||
|
||||
coso = ap.COSO_CHECKLIST_DATA["hanke"]
|
||||
failed = _failed_items(coso)
|
||||
expected_titles = {f"[COSO] {el['name']}·{it['text']}"[:200] for el, it in failed}
|
||||
passed_titles = {
|
||||
f"[COSO] {el['name']}·{it['text']}"[:200]
|
||||
for el in coso["elements"]
|
||||
for it in (el.get("items") or [])
|
||||
if it.get("passed")
|
||||
}
|
||||
|
||||
assert len(failed) > 0, "夹具失配:hanke 自检应存在未通过条目"
|
||||
assert body["failed_total"] == len(failed), body
|
||||
assert body["created"] == len(failed), body # 首轮无已存在行,created == 未通过总数
|
||||
assert body["skipped"] == 0, body
|
||||
assert body["entity_id"] == 1, body
|
||||
assert body["entity_slug"] == "hanke", body
|
||||
assert body["kpi_id"] == kpi.id, body # 自动选取落在本账套唯一 KPI
|
||||
assert body["source"] == "coso", body
|
||||
|
||||
rows = db.query(ActionPlan).filter(ActionPlan.title.like("[COSO]%")).all()
|
||||
assert len(rows) == body["created"] == len(body["created_ids"]), body
|
||||
actual_titles = {r.title for r in rows}
|
||||
assert actual_titles == expected_titles, "新建标题集合与未通过条目集合不一致"
|
||||
assert not (actual_titles & passed_titles), "passed=True 条目不应生成对应行"
|
||||
for r in rows:
|
||||
assert r.entity_id == 1, r.title # 写库账套只来自 token(get_entity_id)
|
||||
assert r.status == "pending", r.title
|
||||
assert r.progress == 0, r.title
|
||||
assert r.kpi_id == kpi.id, r.title
|
||||
# kpi 归属校验:挂的 KPI 必须属于本账套(列表按 KPI 归属做账套 join 的口径)
|
||||
kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
|
||||
assert kpi_ent is not None and kpi_ent.entity_id == 1, r.title
|
||||
assert r.title.startswith("[COSO]"), r.title
|
||||
assert "来源: COSO内控自检" in (r.description or ""), r.title
|
||||
assert r.auto_verify_rule is None, r.title # 显式不生成验证规则
|
||||
assert r.due_date is not None, r.title # 默认 +30 天
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ② 幂等
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_idempotent_second_call_skips_existing(client: TestClient, db: Session):
|
||||
"""幂等:同一会话第二次调用 created=0、skipped=首次 created、DB 行数不变
|
||||
|
||||
判重键 (entity_id, title):重复点击「生成改善行动」不得重复造行。
|
||||
"""
|
||||
_seed_e1(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
|
||||
first = client.post("/api/cma/action-plans/coso-generate-actions", headers=h, json={})
|
||||
assert first.status_code == 200
|
||||
body1 = first.json()
|
||||
count_after_first = db.query(ActionPlan).count()
|
||||
assert count_after_first == body1["created"] > 0
|
||||
|
||||
second = client.post("/api/cma/action-plans/coso-generate-actions", headers=h, json={})
|
||||
assert second.status_code == 200
|
||||
body2 = second.json()
|
||||
assert body2["created"] == 0, body2
|
||||
assert body2["skipped"] == body1["created"], body2
|
||||
assert body2["failed_total"] == body1["failed_total"], body2
|
||||
assert db.query(ActionPlan).count() == count_after_first, "幂等调用不得新增行"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ③ 账套隔离
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_entity_isolation_e1_e2(client: TestClient, db: Session):
|
||||
"""账套隔离:e2 生成的行 entity_id=2 且挂 e2 KPI;e1/e2 列表互不可见
|
||||
|
||||
两个账套存在同名标题(如「控制环境·授权审批制度」两个账套都未通过)——
|
||||
这正好验证判重键带账套过滤:对端账套同标题行不得抑制本账套创建,
|
||||
列表也不能把对端行算进来。
|
||||
"""
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
k1 = create_test_kpi(db, kpi_code=E1_KPI_CODE, kpi_name="e1内控KPI", entity_id=1)
|
||||
k2 = create_test_kpi(db, kpi_code=E2_KPI_CODE, kpi_name="e2内控KPI", entity_id=2)
|
||||
|
||||
h1 = auth_header(_login(client, 1))
|
||||
h2 = auth_header(_login(client, 2))
|
||||
|
||||
r1 = client.post("/api/cma/action-plans/coso-generate-actions", headers=h1, json={})
|
||||
r2 = client.post("/api/cma/action-plans/coso-generate-actions", headers=h2, json={})
|
||||
assert r1.status_code == 200, r1.text[:300]
|
||||
assert r2.status_code == 200, r2.text[:300]
|
||||
|
||||
# e2 落库行:entity_id=2 且挂 e2 自己的 KPI
|
||||
e2_rows = db.query(ActionPlan).filter(ActionPlan.entity_id == 2).all()
|
||||
assert len(e2_rows) == r2.json()["created"] == len(_failed_items(ap.COSO_CHECKLIST_DATA["bohai"]))
|
||||
for row in e2_rows:
|
||||
assert row.entity_id == 2, row.title
|
||||
assert row.kpi_id == k2.id, row.title
|
||||
|
||||
# 列表互不可见:各自列表 == 各自自检未通过标题全集(含同名标题,也绝不含对端专属行)
|
||||
e1_list = {row["title"] for row in client.get("/api/cma/action-plans", headers=h1).json()["data"]}
|
||||
e2_list = {row["title"] for row in client.get("/api/cma/action-plans", headers=h2).json()["data"]}
|
||||
assert e1_list == _failed_titles("hanke"), e1_list
|
||||
assert e2_list == _failed_titles("bohai"), e2_list
|
||||
e1_only = _failed_titles("hanke") - _failed_titles("bohai")
|
||||
e2_only = _failed_titles("bohai") - _failed_titles("hanke")
|
||||
assert not (e1_list & e2_only), "e1 列表不应出现 e2 专属条目"
|
||||
assert not (e2_list & e1_only), "e2 列表不应出现 e1 专属条目"
|
||||
# 差异集非空才说明上面的断言有鉴别力(两账套未通过集合确有重叠与差异)
|
||||
assert e1_only and e2_only
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ④ 未认证
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_unauthenticated_returns_401(client: TestClient, db: Session):
|
||||
"""未认证:无 token 调生成端点 → 401(写路径必须鉴权)"""
|
||||
_seed_e1(db)
|
||||
resp = client.post("/api/cma/action-plans/coso-generate-actions", json={})
|
||||
assert resp.status_code == 401, resp.text[:300]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ⑤ 显式 kpi_id 归属校验
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_explicit_kpi_id_must_belong_to_session_entity(client: TestClient, db: Session):
|
||||
"""显式 kpi_id 归属校验:e1 会话传 e2 的 kpi_id → 404 且不落任何行
|
||||
|
||||
kpi_id 是本账套隔离的唯一 join 锚点——若允许挂对端 KPI,
|
||||
本账套列表会按 KPI 归属 join 而丢失该行,形成「孤儿行」。
|
||||
"""
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
create_test_kpi(db, kpi_code=E1_KPI_CODE, kpi_name="e1 KPI", entity_id=1)
|
||||
k2 = create_test_kpi(db, kpi_code=E2_KPI_CODE, kpi_name="e2 KPI", entity_id=2)
|
||||
|
||||
h = auth_header(_login(client, 1))
|
||||
resp = client.post("/api/cma/action-plans/coso-generate-actions",
|
||||
headers=h, json={"kpi_id": k2.id})
|
||||
assert resp.status_code == 404, resp.text[:300]
|
||||
assert db.query(ActionPlan).count() == 0, "跨账套 kpi_id 被拒后不得残留任何行"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ⑥ 无未通过条目
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_no_failed_items_returns_zero_not_error(client: TestClient, db: Session, monkeypatch):
|
||||
"""无未通过条目:全 passed=True → 200 / created=0 / failed_total=0 / 不建行
|
||||
|
||||
monkeypatch 模块级 COSO_CHECKLIST_DATA(深拷贝,测后自动还原)——
|
||||
端点必须把它当「空结果」而不是报错。
|
||||
"""
|
||||
_seed_e1(db)
|
||||
original = ap.COSO_CHECKLIST_DATA # patch 前持有原始引用,用于测后还原校验
|
||||
patched = copy.deepcopy(ap.COSO_CHECKLIST_DATA)
|
||||
for entity_data in patched.values():
|
||||
for el in entity_data["elements"]:
|
||||
for it in el["items"]:
|
||||
it["passed"] = True
|
||||
monkeypatch.setattr(ap, "COSO_CHECKLIST_DATA", patched)
|
||||
|
||||
h = auth_header(get_token_for_user(client))
|
||||
resp = client.post("/api/cma/action-plans/coso-generate-actions", headers=h, json={})
|
||||
assert resp.status_code == 200, resp.text[:300]
|
||||
body = resp.json()
|
||||
assert body["failed_total"] == 0, body
|
||||
assert body["created"] == 0 and body["skipped"] == 0, body
|
||||
assert body["created_ids"] == [], body
|
||||
assert db.query(ActionPlan).count() == 0, "无未通过条目时不得新建行"
|
||||
|
||||
# 还原校验:patch 基于深拷贝,原始数据未被改坏(仍含未通过条目)
|
||||
assert len(_failed_items(original["hanke"])) > 0
|
||||
@@ -0,0 +1,424 @@
|
||||
"""CMA 跨实体隔离收口(第二批)账套隔离回归测试 — 2026-09-18
|
||||
|
||||
任务:0-P1-project-20260918-entity-isolation-batch2(项目Bot 派单,任总 2026-09-18 17:59 放行)
|
||||
缺陷(本单范围):
|
||||
1. 写型·列表批处理
|
||||
- POST /api/cma/budget/roll-forward 全账套 active 预算一起删最早月/加新月
|
||||
- GET /api/cma/alerts/check-timeout 全账套 pending 超时预警一起升级为 red
|
||||
2. 写型/读型·按 id 直取(无归属校验 → 跨账套可达)
|
||||
- POST /api/cma/alerts/{alert_id}/process | /escalate | /create-action-plan
|
||||
- PUT /api/cma/action-plans/{plan_id} | DELETE /api/cma/action-plans/{plan_id}
|
||||
3. 读型·已在发生
|
||||
- GET /api/cma/cost/abc/activities 全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心
|
||||
|
||||
本用例覆盖(每条均双向 + 同账套正对照):
|
||||
- 写型批处理:会话账套触发只影响本账套行,对端账套行前后逐字段不变;
|
||||
- 按 id 直取:跨账套 id → 404(对齐同文件既有语义),本账套 id → 正常;且跨账套请求后对端行未被修改/删除;
|
||||
- 读型:会话账套只返回本账套行,不含对端行。
|
||||
|
||||
修复前(旧码)本文件在下列断言上必失败 —— 即「改前/改后对照」中的改前证据(见回报 §三)。
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
from app.models import (
|
||||
AbcActivity,
|
||||
ActionPlan,
|
||||
BudgetPlan,
|
||||
Entity,
|
||||
KPIAlert,
|
||||
KPIDefinition,
|
||||
SystemConfig,
|
||||
)
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
ALERTS = "/api/cma/alerts"
|
||||
BUDGET = "/api/cma/budget"
|
||||
COST = "/api/cma/cost"
|
||||
PLANS = "/api/cma/action-plans"
|
||||
|
||||
# 账套 1/2 各自的资源编码前缀(kpi_code / activity_code 库内唯一)
|
||||
E1_KPI = "ZZ_B2_E1_KPI_001"
|
||||
E2_KPI = "ZZ_B2_E2_KPI_001"
|
||||
E1_ACT = "ZZ_B2_E1_ACT_001"
|
||||
E2_ACT = "ZZ_B2_E2_ACT_001"
|
||||
E2_ACT2 = "ZZ_B2_E2_ACT_002"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _set_rolling(db: Session) -> None:
|
||||
"""滚动预算模式(全局 SystemConfig,配置值为 JSON 文本,与 /budget/config 写入口径一致)"""
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
|
||||
val = json.dumps({"mode": "rolling", "rolling_months": 12}, ensure_ascii=False)
|
||||
if cfg:
|
||||
cfg.config_value = val
|
||||
else:
|
||||
db.add(SystemConfig(config_key="budget_mode", config_value=val,
|
||||
description="预算模式: fixed=固定预算, rolling=滚动预算"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_plans(db: Session, kpi_id: int, eid: int, months: int, base: float) -> None:
|
||||
for m in range(1, months + 1):
|
||||
db.add(BudgetPlan(
|
||||
entity_id=eid, kpi_id=kpi_id, period=f"2026-{m:02d}",
|
||||
budget_value=base + m, budget_year=2026, budget_month=m,
|
||||
version="v1.0", status="active",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _plan_snapshot(db: Session, eid: int) -> list:
|
||||
"""某账套全部预算行(按 period 排序,逐字段快照)"""
|
||||
rows = (db.query(BudgetPlan)
|
||||
.filter(BudgetPlan.entity_id == eid)
|
||||
.order_by(BudgetPlan.period)
|
||||
.all())
|
||||
return [
|
||||
(r.id, r.kpi_id, r.period, round(r.budget_value, 4), r.budget_year,
|
||||
r.budget_month, r.status, r.version)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _mk_alert(db: Session, kpi_id: int, eid: int, **kw) -> KPIAlert:
|
||||
alert = KPIAlert(
|
||||
entity_id=eid,
|
||||
kpi_id=kpi_id,
|
||||
alert_level=kw.get("alert_level", "yellow"),
|
||||
alert_message=kw.get("alert_message", f"e{eid} 测试预警"),
|
||||
status=kw.get("status", "pending"),
|
||||
created_at=kw.get("created_at", datetime.now()),
|
||||
)
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return alert
|
||||
|
||||
|
||||
def _mk_plan(db: Session, kpi_id: int, eid: int, title: str) -> ActionPlan:
|
||||
plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, title=title,
|
||||
assignee="张三", priority="medium", status="pending", progress=0)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def _mk_activity(db: Session, code: str, eid: int) -> AbcActivity:
|
||||
act = AbcActivity(
|
||||
entity_id=eid, activity_code=code, activity_name=f"{code}-作业",
|
||||
cost_driver="动因", driver_unit="次", total_cost=1000.0, driver_volume=10,
|
||||
driver_rate=100.0, status="active",
|
||||
)
|
||||
db.add(act)
|
||||
db.commit()
|
||||
db.refresh(act)
|
||||
return act
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
return k1, k2
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 1. 写型·列表批处理:budget/roll-forward 按会话账套
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_roll_forward_only_touches_session_entity(client: TestClient, db: Session):
|
||||
"""酣客会话滚动延展 → 只动 e1 预算,e2 预算前后逐字段不变(含行数/最早月/新月份)"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
_set_rolling(db)
|
||||
_seed_plans(db, k1.id, 1, months=12, base=1000.0) # e1: 12 个月
|
||||
_seed_plans(db, k2.id, 2, months=3, base=2000.0) # e2: 3 个月
|
||||
|
||||
before_e1 = _plan_snapshot(db, 1)
|
||||
before_e2 = _plan_snapshot(db, 2)
|
||||
assert len(before_e1) == 12 and len(before_e2) == 3
|
||||
|
||||
token1 = _login(client, 1)
|
||||
r = client.post(f"{BUDGET}/roll-forward", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
|
||||
after_e1 = _plan_snapshot(db, 1)
|
||||
after_e2 = _plan_snapshot(db, 2)
|
||||
|
||||
# 响应只含本账套 KPI
|
||||
assert len(data["rolled_kpis"]) == 1, f"应只延展 1 个 KPI,实际 {data['rolled_kpis']}"
|
||||
assert data["rolled_kpis"][0]["kpi_id"] == k1.id
|
||||
|
||||
# e1:删 1 行加 1 行(行数不变,最早月被删)
|
||||
assert len(after_e1) == 12
|
||||
e1_periods = [p for _, _, p, *_ in after_e1]
|
||||
assert "2026-01" not in e1_periods
|
||||
assert data["rolled_kpis"][0]["added_period"] in e1_periods
|
||||
|
||||
# e2:逐字段完全未变(这是缺陷核心:跨账套批量写)
|
||||
assert after_e2 == before_e2, (
|
||||
f"entity2 预算被跨账套修改\nbefore={before_e2}\nafter={after_e2}"
|
||||
)
|
||||
|
||||
|
||||
def test_roll_forward_reverse_direction(client: TestClient, db: Session):
|
||||
"""博海会话滚动延展 → 只动 e2 预算,e1 预算前后不变"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
_set_rolling(db)
|
||||
_seed_plans(db, k1.id, 1, months=12, base=1000.0)
|
||||
_seed_plans(db, k2.id, 2, months=3, base=2000.0)
|
||||
|
||||
before_e1 = _plan_snapshot(db, 1)
|
||||
|
||||
token2 = _login(client, 2)
|
||||
r = client.post(f"{BUDGET}/roll-forward", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
|
||||
assert len(data["rolled_kpis"]) == 1
|
||||
assert data["rolled_kpis"][0]["kpi_id"] == k2.id
|
||||
assert _plan_snapshot(db, 1) == before_e1, "entity1 预算被 e2 会话跨账套修改"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 2. 写型·列表批处理:alerts/check-timeout 按会话账套
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _alert_state(db: Session, eid: int):
|
||||
rows = db.query(KPIAlert).filter(KPIAlert.entity_id == eid).order_by(KPIAlert.id).all()
|
||||
return [(a.id, a.status, a.alert_level) for a in rows]
|
||||
|
||||
|
||||
def test_check_timeout_only_upgrades_session_entity(client: TestClient, db: Session):
|
||||
"""酣客会话超时检测 → 只升级 e1 的 pending 预警,e2 保持 pending/yellow"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
old = datetime.now() - timedelta(hours=25)
|
||||
_mk_alert(db, k1.id, 1, created_at=old)
|
||||
_mk_alert(db, k1.id, 1, created_at=old)
|
||||
_mk_alert(db, k2.id, 2, created_at=old)
|
||||
_mk_alert(db, k2.id, 2, created_at=old)
|
||||
|
||||
token1 = _login(client, 1)
|
||||
r = client.get(f"{ALERTS}/check-timeout", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["total_timeout"] == 2, f"应只命中本账套 2 条,实际 {body}"
|
||||
assert body["upgraded_count"] == 2
|
||||
|
||||
# e1 升级为 red/processing
|
||||
for _id, status, level in _alert_state(db, 1):
|
||||
assert (status, level) == ("processing", "red"), f"e1 预警 {_id} 未升级: {status}/{level}"
|
||||
# e2 完全未动(缺陷核心:一次调用把所有账套升级)
|
||||
for _id, status, level in _alert_state(db, 2):
|
||||
assert (status, level) == ("pending", "yellow"), f"e2 预警 {_id} 被跨账套升级: {status}/{level}"
|
||||
|
||||
|
||||
def test_check_timeout_reverse_direction(client: TestClient, db: Session):
|
||||
"""博海会话超时检测 → 只升级 e2,e1 保持 pending/yellow"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
old = datetime.now() - timedelta(hours=25)
|
||||
_mk_alert(db, k1.id, 1, created_at=old)
|
||||
_mk_alert(db, k2.id, 2, created_at=old)
|
||||
|
||||
token2 = _login(client, 2)
|
||||
r = client.get(f"{ALERTS}/check-timeout", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["upgraded_count"] == 1
|
||||
|
||||
for _id, status, level in _alert_state(db, 1):
|
||||
assert (status, level) == ("pending", "yellow"), f"e1 预警 {_id} 被 e2 会话跨账套升级"
|
||||
for _id, status, level in _alert_state(db, 2):
|
||||
assert (status, level) == ("processing", "red")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 3. 按 id 直取:alerts 三条(跨账套 → 404,同账套 → 正常)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_alert_process_entity_scoped(client: TestClient, db: Session):
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
mine = _mk_alert(db, k1.id, 1)
|
||||
other = _mk_alert(db, k2.id, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
# 跨账套 → 404(对齐 /resolve 既有语义:查无此记录)
|
||||
r = client.post(f"{ALERTS}/{other.id}/process", headers=auth_header(token1),
|
||||
json={"assignee": "李四"})
|
||||
assert r.status_code == 404, f"跨账套 process 应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
db.refresh(other)
|
||||
assert other.status == "pending", "跨账套 process 竟改动了对端账套记录"
|
||||
|
||||
# 同账套 → 正常
|
||||
r = client.post(f"{ALERTS}/{mine.id}/process", headers=auth_header(token1),
|
||||
json={"assignee": "李四"})
|
||||
assert r.status_code == 200, r.text
|
||||
db.refresh(mine)
|
||||
assert mine.status == "processing" and mine.assignee == "李四"
|
||||
|
||||
|
||||
def test_alert_escalate_entity_scoped(client: TestClient, db: Session):
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
mine = _mk_alert(db, k1.id, 1)
|
||||
other = _mk_alert(db, k2.id, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{ALERTS}/{other.id}/escalate", headers=auth_header(token1),
|
||||
json={"assignee": "李四"})
|
||||
assert r.status_code == 404, f"跨账套 escalate 应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
db.refresh(other)
|
||||
assert other.alert_level == "yellow", "跨账套 escalate 竟升级了对端账套预警"
|
||||
|
||||
r = client.post(f"{ALERTS}/{mine.id}/escalate", headers=auth_header(token1),
|
||||
json={"assignee": "李四"})
|
||||
assert r.status_code == 200, r.text
|
||||
db.refresh(mine)
|
||||
assert mine.alert_level == "red"
|
||||
|
||||
|
||||
def test_alert_create_action_plan_entity_scoped(client: TestClient, db: Session):
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
mine = _mk_alert(db, k1.id, 1)
|
||||
other = _mk_alert(db, k2.id, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{ALERTS}/{other.id}/create-action-plan", headers=auth_header(token1),
|
||||
json={"assignee": "李四"})
|
||||
assert r.status_code == 404, f"跨账套 create-action-plan 应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
assert db.query(ActionPlan).filter(ActionPlan.alert_id == other.id).count() == 0, \
|
||||
"跨账套 create-action-plan 竟为对端账套预警建了计划"
|
||||
db.refresh(other)
|
||||
assert other.action_plan_linked_id is None
|
||||
|
||||
r = client.post(f"{ALERTS}/{mine.id}/create-action-plan", headers=auth_header(token1),
|
||||
json={"assignee": "李四"})
|
||||
assert r.status_code == 200, r.text
|
||||
db.refresh(mine)
|
||||
assert mine.action_plan_linked_id is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 4. 按 id 直取:action-plans 改/删(跨账套 → 404,同账套 → 正常)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_update_plan_entity_scoped(client: TestClient, db: Session):
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
mine = _mk_plan(db, k1.id, 1, "e1 计划")
|
||||
other = _mk_plan(db, k2.id, 2, "e2 计划")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{PLANS}/{other.id}", headers=auth_header(token1),
|
||||
json={"title": "被跨账套改名", "status": "completed"})
|
||||
assert r.status_code == 404, f"跨账套 update 应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
db.refresh(other)
|
||||
assert other.title == "e2 计划" and other.status == "pending", "跨账套 update 竟改动了对端计划"
|
||||
|
||||
r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1),
|
||||
json={"title": "本账套改名", "status": "in_progress"})
|
||||
assert r.status_code == 200, r.text
|
||||
db.refresh(mine)
|
||||
assert mine.title == "本账套改名" and mine.status == "in_progress"
|
||||
|
||||
|
||||
def test_delete_plan_entity_scoped(client: TestClient, db: Session):
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
mine = _mk_plan(db, k1.id, 1, "e1 计划")
|
||||
other = _mk_plan(db, k2.id, 2, "e2 计划")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.delete(f"{PLANS}/{other.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 404, f"跨账套 delete 应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
assert db.query(ActionPlan).filter(ActionPlan.id == other.id).count() == 1, \
|
||||
"跨账套 delete 竟删除了对端账套计划"
|
||||
|
||||
r = client.delete(f"{PLANS}/{mine.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.query(ActionPlan).filter(ActionPlan.id == mine.id).count() == 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 5. 读型:cost/abc/activities 按会话账套
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _abc_codes(client: TestClient, token: str) -> set:
|
||||
r = client.get(f"{COST}/abc/activities", headers=auth_header(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return {row["activity_code"] for row in r.json()["data"]}
|
||||
|
||||
|
||||
def test_abc_activities_isolated_by_entity(client: TestClient, db: Session):
|
||||
"""酣客会话不应看到博海 ABC 作业中心(生产:10 行全属 entity2)"""
|
||||
_seed_two_entities(db)
|
||||
_mk_activity(db, E1_ACT, 1)
|
||||
_mk_activity(db, E2_ACT, 2)
|
||||
_mk_activity(db, E2_ACT2, 2)
|
||||
|
||||
token1 = _login(client, 1)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
codes1 = _abc_codes(client, token1)
|
||||
codes2 = _abc_codes(client, token2)
|
||||
|
||||
assert codes1 == {E1_ACT}, f"entity1 会话应只见本账套作业中心,实际 {sorted(codes1)}"
|
||||
assert not any(c.startswith("ZZ_B2_E2_") for c in codes1), "酣客会话混入博海 ABC 作业中心"
|
||||
assert codes2 == {E2_ACT, E2_ACT2}, f"entity2 会话应只见本账套,实际 {sorted(codes2)}"
|
||||
|
||||
|
||||
def test_abc_activities_without_token_401(client: TestClient, db: Session):
|
||||
"""鉴权底线不变:无 Bearer token → 401"""
|
||||
_seed_two_entities(db)
|
||||
_mk_activity(db, E1_ACT, 1)
|
||||
r = client.get(f"{COST}/abc/activities")
|
||||
assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"
|
||||
@@ -0,0 +1,465 @@
|
||||
"""CMA 跨实体隔离收口(第三批)账套隔离回归测试 — 2026-09-18
|
||||
|
||||
任务:project-20260918-entity-isolation-batch3(项目Bot 派单)
|
||||
缺陷(本单范围,均与 batch2 同类残留):
|
||||
|
||||
A. 写型未接账套
|
||||
1. POST /api/cma/cost/abc/activities 函数签名无 get_entity_id 依赖;AbcActivity(...) 不写归属
|
||||
→ 落模型默认 entity_id=1(非 e1 账套新建的作业中心落进 e1)
|
||||
2. POST /api/cma/cost/abc/allocate 无账套依赖;cost_engine.allocate_cost() 内 AbcAllocation(...)
|
||||
不写 entity_id → 分配记录全落 e1;activity_id 也不校验归属
|
||||
|
||||
B. 读型未接账套
|
||||
3. GET /api/cma/cost/abc/allocations 查询只按 period/product_code 过滤 → 跨账套读泄漏
|
||||
(与 batch2 已修的 GET /abc/activities 同族)
|
||||
4. GET /api/cma/action-plans/stats 统计 db.query(ActionPlan) 无账套过滤
|
||||
→ 非 e1 账套用户统计数含其它账套计划(同文件既有先例=join KPIDefinition)
|
||||
|
||||
C. 归属校验缺失(写型)
|
||||
5. PUT /api/cma/action-plans/{plan_id} plan_id 已按账套校验(batch2),但 kr_id 分支
|
||||
db.query(KR).filter(KR.id == data["kr_id"]) 无账套校验
|
||||
→ 可把本账套计划挂到对端账套的 KR(同文件 create_plan:158 有先例)
|
||||
|
||||
本用例覆盖(每条均双向 + 同账套正对照;改前必失败条目见回报 §三 RED 原文):
|
||||
- 写型:会话账套新建 ABC 作业中心 → 库内该行 entity_id = 会话账套;对端账套看不到;
|
||||
- 写型:本账套 activity_id 分配 → 200 且新建 abc_allocations 行 entity_id = 会话账套;
|
||||
对端 activity_id → 404 且不产生任何分配行;
|
||||
- 读型:GET /abc/allocations 只返回本账套行;
|
||||
- 归属:PUT /{plan_id} 传对端 kr_id → 404 且对端 KR/本账套计划均未被改动;同账套改挂 → 200;
|
||||
- 统计:GET /action-plans/stats 只统计本账套计划;
|
||||
- 鉴权底线:无 token → 401(改前改后都应通过)。
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
AbcActivity,
|
||||
AbcAllocation,
|
||||
ActionPlan,
|
||||
Entity,
|
||||
KR,
|
||||
KPIDefinition,
|
||||
Objective,
|
||||
)
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
COST = "/api/cma/cost"
|
||||
PLANS = "/api/cma/action-plans"
|
||||
|
||||
# 账套 1/2 各自的资源编码前缀(activity_code 库内唯一)
|
||||
E1_KPI = "ZZ_B3_E1_KPI_001"
|
||||
E2_KPI = "ZZ_B3_E2_KPI_001"
|
||||
E1_ACT = "ZZ_B3_E1_ACT_001"
|
||||
E2_ACT = "ZZ_B3_E2_ACT_001"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _mk_activity(db: Session, code: str, eid: int) -> AbcActivity:
|
||||
"""直插作业中心(显式 entity_id,用于构造「对端账套既有数据」)"""
|
||||
act = AbcActivity(
|
||||
entity_id=eid, activity_code=code, activity_name=f"{code}-作业",
|
||||
cost_driver="动因", driver_unit="次", total_cost=1000.0, driver_volume=10,
|
||||
driver_rate=100.0, status="active",
|
||||
)
|
||||
db.add(act)
|
||||
db.commit()
|
||||
db.refresh(act)
|
||||
return act
|
||||
|
||||
|
||||
def _mk_allocation(db: Session, activity: AbcActivity, eid: int, product_code: str) -> AbcAllocation:
|
||||
alloc = AbcAllocation(
|
||||
entity_id=eid, period="2026-06", activity_id=activity.id,
|
||||
product_code=product_code, product_name=f"{product_code}-产品",
|
||||
driver_consumed=3, allocated_cost=300.0,
|
||||
)
|
||||
db.add(alloc)
|
||||
db.commit()
|
||||
db.refresh(alloc)
|
||||
return alloc
|
||||
|
||||
|
||||
def _mk_plan(db: Session, kpi_id: int, eid: int, title: str, **kw) -> ActionPlan:
|
||||
plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, kr_id=kw.get("kr_id"), title=title,
|
||||
assignee=kw.get("assignee", "张三"), priority="medium",
|
||||
status=kw.get("status", "pending"), progress=0)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def _mk_objective_kr(db: Session, eid: int, title: str) -> KR:
|
||||
obj = Objective(entity_id=eid, title=f"{title}-目标", quarter="2026Q3", status="active")
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
kr = KR(entity_id=eid, objective_id=obj.id, title=title, operator=">=",
|
||||
weight=50, target_value=100)
|
||||
db.add(kr)
|
||||
db.commit()
|
||||
db.refresh(kr)
|
||||
return kr
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
return k1, k2
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# A1. 写型:POST /cost/abc/activities 按会话账套写入
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_create_abc_activity_writes_session_entity(client: TestClient, db: Session):
|
||||
"""博海(e2)会话新建作业中心 → 库内该行 entity_id=2;酣客(e1)会话看不到它"""
|
||||
_seed_two_entities(db)
|
||||
|
||||
token2 = _login(client, 2)
|
||||
r = client.post(f"{COST}/abc/activities", headers=auth_header(token2), json={
|
||||
"activity_code": E2_ACT,
|
||||
"activity_name": "e2 新建作业",
|
||||
"cost_driver": "动因",
|
||||
"driver_unit": "次",
|
||||
"total_cost": 2000.0,
|
||||
"driver_volume": 20,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
new_id = r.json()["id"]
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(AbcActivity).filter(AbcActivity.id == new_id).first()
|
||||
assert row is not None, "作业中心未落库"
|
||||
assert row.entity_id == 2, (
|
||||
f"会话账套=2 新建的作业中心落库归属 entity_id={row.entity_id}(缺陷:未写归属→模型默认 1)"
|
||||
)
|
||||
|
||||
# e1 会话看不到 e2 的作业中心;e2 会话能看到
|
||||
codes1 = _abc_codes(client, _login(client, 1))
|
||||
codes2 = _abc_codes(client, _login(client, 2))
|
||||
assert E2_ACT not in codes1, f"酣客(e1)会话看到了博海(e2)新建的作业中心: {sorted(codes1)}"
|
||||
assert E2_ACT in codes2, f"博海(e2)会话看不到自己新建的作业中心: {sorted(codes2)}"
|
||||
|
||||
|
||||
def test_create_abc_activity_e1_still_defaults_to_own_entity(client: TestClient, db: Session):
|
||||
"""酣客(e1)会话新建 → entity_id=1 且对端(e2)看不到(同账套正对照,防「一律写死某账套」)"""
|
||||
_seed_two_entities(db)
|
||||
|
||||
token1 = _login(client, 1)
|
||||
r = client.post(f"{COST}/abc/activities", headers=auth_header(token1), json={
|
||||
"activity_code": E1_ACT,
|
||||
"activity_name": "e1 新建作业",
|
||||
"cost_driver": "动因",
|
||||
"driver_unit": "次",
|
||||
"total_cost": 1000.0,
|
||||
"driver_volume": 10,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
new_id = r.json()["id"]
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(AbcActivity).filter(AbcActivity.id == new_id).first()
|
||||
assert row.entity_id == 1, f"会话账套=1 新建行归属 {row.entity_id}"
|
||||
|
||||
codes2 = _abc_codes(client, _login(client, 2))
|
||||
assert E1_ACT not in codes2, f"博海(e2)会话看到了酣客(e1)作业中心: {sorted(codes2)}"
|
||||
|
||||
|
||||
def _abc_codes(client: TestClient, token: str) -> set:
|
||||
r = client.get(f"{COST}/abc/activities", headers=auth_header(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return {row["activity_code"] for row in r.json()["data"]}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# A2. 写型:POST /cost/abc/allocate 按会话账套 + activity_id 归属校验
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_allocate_cross_entity_activity_rejected(client: TestClient, db: Session):
|
||||
"""酣客(e1)会话用博海(e2)的 activity_id 分配 → 404,且不产生任何分配行"""
|
||||
_seed_two_entities(db)
|
||||
other = _mk_activity(db, E2_ACT, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{COST}/abc/allocate", headers=auth_header(token1), json={
|
||||
"activity_id": other.id,
|
||||
"period": "2026-06",
|
||||
"product_code": "ZZ_B3_E1_PROD",
|
||||
"product_name": "跨账套产品",
|
||||
"driver_consumed": 5,
|
||||
})
|
||||
assert r.status_code == 404, (
|
||||
f"跨账套 allocate 应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
assert db.query(AbcAllocation).count() == 0, "跨账套 allocate 竟写入了分配记录"
|
||||
# 对端作业中心未被改动(分配率计算会回写 driver_rate)
|
||||
db.refresh(other)
|
||||
assert other.driver_rate == 100.0, "跨账套 allocate 竟改动了对端作业中心"
|
||||
|
||||
|
||||
def test_allocate_writes_session_entity(client: TestClient, db: Session):
|
||||
"""双向:e1/e2 各自用本账套 activity_id 分配 → 200 且新建行 entity_id = 会话账套"""
|
||||
_seed_two_entities(db)
|
||||
act1 = _mk_activity(db, E1_ACT, 1)
|
||||
act2 = _mk_activity(db, E2_ACT, 2)
|
||||
|
||||
# e2 会话分配本账套作业中心
|
||||
token2 = _login(client, 2)
|
||||
r = client.post(f"{COST}/abc/allocate", headers=auth_header(token2), json={
|
||||
"activity_id": act2.id,
|
||||
"period": "2026-06",
|
||||
"product_code": "ZZ_B3_E2_PROD",
|
||||
"product_name": "e2 产品",
|
||||
"driver_consumed": 4,
|
||||
})
|
||||
assert r.status_code == 200, f"同账套 allocate 应 200,实际 {r.status_code} {r.text[:200]}"
|
||||
assert r.json()["allocated_cost"] == 400.0 # 4 × 100.0
|
||||
|
||||
db.expire_all()
|
||||
row2 = (db.query(AbcAllocation)
|
||||
.filter(AbcAllocation.product_code == "ZZ_B3_E2_PROD").first())
|
||||
assert row2 is not None, "e2 会话分配未落库"
|
||||
assert row2.entity_id == 2, (
|
||||
f"e2 会话分配行归属 entity_id={row2.entity_id}(缺陷:AbcAllocation 未写归属→默认 1)"
|
||||
)
|
||||
|
||||
# e1 会话分配本账套作业中心(反向对照)
|
||||
token1 = _login(client, 1)
|
||||
r = client.post(f"{COST}/abc/allocate", headers=auth_header(token1), json={
|
||||
"activity_id": act1.id,
|
||||
"period": "2026-06",
|
||||
"product_code": "ZZ_B3_E1_PROD",
|
||||
"product_name": "e1 产品",
|
||||
"driver_consumed": 2,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.expire_all()
|
||||
row1 = (db.query(AbcAllocation)
|
||||
.filter(AbcAllocation.product_code == "ZZ_B3_E1_PROD").first())
|
||||
assert row1 is not None and row1.entity_id == 1, f"e1 分配行归属异常: {row1.entity_id if row1 else None}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# B3. 读型:GET /cost/abc/allocations 按会话账套
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _alloc_products(client: TestClient, token: str) -> set:
|
||||
r = client.get(f"{COST}/abc/allocations", headers=auth_header(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return {row["product_code"] for row in r.json()["data"]}
|
||||
|
||||
|
||||
def test_allocations_list_isolated_by_entity(client: TestClient, db: Session):
|
||||
"""酣客(e1)会话只返本账套分配行,不含博海(e2)行;e2 会话同理(双向)"""
|
||||
_seed_two_entities(db)
|
||||
act1 = _mk_activity(db, E1_ACT, 1)
|
||||
act2 = _mk_activity(db, E2_ACT, 2)
|
||||
_mk_allocation(db, act1, 1, "ZZ_B3_E1_ALLOC")
|
||||
_mk_allocation(db, act2, 2, "ZZ_B3_E2_ALLOC")
|
||||
|
||||
prods1 = _alloc_products(client, _login(client, 1))
|
||||
prods2 = _alloc_products(client, _login(client, 2))
|
||||
|
||||
assert prods1 == {"ZZ_B3_E1_ALLOC"}, f"e1 会话分配记录集应为本账套,实际 {sorted(prods1)}"
|
||||
assert prods2 == {"ZZ_B3_E2_ALLOC"}, f"e2 会话分配记录集应为本账套,实际 {sorted(prods2)}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# C5. PUT /action-plans/{plan_id} 的 kr_id 归属校验
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_update_plan_kr_id_cross_entity_404(client: TestClient, db: Session):
|
||||
"""酣客(e1)计划改挂博海(e2)KR → 404,且计划 kr_id 未变、对端 KR 未被改动"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
mine = _mk_plan(db, k1.id, 1, "e1 计划")
|
||||
kr2 = _mk_objective_kr(db, 2, "e2 KR")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1),
|
||||
json={"kr_id": kr2.id})
|
||||
assert r.status_code == 404, (
|
||||
f"跨账套 kr_id 应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
after = db.query(ActionPlan).filter(ActionPlan.id == mine.id).first()
|
||||
assert after.kr_id is None, f"跨账套 kr_id 竟被写入: kr_id={after.kr_id}"
|
||||
assert after.objective_id is None, f"跨账套改挂竟改动了 objective_id={after.objective_id}"
|
||||
|
||||
db.refresh(kr2)
|
||||
assert kr2.entity_id == 2 and kr2.title == "e2 KR", "对端 KR 被改动"
|
||||
|
||||
|
||||
def test_update_plan_kr_id_same_entity_200(client: TestClient, db: Session):
|
||||
"""同账套正对照:酣客(e1)计划改挂本账套 KR → 200 且 kr_id/objective_id 生效"""
|
||||
k1, _ = _seed_two_entities(db)
|
||||
mine = _mk_plan(db, k1.id, 1, "e1 计划")
|
||||
kr1 = _mk_objective_kr(db, 1, "e1 KR")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1),
|
||||
json={"kr_id": kr1.id})
|
||||
assert r.status_code == 200, f"同账套 kr_id 应 200,实际 {r.status_code} {r.text[:200]}"
|
||||
body = r.json()
|
||||
assert body["kr_id"] == kr1.id
|
||||
assert body["objective_id"] == kr1.objective_id
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# B4. GET /action-plans/stats 按会话账套
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _stats(client: TestClient, token: str) -> dict:
|
||||
r = client.get(f"{PLANS}/stats", headers=auth_header(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_plan_stats_isolated_by_entity(client: TestClient, db: Session):
|
||||
"""酣客(e1)会话统计只含本账套计划(e2 造 2 条做对照)"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
_mk_plan(db, k1.id, 1, "e1 待开始")
|
||||
_mk_plan(db, k1.id, 1, "e1 已完成", status="completed")
|
||||
_mk_plan(db, k2.id, 2, "e2 待开始")
|
||||
_mk_plan(db, k2.id, 2, "e2 进行中", status="in_progress")
|
||||
|
||||
s1 = _stats(client, _login(client, 1))
|
||||
assert s1["total"] == 2, f"e1 统计应只含本账套 2 条,实际 {s1}"
|
||||
assert s1["pending"] == 1 and s1["completed"] == 1 and s1["in_progress"] == 0
|
||||
|
||||
|
||||
def test_plan_stats_reverse_direction(client: TestClient, db: Session):
|
||||
"""博海(e2)会话统计只含本账套计划(防止「一律统计 e1」或全局口径)"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
_mk_plan(db, k1.id, 1, "e1 待开始")
|
||||
_mk_plan(db, k1.id, 1, "e1 待开始2")
|
||||
_mk_plan(db, k2.id, 2, "e2 待开始")
|
||||
|
||||
s2 = _stats(client, _login(client, 2))
|
||||
assert s2["total"] == 1, f"e2 统计应只含本账套 1 条,实际 {s2}"
|
||||
assert s2["pending"] == 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# D6. GET /action-plans 的 KR 富化按会话账套(batch3 读侧同族残余)
|
||||
# ──────────────────────────────────────────────
|
||||
# 缺陷:list_plans 富化 kr_title 的查询 db.query(KR).filter(KR.id == p.kr_id) 无账套校验
|
||||
# → 存量脏行(action_plans.kr_id 指向对端账套 KR)会让列表跨账套回显对端 KR 标题。
|
||||
# 写入侧已在 batch3 修(update_plan/create_plan 校验 kr_id 归属),本用例覆盖读侧。
|
||||
|
||||
def _list_plans(client: TestClient, token: str) -> dict:
|
||||
r = client.get(PLANS, headers=auth_header(token))
|
||||
assert r.status_code == 200, r.text
|
||||
return {row["id"]: row for row in r.json()["data"]}
|
||||
|
||||
|
||||
def test_list_plans_kr_enrichment_isolated_by_entity(client: TestClient, db: Session):
|
||||
"""双向:本账套计划挂对端 KR(存量脏行)→ 列表不回显对端 KR 标题;挂本账套 KR → 正常回显"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
kr1 = _mk_objective_kr(db, 1, "e1 KR")
|
||||
kr2 = _mk_objective_kr(db, 2, "e2 KR")
|
||||
|
||||
# e1 账套:一条挂本账套 KR(正对照)+ 一条脏行挂 e2 的 KR
|
||||
good1 = _mk_plan(db, k1.id, 1, "zz e1 挂本账套 KR", kr_id=kr1.id)
|
||||
dirty1 = _mk_plan(db, k1.id, 1, "zz e1 脏行挂对端 KR", kr_id=kr2.id)
|
||||
# e2 账套:反向同理
|
||||
good2 = _mk_plan(db, k2.id, 2, "zz e2 挂本账套 KR", kr_id=kr2.id)
|
||||
dirty2 = _mk_plan(db, k2.id, 2, "zz e2 脏行挂对端 KR", kr_id=kr1.id)
|
||||
|
||||
rows1 = _list_plans(client, _login(client, 1))
|
||||
assert good1.id in rows1 and dirty1.id in rows1, f"e1 会话看不到本账套计划: {sorted(rows1)}"
|
||||
assert rows1[good1.id]["kr_title"] == "e1 KR", (
|
||||
f"e1 本账套 KR 标题未回显: {rows1[good1.id].get('kr_title')!r}"
|
||||
)
|
||||
assert rows1[dirty1.id]["kr_title"] is None, (
|
||||
f"e1 会话跨账套回显了 e2 的 KR 标题 {rows1[dirty1.id]['kr_title']!r}"
|
||||
"(缺陷:list_plans 富化 kr 查询缺 KR.entity_id 校验)"
|
||||
)
|
||||
assert rows1[dirty1.id]["kr_metric_kpi_id"] is None, (
|
||||
f"e1 会话跨账套回显了 e2 KR 的 metric_kpi_id = {rows1[dirty1.id]['kr_metric_kpi_id']!r}"
|
||||
)
|
||||
|
||||
rows2 = _list_plans(client, _login(client, 2))
|
||||
assert good2.id in rows2 and dirty2.id in rows2, f"e2 会话看不到本账套计划: {sorted(rows2)}"
|
||||
assert rows2[good2.id]["kr_title"] == "e2 KR", (
|
||||
f"e2 本账套 KR 标题未回显: {rows2[good2.id].get('kr_title')!r}"
|
||||
)
|
||||
assert rows2[dirty2.id]["kr_title"] is None, (
|
||||
f"e2 会话跨账套回显了 e1 的 KR 标题 {rows2[dirty2.id]['kr_title']!r}"
|
||||
)
|
||||
assert rows2[dirty2.id]["kr_metric_kpi_id"] is None
|
||||
|
||||
# 计划本体归属未被本改动影响:e1 列表不含 e2 的两条计划
|
||||
assert good2.id not in rows1 and dirty2.id not in rows1, "e1 列表混入了 e2 账套计划"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 鉴权底线(改前改后都应通过)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_batch3_endpoints_without_token_401(client: TestClient, db: Session):
|
||||
"""无 Bearer token → 401(四个端点鉴权底线不变)"""
|
||||
_seed_two_entities(db)
|
||||
assert client.post(f"{COST}/abc/activities", json={
|
||||
"activity_code": "ZZ_B3_NOTOKEN", "activity_name": "x",
|
||||
"cost_driver": "d", "total_cost": 1, "driver_volume": 1,
|
||||
}).status_code == 401
|
||||
assert client.post(f"{COST}/abc/allocate", json={
|
||||
"activity_id": 1, "product_code": "p", "driver_consumed": 1,
|
||||
}).status_code == 401
|
||||
assert client.get(f"{COST}/abc/allocations").status_code == 401
|
||||
assert client.get(f"{PLANS}/stats").status_code == 401
|
||||
@@ -0,0 +1,376 @@
|
||||
"""CMA 跨实体隔离 — action_plans.objective_id 归属校验回归测试(2026-09-18 batch3 二次派生)
|
||||
|
||||
任务:fullstack-20260918-1905-actionplan-objective-id-scope
|
||||
来源:fullstack-20260918-1845-batch3-followup-listplans-kr(读侧已修,写侧 objective_id 未修)
|
||||
|
||||
缺陷(本单范围,与 batch3 已修的 kr_id 缺口同源:写的归属不校验 → 读侧跨账套回显):
|
||||
1. POST /api/cma/action-plans(create_plan)
|
||||
`db.query(Objective).filter(Objective.id == objective_id).first()` 只用于季度校验,
|
||||
不校验 `Objective.entity_id`,随后 `ActionPlan(objective_id=...)` 直接落库
|
||||
→ 本账套计划可挂到对端账套 Objective。
|
||||
2. PUT /api/cma/action-plans/{plan_id}(update_plan)
|
||||
`if "objective_id" in data: plan.objective_id = data["objective_id"]` 既无归属校验,
|
||||
又覆盖同函数内 kr_id 分支推导出的 objective_id(先按合法 KR 推导出正确目标,再被入参改掉)。
|
||||
3. 可观测后果:okr.py:get_okr_decomposition 的 weekly_actions 仅按
|
||||
ActionPlan.objective_id == okr_id + due_date 过滤(无账套条件)
|
||||
→ 对端账套计划可出现在本账套 OKR 分解视图(下方 T8 覆盖该读侧后果)。
|
||||
|
||||
本用例覆盖(每条均双向 + 同账套正对照;改前失败条目 = 5 条,RED 原文见
|
||||
_reports/evidence-fullstack-20260918-1905-red.txt):
|
||||
- T1 create_plan 跨账套 objective_id → 404 且不落库半成品行(双向)【改前失败】
|
||||
- T2 create_plan 同账套 objective_id → 200 且落库正确(双向,正对照)
|
||||
- T3a objective_id 归属改造后季度校验仍在(越季 → 422,防重构掉既有校验)
|
||||
- T3b create_plan 不存在的 objective_id → 404【改前失败】
|
||||
- T4 update_plan 跨账套 objective_id → 404 且计划未被改动(双向)【改前失败】
|
||||
- T5 update_plan 同账套 objective_id → 200(正对照)
|
||||
- T6 update_plan 的「入参 objective_id vs kr_id 推导值」显式优先级(推导 / 入参覆盖 / 入参 null 置空)
|
||||
—— 三条在改前均通过(旧代码同样实现「入参覆盖」),此处作为语义锁用例防后续重构反转口径
|
||||
- T7 update_plan 同请求内 kr_id 合法 + objective_id 跨账套 → 整体 404 且零改动【改前失败】
|
||||
- T8 读侧后果:对端账套经 API 无法把计划注入本账套 OKR 分解视图【改前失败】(本账套合法计划仍可见=正对照)
|
||||
- T9 鉴权底线:无 token → 401(改前改后都应通过)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ActionPlan, Entity, KR, KPIDefinition, Objective
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
PLANS = "/api/cma/action-plans"
|
||||
OKR = "/api/cma/okr"
|
||||
|
||||
E1_KPI = "ZZ_B3O_E1_KPI_001"
|
||||
E2_KPI = "ZZ_B3O_E2_KPI_001"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _mk_objective(db: Session, eid: int, title: str, quarter: str = "2026Q3") -> Objective:
|
||||
obj = Objective(entity_id=eid, title=title, quarter=quarter, status="active")
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _mk_kr(db: Session, eid: int, objective_id: int, title: str) -> KR:
|
||||
kr = KR(entity_id=eid, objective_id=objective_id, title=title, operator=">=",
|
||||
weight=50, target_value=100)
|
||||
db.add(kr)
|
||||
db.commit()
|
||||
db.refresh(kr)
|
||||
return kr
|
||||
|
||||
|
||||
def _mk_plan(db: Session, kpi_id: int, eid: int, title: str, **kw) -> ActionPlan:
|
||||
plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, objective_id=kw.get("objective_id"),
|
||||
kr_id=kw.get("kr_id"), title=title, assignee="张三",
|
||||
priority="medium", status=kw.get("status", "pending"), progress=0)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
return k1, k2
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _plans_by_title(db: Session, title: str):
|
||||
db.expire_all()
|
||||
return db.query(ActionPlan).filter(ActionPlan.title == title).all()
|
||||
|
||||
|
||||
def _cur_quarter() -> str:
|
||||
import datetime as _dt
|
||||
now = _dt.datetime.now()
|
||||
return f"{now.year}Q{(now.month - 1) // 3 + 1}"
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
import datetime as _dt
|
||||
return _dt.date.today().isoformat()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T1. create_plan 跨账套 objective_id → 404 且不落库
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_create_plan_objective_id_cross_entity_404(client: TestClient, db: Session):
|
||||
"""双向:e1 会话挂 e2 目标 / e2 会话挂 e1 目标 → 均 404 且库内无半成品行"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标")
|
||||
obj2 = _mk_objective(db, 2, "e2 目标")
|
||||
|
||||
t_fwd = "ZZ_B3O 跨账套目标 e1挂e2"
|
||||
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
|
||||
json={"title": t_fwd, "kpi_id": k1.id, "objective_id": obj2.id})
|
||||
assert r1.status_code == 404, (
|
||||
f"e1 会话挂 e2 目标应 404,实际 {r1.status_code} {r1.text[:200]}"
|
||||
)
|
||||
assert _plans_by_title(db, t_fwd) == [], "跨账套 objective_id 竟落库(半成品行)"
|
||||
|
||||
t_rev = "ZZ_B3O 跨账套目标 e2挂e1"
|
||||
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
|
||||
json={"title": t_rev, "kpi_id": k2.id, "objective_id": obj1.id})
|
||||
assert r2.status_code == 404, (
|
||||
f"e2 会话挂 e1 目标应 404,实际 {r2.status_code} {r2.text[:200]}"
|
||||
)
|
||||
assert _plans_by_title(db, t_rev) == [], "跨账套 objective_id 竟落库(半成品行)"
|
||||
|
||||
|
||||
def test_create_plan_objective_id_same_entity_200(client: TestClient, db: Session):
|
||||
"""同账套正对照(双向):本账套目标 → 200 且落库 objective_id 正确"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标")
|
||||
obj2 = _mk_objective(db, 2, "e2 目标")
|
||||
|
||||
t1 = "ZZ_B3O 同账套目标 e1"
|
||||
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
|
||||
json={"title": t1, "kpi_id": k1.id, "objective_id": obj1.id})
|
||||
assert r1.status_code == 200, f"同账套 objective_id 应 200,实际 {r1.status_code} {r1.text[:200]}"
|
||||
assert r1.json()["objective_id"] == obj1.id
|
||||
|
||||
t2 = "ZZ_B3O 同账套目标 e2"
|
||||
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
|
||||
json={"title": t2, "kpi_id": k2.id, "objective_id": obj2.id})
|
||||
assert r2.status_code == 200, f"同账套 objective_id 应 200,实际 {r2.status_code} {r2.text[:200]}"
|
||||
assert r2.json()["objective_id"] == obj2.id
|
||||
|
||||
|
||||
def test_create_plan_objective_quarter_validation_preserved(client: TestClient, db: Session):
|
||||
"""归属校验改造不得吃掉既有季度校验:同账套目标 + 越季截止 → 422"""
|
||||
import datetime as _dt
|
||||
k1, _ = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标", quarter=_cur_quarter())
|
||||
far_due = (_dt.date.today() + _dt.timedelta(days=200)).isoformat()
|
||||
|
||||
r = client.post(PLANS, headers=auth_header(_login(client, 1)),
|
||||
json={"title": "ZZ_B3O 越季截止", "kpi_id": k1.id,
|
||||
"objective_id": obj1.id, "due_date": far_due})
|
||||
assert r.status_code == 422, (
|
||||
f"越季截止应 422(既有季度校验被重构吃掉?),实际 {r.status_code} {r.text[:200]}"
|
||||
)
|
||||
assert _plans_by_title(db, "ZZ_B3O 越季截止") == []
|
||||
|
||||
|
||||
def test_create_plan_objective_id_unknown_404(client: TestClient, db: Session):
|
||||
"""不存在的 objective_id → 404(不得静默落库)"""
|
||||
k1, _ = _seed_two_entities(db)
|
||||
r = client.post(PLANS, headers=auth_header(_login(client, 1)),
|
||||
json={"title": "ZZ_B3O 幽灵目标", "kpi_id": k1.id,
|
||||
"objective_id": 999999})
|
||||
assert r.status_code == 404, f"不存在的目标应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
assert _plans_by_title(db, "ZZ_B3O 幽灵目标") == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T4/T5. update_plan 的 objective_id 归属校验
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_update_plan_objective_id_cross_entity_404(client: TestClient, db: Session):
|
||||
"""双向:e1 计划改挂 e2 目标 / e2 计划改挂 e1 目标 → 404 且计划零改动"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标")
|
||||
obj2 = _mk_objective(db, 2, "e2 目标")
|
||||
|
||||
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O e1 计划")
|
||||
r1 = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
|
||||
json={"objective_id": obj2.id})
|
||||
assert r1.status_code == 404, (
|
||||
f"e1 计划挂 e2 目标应 404,实际 {r1.status_code} {r1.text[:200]}"
|
||||
)
|
||||
db.expire_all()
|
||||
assert db.query(ActionPlan).filter(ActionPlan.id == p1.id).first().objective_id is None, \
|
||||
"跨账套 objective_id 竟被写入"
|
||||
|
||||
p2 = _mk_plan(db, k2.id, 2, "ZZ_B3O e2 计划")
|
||||
r2 = client.put(f"{PLANS}/{p2.id}", headers=auth_header(_login(client, 2)),
|
||||
json={"objective_id": obj1.id})
|
||||
assert r2.status_code == 404, (
|
||||
f"e2 计划挂 e1 目标应 404,实际 {r2.status_code} {r2.text[:200]}"
|
||||
)
|
||||
db.expire_all()
|
||||
assert db.query(ActionPlan).filter(ActionPlan.id == p2.id).first().objective_id is None, \
|
||||
"跨账套 objective_id 竟被写入"
|
||||
|
||||
|
||||
def test_update_plan_objective_id_same_entity_200(client: TestClient, db: Session):
|
||||
"""同账套正对照:e1 计划改挂本账套目标 → 200 且落库"""
|
||||
k1, _ = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标")
|
||||
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O e1 计划")
|
||||
|
||||
r = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
|
||||
json={"objective_id": obj1.id})
|
||||
assert r.status_code == 200, f"同账套 objective_id 应 200,实际 {r.status_code} {r.text[:200]}"
|
||||
assert r.json()["objective_id"] == obj1.id
|
||||
db.expire_all()
|
||||
assert db.query(ActionPlan).filter(ActionPlan.id == p1.id).first().objective_id == obj1.id
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T6. 入参 objective_id 与 kr_id 推导值的显式优先级
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_update_plan_objective_id_priority_over_kr_derived(client: TestClient, db: Session):
|
||||
"""三条口径:① 未传入参 → 按 KR 推导;② 同传则入参优先(不被推导覆盖);③ 入参 null → 置空"""
|
||||
k1, _ = _seed_two_entities(db)
|
||||
obj_a = _mk_objective(db, 1, "e1 目标A")
|
||||
obj_b = _mk_objective(db, 1, "e1 目标B")
|
||||
kr_a = _mk_kr(db, 1, obj_a.id, "e1 KR-A")
|
||||
kr_b = _mk_kr(db, 1, obj_b.id, "e1 KR-B")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
# ① 未传 objective_id + 改挂 KR + 计划当前无目标 → 推导自 KR
|
||||
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-推导")
|
||||
r1 = client.put(f"{PLANS}/{p1.id}", headers=auth_header(token1), json={"kr_id": kr_a.id})
|
||||
assert r1.status_code == 200, r1.text[:200]
|
||||
assert r1.json()["objective_id"] == obj_a.id, (
|
||||
f"未传入参时应按 KR 推导 objective_id={obj_a.id},实际 {r1.json()['objective_id']}"
|
||||
)
|
||||
|
||||
# ② 同传入参 objective_id(属本账套另一目标)+ 改挂 KR-B → 入参优先,不被 KR-B 推导覆盖
|
||||
p2 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-入参覆盖")
|
||||
r2 = client.put(f"{PLANS}/{p2.id}", headers=auth_header(token1),
|
||||
json={"kr_id": kr_b.id, "objective_id": obj_a.id})
|
||||
assert r2.status_code == 200, r2.text[:200]
|
||||
assert r2.json()["kr_id"] == kr_b.id
|
||||
assert r2.json()["objective_id"] == obj_a.id, (
|
||||
f"入参 objective_id 应优先于 kr_id 推导值(期望 {obj_a.id},实际 {r2.json()['objective_id']})"
|
||||
)
|
||||
|
||||
# ③ 显式传 objective_id=null → 置空(不回落推导值)
|
||||
p3 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-null置空", objective_id=obj_a.id)
|
||||
r3 = client.put(f"{PLANS}/{p3.id}", headers=auth_header(token1),
|
||||
json={"kr_id": kr_a.id, "objective_id": None})
|
||||
assert r3.status_code == 200, r3.text[:200]
|
||||
assert r3.json()["objective_id"] is None, (
|
||||
f"显式 null 应置空 objective_id,实际 {r3.json()['objective_id']}"
|
||||
)
|
||||
|
||||
|
||||
def test_update_plan_kr_ok_objective_cross_entity_404(client: TestClient, db: Session):
|
||||
"""同请求内 KR 合法 + objective_id 跨账套 → 404 且 KR/目标零改动"""
|
||||
k1, _ = _seed_two_entities(db)
|
||||
obj_a = _mk_objective(db, 1, "e1 目标A")
|
||||
obj2 = _mk_objective(db, 2, "e2 目标")
|
||||
kr_a = _mk_kr(db, 1, obj_a.id, "e1 KR-A")
|
||||
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 混合载荷")
|
||||
|
||||
r = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
|
||||
json={"kr_id": kr_a.id, "objective_id": obj2.id})
|
||||
assert r.status_code == 404, (
|
||||
f"跨账套 objective_id 应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
)
|
||||
db.expire_all()
|
||||
after = db.query(ActionPlan).filter(ActionPlan.id == p1.id).first()
|
||||
assert after.kr_id is None, f"404 时 kr_id 竟被写入: {after.kr_id}"
|
||||
assert after.objective_id is None, f"404 时 objective_id 竟被写入: {after.objective_id}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T8. 读侧后果:OKR 分解视图不得出现对端账套计划
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _weekly_titles(client: TestClient, token: str, okr_id: int):
|
||||
r = client.get(f"{OKR}/{okr_id}/decomposition", headers=auth_header(token))
|
||||
assert r.status_code == 200, f"分解视图应 200,实际 {r.status_code} {r.text[:200]}"
|
||||
return {row["title"] for row in r.json()["weekly_actions"]}
|
||||
|
||||
|
||||
def test_okr_decomposition_excludes_cross_entity_plan(client: TestClient, db: Session):
|
||||
"""e2 会话无法把计划挂进 e1 目标(404)→ e1 分解视图不含该计划;本账套合法计划仍可见"""
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
obj1 = _mk_objective(db, 1, "e1 目标", quarter=_cur_quarter())
|
||||
due = _today_iso()
|
||||
|
||||
# 对端(e2)尝试挂 e1 目标 + 本周截止:写入侧拒绝 → 视图不应出现
|
||||
cross_title = "ZZ_B3O 对端注入计划"
|
||||
rc = client.post(PLANS, headers=auth_header(_login(client, 2)),
|
||||
json={"title": cross_title, "kpi_id": k2.id,
|
||||
"objective_id": obj1.id, "due_date": due})
|
||||
assert rc.status_code == 404, (
|
||||
f"对端账套挂本账套目标应 404,实际 {rc.status_code} {rc.text[:200]}"
|
||||
)
|
||||
|
||||
# 本账套合法计划(同目标 + 本周截止)= 正对照
|
||||
own_title = "ZZ_B3O 本账套合法计划"
|
||||
ro = client.post(PLANS, headers=auth_header(_login(client, 1)),
|
||||
json={"title": own_title, "kpi_id": k1.id,
|
||||
"objective_id": obj1.id, "due_date": due})
|
||||
assert ro.status_code == 200, ro.text[:200]
|
||||
|
||||
titles = _weekly_titles(client, _login(client, 1), obj1.id)
|
||||
assert cross_title not in titles, f"对端账套计划出现在本账套分解视图: {sorted(titles)}"
|
||||
assert own_title in titles, f"本账套合法计划未出现在分解视图(正对照失效): {sorted(titles)}"
|
||||
|
||||
# 对端账套查本账套目标 → 404(账套边界本身)
|
||||
rq = client.get(f"{OKR}/{obj1.id}/decomposition", headers=auth_header(_login(client, 2)))
|
||||
assert rq.status_code == 404, f"对端账套查本账套 OKR 应 404,实际 {rq.status_code}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T9. 鉴权底线
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_objective_id_endpoints_without_token_401(client: TestClient, db: Session):
|
||||
"""无 token → 401(POST 创建 / PUT 更新),改前改后都应通过"""
|
||||
k1, _ = _seed_two_entities(db)
|
||||
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 无token")
|
||||
|
||||
r1 = client.post(PLANS, json={"title": "ZZ_B3O 无token创建", "kpi_id": k1.id,
|
||||
"objective_id": 1})
|
||||
assert r1.status_code == 401, f"创建无 token 应 401,实际 {r1.status_code}"
|
||||
r2 = client.put(f"{PLANS}/{p1.id}", json={"objective_id": 1})
|
||||
assert r2.status_code == 401, f"更新无 token 应 401,实际 {r2.status_code}"
|
||||
@@ -0,0 +1,615 @@
|
||||
"""CMA 跨实体隔离收口(第四批)账套隔离回归测试 — 2026-09-21
|
||||
|
||||
任务:project-20260921-cma-entity-isolation-batch4(项目Bot 派单;来源 OpenCode 周审 20260921)
|
||||
缺陷(本单范围):
|
||||
P0-A cash.py 4 端点无账套校验(跨租户改/删/完成/回款)
|
||||
PUT /api/cma/cash/plans/{plan_id}
|
||||
DELETE /api/cma/cash/plans/{plan_id}
|
||||
POST /api/cma/cash/plans/{plan_id}/complete
|
||||
POST /api/cma/cash/receivables/{plan_id}/payment
|
||||
P0-B budget.py create_budget_plan 建行未写 entity_id(落默认 1,错归属)
|
||||
P1 notifications.py update_channel 无字段白名单(mass-assignment)
|
||||
P1 跨实体只读泄漏点(bot_kpis / data_quality / kpis / dashboard /
|
||||
customer_dashboard / kpi_governance / budget_generate / budget 读 /
|
||||
driver_budget / expenses)
|
||||
P2 okr_templates.py apply_okr_template 建 StrategicMap 未写 entity_id
|
||||
|
||||
每条均「跨账套负例 + 本账套正对照」;改前 RED 原文见回报 §三。
|
||||
本文件只跑内存 SQLite(tests/conftest.py 机制),零生产库 / 零生产 Redis 连接。
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
BudgetPlan,
|
||||
CashPlan,
|
||||
Entity,
|
||||
ExpenseReimbursement,
|
||||
KPIDefinition,
|
||||
KpiDataQualityLog,
|
||||
NotificationChannel,
|
||||
OKRTemplate,
|
||||
StrategicMap,
|
||||
DriverFactorBudget,
|
||||
)
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
CASH = "/api/cma/cash"
|
||||
BUDGET = "/api/cma/budget"
|
||||
NOTIF = "/api/cma/notifications"
|
||||
KPIS = "/api/cma/kpis"
|
||||
DQ = "/api/cma/data-quality"
|
||||
DASH = "/api/cma/dashboard"
|
||||
CUST = "/api/cma/customer-dashboard"
|
||||
GOV = "/api/cma/kpi"
|
||||
OKRT = "/api/cma/okr-templates"
|
||||
EXPENSES = "/api/cma/expenses"
|
||||
|
||||
BOT_SRC = "batch4-bot"
|
||||
|
||||
# 两账套资源编码前缀(kpi_code 库内唯一)
|
||||
E1_KPI = "ZZ_B4_E1_KPI_001"
|
||||
E2_KPI = "ZZ_B4_E2_KPI_001"
|
||||
E1_CUST = "ZZ_B4_E1_CUST_001"
|
||||
E2_CUST = "ZZ_B4_E2_CUST_001"
|
||||
E1_BOT = "ZZ_B4_E1_BOT_001"
|
||||
E2_BOT = "ZZ_B4_E2_BOT_001"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int, dimension: str = "finance", **kw) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension=dimension,
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
**kw,
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _mk_plan(db: Session, eid: int, amount: float = 10.0, plan_type: str = "receive",
|
||||
status: str = "pending", paid_amount: float = 0.0, **kw) -> CashPlan:
|
||||
from datetime import datetime
|
||||
plan = CashPlan(
|
||||
entity_id=eid, plan_type=plan_type, amount=amount,
|
||||
plan_date=datetime(2026, 9, 30), counterparty=f"e{eid}客户",
|
||||
description=f"e{eid}计划", status=status, source="manual",
|
||||
paid_amount=paid_amount, **kw,
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def _mk_map(db: Session, eid: int, title: str) -> StrategicMap:
|
||||
m = StrategicMap(entity_id=eid, title=title, status="draft",
|
||||
dimensions=[], canvas_data={"connections": []})
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
def _mk_channel(db: Session, name: str) -> NotificationChannel:
|
||||
ch = NotificationChannel(name=name, channel_type="wecom", config={"webhook": "x"}, enabled=True)
|
||||
db.add(ch)
|
||||
db.commit()
|
||||
db.refresh(ch)
|
||||
return ch
|
||||
|
||||
|
||||
def _mk_budget(db: Session, kpi_id: int, eid: int, period: str = "2026-09", value: float = 100.0):
|
||||
bp = BudgetPlan(entity_id=eid, kpi_id=kpi_id, period=period, budget_value=value,
|
||||
budget_year=2026, budget_month=9, version="v1.0", status="active")
|
||||
db.add(bp)
|
||||
db.commit()
|
||||
db.refresh(bp)
|
||||
return bp
|
||||
|
||||
|
||||
def _mk_template(db: Session, name: str) -> OKRTemplate:
|
||||
t = OKRTemplate(name=name, description="批4测试模板", dimension="finance",
|
||||
layer="level1", preset_krs=[{"name": "KR测试", "target_value": 100, "weight": 50}],
|
||||
source="system", is_active=1)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
def _mk_reimb(db: Session, eid: int, no: str, amount: float = 100.0):
|
||||
from datetime import datetime
|
||||
r = ExpenseReimbursement(entity_id=eid, reimb_no=no, applicant="张三", department="财务部",
|
||||
expense_type="office", title=f"{no}-报销", amount=amount,
|
||||
expense_date=datetime(2026, 9, 10), status="pending",
|
||||
check_result="pass")
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "测试企业1")
|
||||
_ensure_entity(db, 2, "测试企业2")
|
||||
create_test_user(db)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P0-A. cash.py 4 端点按账套(跨账套 404)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_cash_update_plan_cross_entity_404(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
other = _mk_plan(db, 2, amount=50.0)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{CASH}/plans/{other.id}", headers=auth_header(token1), json={"amount": 999.0})
|
||||
assert r.status_code == 404, f"跨账套改计划应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(CashPlan).filter(CashPlan.id == other.id).first()
|
||||
assert row is not None, "对端计划被删除"
|
||||
assert row.amount == 50.0, f"跨账套改计划生效了:amount={row.amount}"
|
||||
|
||||
|
||||
def test_cash_update_plan_own_entity_ok(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
mine = _mk_plan(db, 1, amount=50.0)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{CASH}/plans/{mine.id}", headers=auth_header(token1), json={"amount": 88.0})
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
db.expire_all()
|
||||
assert db.query(CashPlan).filter(CashPlan.id == mine.id).first().amount == 88.0
|
||||
|
||||
|
||||
def test_cash_delete_plan_cross_entity_404(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
other = _mk_plan(db, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.delete(f"{CASH}/plans/{other.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 404, f"跨账套删计划应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
assert db.query(CashPlan).filter(CashPlan.id == other.id).first() is not None, \
|
||||
"跨账套删除竟生效(对端计划已消失)"
|
||||
|
||||
|
||||
def test_cash_delete_plan_own_entity_ok(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
mine = _mk_plan(db, 1)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.delete(f"{CASH}/plans/{mine.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
db.expire_all()
|
||||
assert db.query(CashPlan).filter(CashPlan.id == mine.id).first() is None
|
||||
|
||||
|
||||
def test_cash_complete_plan_cross_entity_404(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
other = _mk_plan(db, 2, amount=50.0)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{CASH}/plans/{other.id}/complete", headers=auth_header(token1), json={})
|
||||
assert r.status_code == 404, f"跨账套完成计划应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(CashPlan).filter(CashPlan.id == other.id).first()
|
||||
assert row.status == "pending", f"跨账套完成生效了:status={row.status}"
|
||||
assert (row.paid_amount or 0) == 0, f"跨账套完成写入了 paid_amount={row.paid_amount}"
|
||||
|
||||
|
||||
def test_cash_complete_plan_own_entity_ok(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
mine = _mk_plan(db, 1, amount=50.0)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{CASH}/plans/{mine.id}/complete", headers=auth_header(token1), json={})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
db.expire_all()
|
||||
row = db.query(CashPlan).filter(CashPlan.id == mine.id).first()
|
||||
assert row.status == "completed" and row.paid_amount == 50.0
|
||||
|
||||
|
||||
def test_cash_register_payment_cross_entity_404(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
other = _mk_plan(db, 2, amount=50.0, plan_type="receive")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{CASH}/receivables/{other.id}/payment", headers=auth_header(token1),
|
||||
json={"amount": 5.0, "paid_date": "2026-09-20"})
|
||||
assert r.status_code == 404, f"跨账套回款登记应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(CashPlan).filter(CashPlan.id == other.id).first()
|
||||
assert (row.paid_amount or 0) == 0, f"跨账套回款登记生效了:paid_amount={row.paid_amount}"
|
||||
assert row.status == "pending", f"跨账套回款登记改了 status={row.status}"
|
||||
|
||||
|
||||
def test_cash_register_payment_own_entity_ok(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
mine = _mk_plan(db, 1, amount=50.0, plan_type="receive")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{CASH}/receivables/{mine.id}/payment", headers=auth_header(token1),
|
||||
json={"amount": 5.0, "paid_date": "2026-09-20"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
db.expire_all()
|
||||
assert db.query(CashPlan).filter(CashPlan.id == mine.id).first().paid_amount == 5.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P0-B. budget.py 建预算写会话账套
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_create_budget_plan_writes_session_entity(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.post(f"{BUDGET}/plans", headers=auth_header(token2),
|
||||
json={"kpi_id": k2.id, "period": "2026-09", "budget_value": 123.0})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
new_id = r.json()["id"]
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(BudgetPlan).filter(BudgetPlan.id == new_id).first()
|
||||
assert row is not None, "预算未落库"
|
||||
assert row.entity_id == 2, (
|
||||
f"会话账套=2 新建预算落库归属 entity_id={row.entity_id}(缺陷:未写归属→模型默认 1)"
|
||||
)
|
||||
|
||||
|
||||
def test_create_budget_plan_e1_control(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{BUDGET}/plans", headers=auth_header(token1),
|
||||
json={"kpi_id": k1.id, "period": "2026-09", "budget_value": 55.0})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
db.expire_all()
|
||||
row = db.query(BudgetPlan).filter(BudgetPlan.id == r.json()["id"]).first()
|
||||
assert row.entity_id == 1, f"e1 会话建预算 entity_id={row.entity_id}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P1. notifications.py 字段白名单
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_update_channel_rejects_non_whitelist_fields(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
ch = _mk_channel(db, "批4渠道")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{NOTIF}/channels/{ch.id}", headers=auth_header(token1), json={
|
||||
"name": "批4渠道-改名",
|
||||
"channel_type": "email",
|
||||
"id": 999999,
|
||||
"created_at": None,
|
||||
})
|
||||
assert r.status_code in (200, 400), f"非白名单字段应被忽略或 400,实际 {r.status_code} {r.text[:200]}"
|
||||
db.expire_all()
|
||||
row = db.query(NotificationChannel).filter(NotificationChannel.id == ch.id).first()
|
||||
assert row.name == "批4渠道-改名", "白名单字段 name 未更新"
|
||||
assert row.channel_type == "wecom", f"非白名单字段 channel_type 被篡改 → {row.channel_type}"
|
||||
assert row.id == ch.id, "主键被篡改"
|
||||
|
||||
|
||||
def test_update_channel_allows_business_fields(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
ch = _mk_channel(db, "批4渠道2")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{NOTIF}/channels/{ch.id}", headers=auth_header(token1), json={
|
||||
"name": "批4渠道2-新", "config": {"webhook": "y"}, "enabled": False,
|
||||
})
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
db.expire_all()
|
||||
row = db.query(NotificationChannel).filter(NotificationChannel.id == ch.id).first()
|
||||
assert row.name == "批4渠道2-新"
|
||||
assert row.enabled in (False, 0)
|
||||
assert (row.config or {}).get("webhook") == "y"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P1. 跨实体只读泄漏点
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_bot_kpis_list_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_BOT, 1, bot_source=BOT_SRC)
|
||||
_mk_kpi(db, E2_BOT, 2, bot_source=BOT_SRC)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"/api/cma/bot-kpis?source={BOT_SRC}", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
codes = {k["kpi_code"] for k in r.json()["kpis"]}
|
||||
assert E1_BOT not in codes, f"e2 会话读到 e1 Bot KPI: {sorted(codes)}"
|
||||
assert E2_BOT in codes, f"e2 会话读不到自己的 Bot KPI: {sorted(codes)}"
|
||||
|
||||
|
||||
def test_data_quality_check_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
_mk_kpi(db, E2_KPI, 2)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"{DQ}/check", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
body = r.json()
|
||||
assert body["total_kpis"] == 1, f"e2 会话扫描了 {body['total_kpis']} 个 KPI(应只扫本账套 1 个)"
|
||||
|
||||
db.expire_all()
|
||||
leaked = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.kpi_id == k1.id).count()
|
||||
assert leaked == 0, f"e2 会话的质量检查为 e1 的 KPI 写了 {leaked} 条日志"
|
||||
|
||||
|
||||
def test_data_quality_stats_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_KPI, 1)
|
||||
_mk_kpi(db, E2_KPI, 2)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"{DQ}/stats", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert r.json()["total_kpis"] == 1, f"e2 统计口径含他账套:total_kpis={r.json()['total_kpis']}"
|
||||
|
||||
|
||||
def test_kpis_causality_chain_cross_entity_404(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{KPIS}/{k2.id}/causality-chain", headers=auth_header(token1))
|
||||
assert r.status_code == 404, f"跨账套读因果链应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
assert E2_KPI not in r.text, "跨账套响应体泄漏了对端 kpi_code"
|
||||
|
||||
|
||||
def test_kpis_causality_chain_own_entity_ok(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{KPIS}/{k1.id}/causality-chain", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert r.json()["kpi"]["kpi_code"] == E1_KPI
|
||||
|
||||
|
||||
def test_kpis_associate_map_cross_entity_kpi_404(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
m1 = _mk_map(db, 1, "e1地图")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{KPIS}/{k2.id}/associate-map", headers=auth_header(token1), json={"map_id": m1.id})
|
||||
assert r.status_code == 404, f"跨账套改 KPI 归属应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
db.expire_all()
|
||||
assert db.query(KPIDefinition).filter(KPIDefinition.id == k2.id).first().map_id is None, \
|
||||
"跨账套 associate-map 竟改动了对端 KPI"
|
||||
|
||||
|
||||
def test_kpis_associate_map_cross_entity_map_404(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
m2 = _mk_map(db, 2, "e2地图")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{KPIS}/{k1.id}/associate-map", headers=auth_header(token1), json={"map_id": m2.id})
|
||||
assert r.status_code == 404, f"本账套 KPI 关联对端地图应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
db.expire_all()
|
||||
assert db.query(KPIDefinition).filter(KPIDefinition.id == k1.id).first().map_id is None
|
||||
|
||||
|
||||
def test_kpis_associate_map_own_entity_ok(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
m1 = _mk_map(db, 1, "e1地图")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{KPIS}/{k1.id}/associate-map", headers=auth_header(token1), json={"map_id": m1.id})
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
db.expire_all()
|
||||
assert db.query(KPIDefinition).filter(KPIDefinition.id == k1.id).first().map_id == m1.id
|
||||
|
||||
|
||||
def test_dashboard_my_kpis_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_KPI, 1)
|
||||
_mk_kpi(db, E2_KPI, 2)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"{DASH}/my-kpis", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert E1_KPI not in r.text, "e2 会话在 /dashboard/my-kpis 看到 e1 KPI 数据"
|
||||
assert E2_KPI in r.text, "e2 会话看不到自己的 KPI"
|
||||
|
||||
|
||||
def test_customer_dashboard_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_CUST, 1, dimension="customer")
|
||||
_mk_kpi(db, E2_CUST, 2, dimension="customer")
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(CUST, headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert E1_CUST not in r.text, "e2 会话在客户看板看到 e1 的客户维度 KPI"
|
||||
assert E2_CUST in r.text, "e2 会话看不到自己的客户维度 KPI"
|
||||
|
||||
|
||||
def test_kpi_governance_audit_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_KPI, 1)
|
||||
_mk_kpi(db, E2_KPI, 2)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"{GOV}/governance/audit", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
body = r.json()
|
||||
assert body["total"] == 1, f"e2 治理审计扫了 {body['total']} 个 KPI(应只含本账套 1 个)"
|
||||
assert E1_KPI not in r.text, "e2 治理审计泄漏 e1 KPI"
|
||||
|
||||
|
||||
def test_budget_generate_candidates_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_KPI, 1)
|
||||
_mk_kpi(db, E2_KPI, 2)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"{BUDGET}/kpi-budget-candidates?year=2026", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert E1_KPI not in r.text, "e2 会话在预算候选里看到 e1 KPI"
|
||||
assert E2_KPI in r.text, "e2 会话看不到自己的预算候选 KPI"
|
||||
|
||||
|
||||
def test_budget_comparison_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
_mk_budget(db, k1.id, 1)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"{BUDGET}/comparison?year=2026", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert r.json()["total_kpis"] == 0, (
|
||||
f"e2 会话的预算对比含他账套预算:total_kpis={r.json()['total_kpis']}"
|
||||
)
|
||||
|
||||
|
||||
def test_budget_kpi_comparison_cross_entity_404(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{BUDGET}/comparison/kpi/{k2.id}?year=2026", headers=auth_header(token1))
|
||||
assert r.status_code == 404, f"跨账套读预算对比应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
|
||||
def test_budget_deviation_report_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_KPI, 1)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.get(f"{BUDGET}/deviation-report?period=2026-09", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert E1_KPI not in r.text, "e2 会话在预算差异报告看到 e1 KPI"
|
||||
assert r.json()["summary"]["total_kpis"] == 0, (
|
||||
f"e2 差异报告含他账套 KPI:total_kpis={r.json()['summary']['total_kpis']}"
|
||||
)
|
||||
|
||||
|
||||
def test_driver_budget_write_and_history_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
token2 = _login(client, 2)
|
||||
payload = {
|
||||
"name": "批4驱动预算",
|
||||
"formula_text": "a * b",
|
||||
"factor_defs": [{"key": "a", "label": "A", "default": 2}, {"key": "b", "label": "B", "default": 3}],
|
||||
"factors": {},
|
||||
}
|
||||
r = client.post(f"{BUDGET}/driver/calculate", headers=auth_header(token2), json=payload)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
rec_id = r.json()["id"]
|
||||
|
||||
db.expire_all()
|
||||
rec = db.query(DriverFactorBudget).filter(DriverFactorBudget.id == rec_id).first()
|
||||
assert rec is not None and rec.entity_id == 2, (
|
||||
f"e2 会话的驱动因子预算落库归属 entity_id={getattr(rec, 'entity_id', None)}(应为 2)"
|
||||
)
|
||||
|
||||
# 历史列表只含本账套
|
||||
r1 = client.get(f"{BUDGET}/driver/history", headers=auth_header(_login(client, 1)))
|
||||
assert r1.status_code == 200, r1.text[:200]
|
||||
assert "批4驱动预算" not in r1.text, "e1 会话在驱动因子历史里看到 e2 记录"
|
||||
r2 = client.get(f"{BUDGET}/driver/history", headers=auth_header(token2))
|
||||
assert "批4驱动预算" in r2.text, "e2 会话看不到自己的驱动因子历史"
|
||||
|
||||
|
||||
def test_expenses_write_and_stats_scoped(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
_mk_reimb(db, 1, "ZZ_B4_E1_BX001", amount=111.0)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.post(f"{EXPENSES}/reimbursements", headers=auth_header(token2), json={
|
||||
"applicant": "李四", "department": "财务部", "expense_type": "office",
|
||||
"title": "批4 e2 报销", "amount": 222.0, "expense_date": "2026-09-15",
|
||||
})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
new_id = r.json()["data"]["id"]
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == new_id).first()
|
||||
assert row.entity_id == 2, f"e2 会话报销单落库归属 entity_id={row.entity_id}(应为 2)"
|
||||
|
||||
r2 = client.get(f"{EXPENSES}/stats", headers=auth_header(token2))
|
||||
assert r2.status_code == 200, r2.text[:200]
|
||||
counts = r2.json()["counts"]
|
||||
assert counts["total"] == 1, (
|
||||
f"e2 费用看板统计含他账套:total={counts['total']}(应只统计本账套 1 条)"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P2. okr_templates 应用模板写会话账套
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_apply_okr_template_writes_session_entity(client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
t = _mk_template(db, "批4模板")
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.post(f"{OKRT}/{t.id}/apply", headers=auth_header(token2), json={})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
map_id = r.json().get("map_id") or (r.json().get("data") or {}).get("id")
|
||||
|
||||
db.expire_all()
|
||||
m = None
|
||||
if map_id:
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if m is None:
|
||||
m = db.query(StrategicMap).order_by(StrategicMap.id.desc()).first()
|
||||
assert m is not None, f"应用模板未创建地图:{r.text[:300]}"
|
||||
assert m.entity_id == 2, (
|
||||
f"会话账套=2 应用 OKR 模板生成的地图归属 entity_id={m.entity_id}(缺陷:未传 entity_id→默认 1)"
|
||||
)
|
||||
@@ -0,0 +1,625 @@
|
||||
"""CMA 跨实体隔离残余收口(第五批)账套隔离回归测试 — 2026-09-21
|
||||
|
||||
任务:project-20260921-cma-entity-isolation-batch5(项目Bot 派单;
|
||||
来源 = batch4 执行中反向推送的残余单 fullstack-20260921-batch5-residual-entity-isolation.md)
|
||||
|
||||
本单范围(逐条):
|
||||
P0 budget.py update_budget_plan / delete_budget_plan / update_deviation_alert
|
||||
无账套校验 → 本账套会话可改/删他企预算与预警(跨租户写)
|
||||
P1 kpis.py get_kpi_objectives 无账套收口(KPI/StrategicMap/MapObjective 按 id 直取)
|
||||
P1 data_quality.py 质量日志族 GET/PUT/DELETE /logs 无账套收口(表无 entity_id 列,
|
||||
按选项(1) 经 kpi_id join kpi_definitions 按 kpi_definitions.entity_id 收口)
|
||||
P2 budget.py auto_decompose 单 KPI 分支不写 entity_id(落默认 1)
|
||||
P2 predict.py growth-quality 硬编码 ENTITY_DATA + 请求体 data.get("entity", data) 回退
|
||||
P2 expenses.py 列表假隔离 + 详情/审批无归属校验;tax_compliance.py 演示播种不写 entity_id
|
||||
|
||||
每条均「跨账套负例 + 本账套正对照」。改前 RED 原文见回报 §三。
|
||||
本文件只跑内存 SQLite(tests/conftest.py 机制),零生产库 / 零生产 Redis 连接。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
BudgetDeviationAlert,
|
||||
BudgetPlan,
|
||||
Entity,
|
||||
ExpenseReimbursement,
|
||||
KPIDefinition,
|
||||
KPIValue,
|
||||
KpiDataQualityLog,
|
||||
MapObjective,
|
||||
StrategicMap,
|
||||
)
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
BUDGET = "/api/cma/budget"
|
||||
KPIS = "/api/cma/kpis"
|
||||
DQ = "/api/cma/data-quality"
|
||||
PREDICT = "/api/cma/predict"
|
||||
EXPENSES = "/api/cma/expenses"
|
||||
TAX = "/api/cma/tax"
|
||||
|
||||
# 两账套资源编码前缀(kpi_code 库内唯一)
|
||||
E1_KPI = "ZZ_B5_E1_KPI_001"
|
||||
E2_KPI = "ZZ_B5_E2_KPI_001"
|
||||
E1_KPI_TREE = "ZZ_B5_E1_KPI_TREE"
|
||||
E1_KPI_OTHER_MAP = "ZZ_B5_E1_KPI_XMAP"
|
||||
E1_GQ_KPI = "ZZ_B5_E1_GQ"
|
||||
E2_GQ_KPI = "ZZ_B5_E2_GQ"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int, **kw) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
**kw,
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _mk_plan(db: Session, kpi_id: int, eid: int, period: str = "2026-09",
|
||||
value: float = 100.0, status: str = "active") -> BudgetPlan:
|
||||
bp = BudgetPlan(entity_id=eid, kpi_id=kpi_id, period=period, budget_value=value,
|
||||
budget_year=2026, budget_month=9, version="v1.0", status=status)
|
||||
db.add(bp)
|
||||
db.commit()
|
||||
db.refresh(bp)
|
||||
return bp
|
||||
|
||||
|
||||
def _mk_alert(db: Session, kpi_id: int, eid: int, status: str = "open") -> BudgetDeviationAlert:
|
||||
a = BudgetDeviationAlert(entity_id=eid, kpi_id=kpi_id, period="2026-09",
|
||||
budget_value=100.0, actual_value=130.0,
|
||||
deviation_rate=30.0, deviation_value=30.0,
|
||||
alert_level="warning", status=status)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return a
|
||||
|
||||
|
||||
def _mk_map(db: Session, eid: int, title: str) -> StrategicMap:
|
||||
m = StrategicMap(entity_id=eid, title=title, status="draft",
|
||||
dimensions=[], canvas_data={"connections": []})
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
def _mk_objective(db: Session, eid: int, map_id: int, name: str, dimension: str = "finance") -> MapObjective:
|
||||
o = MapObjective(entity_id=eid, map_id=map_id, dimension_key=dimension, name=name)
|
||||
db.add(o)
|
||||
db.commit()
|
||||
db.refresh(o)
|
||||
return o
|
||||
|
||||
|
||||
def _mk_log(db: Session, kpi_id: int, status: str = "open") -> KpiDataQualityLog:
|
||||
lg = KpiDataQualityLog(kpi_id=kpi_id, check_type="flat_data", severity="warning",
|
||||
detail={"note": "batch5"}, suggestion="检查采集", status=status)
|
||||
db.add(lg)
|
||||
db.commit()
|
||||
db.refresh(lg)
|
||||
return lg
|
||||
|
||||
|
||||
def _mk_reimb(db: Session, eid: int, no: str, amount: float = 100.0,
|
||||
status: str = "pending") -> ExpenseReimbursement:
|
||||
r = ExpenseReimbursement(entity_id=eid, reimb_no=no, applicant="张三", department="财务部",
|
||||
expense_type="office", title=f"{no}-报销", amount=amount,
|
||||
expense_date=datetime(2026, 9, 10), status=status,
|
||||
check_result="pass")
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "测试企业1")
|
||||
_ensure_entity(db, 2, "测试企业2")
|
||||
create_test_user(db)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P0. budget.py 预算/预警写端点按账套(跨账套 404)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_budget_update_plan_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话改 e2 预算计划 → 404 且对端计划未被改动"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
other = _mk_plan(db, k2.id, 2, value=50.0)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{BUDGET}/plans/{other.id}", headers=auth_header(token1),
|
||||
json={"budget_value": 999.0, "status": "inactive"})
|
||||
assert r.status_code == 404, f"跨账套改预算应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(BudgetPlan).filter(BudgetPlan.id == other.id).first()
|
||||
assert row is not None, "对端预算行被删除"
|
||||
assert row.budget_value == 50.0, f"跨账套改预算生效了:budget_value={row.budget_value}"
|
||||
assert row.status == "active", f"跨账套改了 status={row.status}"
|
||||
|
||||
|
||||
def test_budget_update_plan_own_entity_ok(client: TestClient, db: Session):
|
||||
"""本账套改自家预算计划 → 200 且落库"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
mine = _mk_plan(db, k1.id, 1, value=50.0)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{BUDGET}/plans/{mine.id}", headers=auth_header(token1),
|
||||
json={"budget_value": 88.0})
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
db.expire_all()
|
||||
assert db.query(BudgetPlan).filter(BudgetPlan.id == mine.id).first().budget_value == 88.0
|
||||
|
||||
|
||||
def test_budget_delete_plan_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话删 e2 预算计划 → 404 且对端计划仍在"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
other = _mk_plan(db, k2.id, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.delete(f"{BUDGET}/plans/{other.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 404, f"跨账套删预算应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
assert db.query(BudgetPlan).filter(BudgetPlan.id == other.id).first() is not None, \
|
||||
"跨账套删除竟生效(对端预算行已消失)"
|
||||
|
||||
|
||||
def test_budget_delete_plan_own_entity_ok(client: TestClient, db: Session):
|
||||
"""本账套删自家预算计划 → 200 且行消失"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
mine = _mk_plan(db, k1.id, 1)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.delete(f"{BUDGET}/plans/{mine.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
db.expire_all()
|
||||
assert db.query(BudgetPlan).filter(BudgetPlan.id == mine.id).first() is None
|
||||
|
||||
|
||||
def test_budget_update_deviation_alert_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话改 e2 偏差预警 → 404 且对端预警状态未变"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
other = _mk_alert(db, k2.id, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{BUDGET}/deviation-alerts/{other.id}", headers=auth_header(token1),
|
||||
json={"status": "resolved"})
|
||||
assert r.status_code == 404, f"跨账套改预警应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == other.id).first()
|
||||
assert row is not None, "对端预警行被删除"
|
||||
assert row.status == "open", f"跨账套改预警生效了:status={row.status}"
|
||||
|
||||
|
||||
def test_budget_update_deviation_alert_own_entity_ok(client: TestClient, db: Session):
|
||||
"""本账套改自家预警 → 200 且落库"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
mine = _mk_alert(db, k1.id, 1)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{BUDGET}/deviation-alerts/{mine.id}", headers=auth_header(token1),
|
||||
json={"status": "resolved"})
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
db.expire_all()
|
||||
assert db.query(BudgetDeviationAlert).filter(
|
||||
BudgetDeviationAlert.id == mine.id).first().status == "resolved"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P1. kpis.py get_kpi_objectives 账套收口
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_kpi_objectives_requires_auth(client: TestClient, db: Session):
|
||||
"""无 token → 401(鉴权面口径)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI_TREE, 1)
|
||||
|
||||
r = client.get(f"{KPIS}/{k1.id}/objectives")
|
||||
assert r.status_code == 401, f"未鉴权访问 KPI 目标应 401,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
|
||||
def test_kpi_objectives_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话取 e2 KPI 的目标 → 404"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{KPIS}/{k2.id}/objectives", headers=auth_header(token1))
|
||||
assert r.status_code == 404, f"跨账套取 KPI 目标应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
|
||||
def test_kpi_objectives_own_entity_ok(client: TestClient, db: Session):
|
||||
"""本账套 KPI 的目标/地图正常返回(正对照)"""
|
||||
_seed_two_entities(db)
|
||||
m1 = _mk_map(db, 1, "e1地图")
|
||||
_mk_objective(db, 1, m1.id, "提升收入")
|
||||
k1 = _mk_kpi(db, E1_KPI_TREE, 1, map_id=m1.id, objective="提升收入")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{KPIS}/{k1.id}/objectives", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
body = r.json()
|
||||
assert body["map"] and body["map"]["id"] == m1.id, f"本账套地图未返回: {body}"
|
||||
assert len(body["objectives"]) == 1 and body["objectives"][0]["name"] == "提升收入"
|
||||
|
||||
|
||||
def test_kpi_objectives_map_and_objectives_scoped_by_entity(client: TestClient, db: Session):
|
||||
"""本账套 KPI 挂了他账套地图/目标 → 不得带出他账套地图与目标"""
|
||||
_seed_two_entities(db)
|
||||
m2 = _mk_map(db, 2, "e2地图")
|
||||
_mk_objective(db, 2, m2.id, "e2目标")
|
||||
# e1 的 KPI,但 map_id 指向 e2 地图(历史脏挂)→ 不应带出 e2 地图/目标
|
||||
k1 = _mk_kpi(db, E1_KPI_OTHER_MAP, 1, map_id=m2.id, objective="e2目标")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{KPIS}/{k1.id}/objectives", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
body = r.json()
|
||||
assert not body.get("map"), f"带出了他账套战略地图: {body.get('map')}"
|
||||
assert body["objectives"] == [], f"带出了他账套战略目标: {body['objectives']}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P1. data_quality.py 质量日志族按 KPI 归属收口
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_quality_logs_list_cross_entity_invisible(client: TestClient, db: Session):
|
||||
"""e1 会话列表看不到 e2 KPI 的质量日志;e2 会话看得到自己的"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
mine = _mk_log(db, k1.id)
|
||||
other = _mk_log(db, k2.id)
|
||||
token1 = _login(client, 1)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r1 = client.get(f"{DQ}/logs", headers=auth_header(token1))
|
||||
assert r1.status_code == 200, r1.text[:200]
|
||||
ids1 = {x["id"] for x in r1.json()["data"]}
|
||||
assert other.id not in ids1, f"e1 会话看到 e2 账套质量日志(id={other.id})"
|
||||
assert mine.id in ids1, f"e1 会话未看到本账套日志(id={mine.id})"
|
||||
|
||||
r2 = client.get(f"{DQ}/logs", headers=auth_header(token2))
|
||||
ids2 = {x["id"] for x in r2.json()["data"]}
|
||||
assert other.id in ids2, "e2 会话看不到本账套日志"
|
||||
|
||||
|
||||
def test_quality_logs_list_kpi_filter_cross_entity_empty(client: TestClient, db: Session):
|
||||
"""e1 会话用他账套 kpi_id 过滤 → 空结果"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
other = _mk_log(db, k2.id)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{DQ}/logs?kpi_id={k2.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert {x["id"] for x in r.json()["data"]} == set(), \
|
||||
f"按他账套 kpi_id 过滤竟返回日志: {r.json()['data']}"
|
||||
|
||||
# 同一过滤器在 e2 会话下命中(正对照,证明过滤本身有效)
|
||||
token2 = _login(client, 2)
|
||||
r2 = client.get(f"{DQ}/logs?kpi_id={k2.id}", headers=auth_header(token2))
|
||||
assert other.id in {x["id"] for x in r2.json()["data"]}
|
||||
|
||||
|
||||
def test_quality_log_update_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话改 e2 KPI 的质量日志 → 404 且状态未变"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
other = _mk_log(db, k2.id)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{DQ}/logs/{other.id}", headers=auth_header(token1),
|
||||
json={"status": "resolved"})
|
||||
assert r.status_code == 404, f"跨账套改质量日志应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == other.id).first()
|
||||
assert row is not None and row.status == "open", f"跨账套改日志生效了:{row and row.status}"
|
||||
|
||||
|
||||
def test_quality_log_delete_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话删 e2 KPI 的质量日志 → 404 且行仍在"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
other = _mk_log(db, k2.id)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.delete(f"{DQ}/logs/{other.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 404, f"跨账套删质量日志应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == other.id).first() is not None, \
|
||||
"跨账套删除竟生效(对端日志行已消失)"
|
||||
|
||||
|
||||
def test_quality_log_update_delete_own_entity_ok(client: TestClient, db: Session):
|
||||
"""本账套改/删自家质量日志 → 200(正对照)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
a = _mk_log(db, k1.id)
|
||||
b = _mk_log(db, k1.id)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.put(f"{DQ}/logs/{a.id}", headers=auth_header(token1), json={"status": "resolved"})
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
db.expire_all()
|
||||
assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == a.id).first().status == "resolved"
|
||||
|
||||
r2 = client.delete(f"{DQ}/logs/{b.id}", headers=auth_header(token1))
|
||||
assert r2.status_code == 200, r2.text[:200]
|
||||
db.expire_all()
|
||||
assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == b.id).first() is None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P2. budget.py auto_decompose 单 KPI 分支账套归属
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_auto_decompose_single_kpi_writes_session_entity(client: TestClient, db: Session):
|
||||
"""e2 会话单 KPI 分解 → 12 行月度预算归属 e2(改前落默认 1)"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token2),
|
||||
json={"kpi_id": k2.id, "annual_budget": 120000, "year": 2026, "method": "equal"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
|
||||
db.expire_all()
|
||||
rows = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k2.id).all()
|
||||
assert len(rows) == 12, f"应生成 12 行月度预算,实际 {len(rows)}"
|
||||
bad = [x.id for x in rows if x.entity_id != 2]
|
||||
assert not bad, f"单 KPI 分支未写会话账套 entity_id(错归属行 id={bad})"
|
||||
|
||||
|
||||
def test_auto_decompose_single_kpi_own_entity_ok(client: TestClient, db: Session):
|
||||
"""e1 会话单 KPI 分解 → 200 且 12 行归属 e1(正对照)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token1),
|
||||
json={"kpi_id": k1.id, "annual_budget": 120000, "year": 2026, "method": "equal"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
db.expire_all()
|
||||
rows = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).all()
|
||||
assert len(rows) == 12 and all(x.entity_id == 1 for x in rows)
|
||||
|
||||
|
||||
def test_auto_decompose_single_kpi_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话对他账套 KPI 做单 KPI 分解 → 404 且不落任何行"""
|
||||
_seed_two_entities(db)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token1),
|
||||
json={"kpi_id": k2.id, "annual_budget": 120000, "year": 2026, "method": "equal"})
|
||||
assert r.status_code == 404, f"跨账套单 KPI 分解应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
assert db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k2.id).count() == 0, \
|
||||
"跨账套分解竟写出了预算行"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P2. predict.py growth-quality 按会话账套读库
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _mk_kpi_gq(db: Session, eid: int, code: str, value: float, period: str = "2026-09"):
|
||||
kpi = _mk_kpi(db, code, eid)
|
||||
db.add(KPIValue(kpi_id=kpi.id, entity_id=eid, period=period, actual_value=value))
|
||||
db.commit()
|
||||
return kpi
|
||||
|
||||
|
||||
def test_growth_quality_body_entity_ignored(client: TestClient, db: Session):
|
||||
"""请求体注入 entity 经营数据不得被采信(原 data.get("entity", data) 回退)"""
|
||||
_seed_two_entities(db)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={
|
||||
"period": "2026-09",
|
||||
"entity": {"entity": "伪造企业", "rebateRate": 82.8, "trueGrossMargin": 18.6,
|
||||
"cashRatio": 0.6, "expenseGrowthRate": 2.2, "revenueGrowthRate": 1.0,
|
||||
"mgmtRatio": 447},
|
||||
})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
body = r.json()
|
||||
assert body.get("entity_name") != "伪造企业", f"请求体 entity 被采信: {body.get('entity_name')}"
|
||||
assert body.get("collected") is False, f"库内无数据却给出评分: {body}"
|
||||
assert body.get("dimensions") == {}, f"未采集却输出维度评分: {body.get('dimensions')}"
|
||||
assert body.get("overall") is None, f"未采集却输出综合评分: {body.get('overall')}"
|
||||
|
||||
# e1 已有同名账套实体 → entity_name 来自库内 Entity 表
|
||||
ent1 = db.query(Entity).filter(Entity.id == 1).first()
|
||||
assert body.get("entity_name") == ent1.name
|
||||
|
||||
|
||||
def test_growth_quality_body_explicit_other_entity_403(client: TestClient, db: Session):
|
||||
"""请求体显式 entity_id 与会话账套不一致 → 403"""
|
||||
_seed_two_entities(db)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1),
|
||||
json={"entity_id": 2})
|
||||
assert r.status_code == 403, f"跨账套显式 entity_id 应 403,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
|
||||
def test_growth_quality_reads_own_entity_kpi(client: TestClient, db: Session):
|
||||
"""库内存在本账套 KPI 实际值 → collected=true 且维度评分来自库内数据"""
|
||||
_seed_two_entities(db)
|
||||
# 库内既有编码口径:C_REBATE_RATE / F_GROSS_MARGIN / F_REVENUE_GROWTH
|
||||
_mk_kpi_gq(db, 1, "C_REBATE_RATE", 82.8)
|
||||
_mk_kpi_gq(db, 1, "F_GROSS_MARGIN", 18.6)
|
||||
_mk_kpi_gq(db, 1, "F_REVENUE_GROWTH", 1.0)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={"period": "2026-09"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
body = r.json()
|
||||
assert body.get("collected") is True, f"库内有数据却判未采集: {body}"
|
||||
assert body.get("overall") is not None, f"未给出综合评分: {body}"
|
||||
assert "revenueStructure" in body.get("dimensions", {}), f"营收结构维度缺失: {body.get('dimensions')}"
|
||||
|
||||
|
||||
def test_growth_quality_compare_body_not_trusted(client: TestClient, db: Session):
|
||||
"""请求体 compare 数据不得被采信"""
|
||||
_seed_two_entities(db)
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={
|
||||
"compare": {"entity": "伪造对比企业", "rebateRate": 0, "mgmtRatio": 1.4},
|
||||
})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
body = r.json()
|
||||
assert not body.get("compare") or body["compare"].get("entity_name") != "伪造对比企业", \
|
||||
f"请求体 compare 被采信: {body.get('compare')}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# P2. expenses.py 列表/详情/审批账套归属
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_expenses_list_cross_entity_invisible(client: TestClient, db: Session):
|
||||
"""e1 会话列表看不到 e2 报销单(原声明了 entity_id 依赖但未生效)"""
|
||||
_seed_two_entities(db)
|
||||
mine = _mk_reimb(db, 1, "ZZB5E1BX001")
|
||||
other = _mk_reimb(db, 2, "ZZB5E2BX001")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{EXPENSES}/reimbursements", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
ids = {x["id"] for x in r.json()["data"]}
|
||||
assert other.id not in ids, f"e1 会话看到 e2 报销单(id={other.id})"
|
||||
assert mine.id in ids, "e1 会话看不到本账套报销单"
|
||||
|
||||
# 正对照:e2 会话看得到自己的
|
||||
token2 = _login(client, 2)
|
||||
r2 = client.get(f"{EXPENSES}/reimbursements", headers=auth_header(token2))
|
||||
assert other.id in {x["id"] for x in r2.json()["data"]}
|
||||
|
||||
|
||||
def test_expenses_detail_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话读 e2 报销单详情 → 404"""
|
||||
_seed_two_entities(db)
|
||||
other = _mk_reimb(db, 2, "ZZB5E2BX002")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{EXPENSES}/reimbursements/{other.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 404, f"跨账套读报销单详情应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
|
||||
def test_expenses_detail_own_entity_ok(client: TestClient, db: Session):
|
||||
"""本账套报销单详情 → 200(正对照)"""
|
||||
_seed_two_entities(db)
|
||||
mine = _mk_reimb(db, 1, "ZZB5E1BX002")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.get(f"{EXPENSES}/reimbursements/{mine.id}", headers=auth_header(token1))
|
||||
assert r.status_code == 200, r.text[:200]
|
||||
assert r.json()["reimb_no"] == "ZZB5E1BX002"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action,payload", [
|
||||
("approve", {}),
|
||||
("reject", {"comment": "不合规"}),
|
||||
("return", {"comment": "请补充材料"}),
|
||||
])
|
||||
def test_expenses_approval_cross_entity_404(client: TestClient, db: Session, action: str, payload: dict):
|
||||
"""e1 会话审批 e2 报销单(approve/reject/return)→ 404 且状态未变"""
|
||||
_seed_two_entities(db)
|
||||
other = _mk_reimb(db, 2, f"ZZB5E2BX_{action.upper()}")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{EXPENSES}/reimbursements/{other.id}/{action}",
|
||||
headers=auth_header(token1), json=payload)
|
||||
assert r.status_code == 404, f"跨账套{action}应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == other.id).first()
|
||||
assert row is not None and row.status == "pending", f"跨账套{action}生效了:{row and row.status}"
|
||||
|
||||
|
||||
def test_expenses_approve_own_entity_ok(client: TestClient, db: Session):
|
||||
"""本账套审批自家报销单 → 200(正对照)"""
|
||||
_seed_two_entities(db)
|
||||
mine = _mk_reimb(db, 1, "ZZB5E1BX003")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{EXPENSES}/reimbursements/{mine.id}/approve",
|
||||
headers=auth_header(token1), json={})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
db.expire_all()
|
||||
assert db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.id == mine.id).first().status == "approved"
|
||||
|
||||
|
||||
def test_tax_demo_seed_reimbursement_entity_id(client: TestClient, db: Session):
|
||||
"""tax_compliance 演示播种的报销单须写会话账套 entity_id"""
|
||||
_seed_two_entities(db)
|
||||
token2 = _login(client, 2)
|
||||
|
||||
r = client.post(f"{TAX}/demo-data", headers=auth_header(token2))
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
|
||||
db.expire_all()
|
||||
rows = db.query(ExpenseReimbursement).all()
|
||||
assert rows, "演示播种未产生报销单"
|
||||
bad = [(x.id, x.entity_id) for x in rows if x.entity_id != 2]
|
||||
assert not bad, f"演示报销单未写会话账套(落默认 1): {bad}"
|
||||
@@ -0,0 +1,225 @@
|
||||
"""CMA expenses 残余跨账套面收口回归测试 — 2026-09-21
|
||||
|
||||
任务:fullstack-20260921-expenses-residual-entity-scope(batch5 派生单)
|
||||
|
||||
本单范围(仅此两处,均在 backend/app/api/expenses.py):
|
||||
F1 (P1 跨租户写) resubmit_reimbursement 仅按 id 取行、无账套归属校验
|
||||
→ 本账套会话持他企报销单 id 即可改写金额并触发重新校验
|
||||
F2 (P2 跨租户读污染判定) validate_reimbursement 月度/年度累计分支的累计查询未按
|
||||
entity_id 过滤 → 他账套同费用类型在途金额计入本账套「月度/年度累计是否超限」判定
|
||||
|
||||
每条均「跨账套负例 + 本账套正对照」。
|
||||
只跑内存 SQLite(tests/conftest.py 机制),零生产库 / 零生产 Redis 连接。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Entity, ExpenseReimbursement, ExpenseRule
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
EXPENSES = "/api/cma/expenses"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_rule(db: Session, **kw) -> ExpenseRule:
|
||||
defaults = {
|
||||
"rule_name": "ZZEXP_RESIDUAL_RULE", "dimension": "expense_type",
|
||||
"dimension_value": "", "expense_type": "office",
|
||||
"limit_type": "monthly", "limit_amount": 1000.0,
|
||||
"cycle": "monthly", "status": "active", "remark": "",
|
||||
}
|
||||
defaults.update(kw)
|
||||
r = ExpenseRule(**defaults)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
def _mk_reimb(db: Session, eid: int, no: str, amount: float = 100.0,
|
||||
status: str = "pending", expense_type: str = "office",
|
||||
expense_date: datetime = datetime(2026, 9, 10)) -> ExpenseReimbursement:
|
||||
r = ExpenseReimbursement(entity_id=eid, reimb_no=no, applicant="张三",
|
||||
department="财务部", expense_type=expense_type,
|
||||
title=f"{no}-报销", amount=amount,
|
||||
expense_date=expense_date, status=status,
|
||||
check_result="pass")
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "测试企业1")
|
||||
_ensure_entity(db, 2, "测试企业2")
|
||||
create_test_user(db)
|
||||
|
||||
|
||||
def _check_entry(detail, rule_type: str) -> dict:
|
||||
for c in (detail or []):
|
||||
if c.get("rule_type") == rule_type:
|
||||
return c
|
||||
raise AssertionError(f"check_detail 缺少 {rule_type} 条目: {detail}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# F1. resubmit_reimbursement 账套归属(跨账套 → 404)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_resubmit_cross_entity_404(client: TestClient, db: Session):
|
||||
"""e1 会话持 e2 报销单 id 重新提交(含改金额)→ 404,且 e2 行零变更"""
|
||||
_seed_two_entities(db)
|
||||
other = _mk_reimb(db, 2, "ZZEXP_E2_RESUBMIT_1", amount=100.0, status="returned")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{EXPENSES}/reimbursements/{other.id}/resubmit",
|
||||
headers=auth_header(token1), json={"amount": 9999.0})
|
||||
assert r.status_code == 404, (
|
||||
f"跨账套 resubmit 应 404,实际 {r.status_code} {r.text[:200]}")
|
||||
assert "报销单不存在" in r.json()["detail"]
|
||||
|
||||
# 跨租户写必须零生效:金额/状态/校验结果均未改
|
||||
db.expire_all()
|
||||
row = db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.id == other.id).first()
|
||||
assert row is not None
|
||||
assert row.amount == 100.0, f"跨账套 resubmit 改写了金额: {row.amount}"
|
||||
assert row.status == "returned", f"跨账套 resubmit 改写了状态: {row.status}"
|
||||
|
||||
|
||||
def test_resubmit_own_entity_ok(client: TestClient, db: Session):
|
||||
"""本账套报销单重新提交仍可用 → 200(正对照,未被收口误杀)"""
|
||||
_seed_two_entities(db)
|
||||
mine = _mk_reimb(db, 1, "ZZEXP_E1_RESUBMIT_1", amount=100.0, status="returned")
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(f"{EXPENSES}/reimbursements/{mine.id}/resubmit",
|
||||
headers=auth_header(token1), json={"amount": 80.0})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert r.json()["message"] == "已重新提交"
|
||||
d = r.json()["data"]
|
||||
assert d["amount"] == 80.0
|
||||
assert d["status"] == "pending"
|
||||
|
||||
db.expire_all()
|
||||
row = db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.id == mine.id).first()
|
||||
assert row.amount == 80.0 and row.status == "pending"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# F2. validate_reimbursement 累计判定按账套(月度/年度)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_monthly_cumulative_cross_entity_excluded(client: TestClient, db: Session):
|
||||
"""e2 在途金额不得计入 e1 月度累计:e1 单笔 200 / 月限额 1000 应通过"""
|
||||
_seed_two_entities(db)
|
||||
_mk_rule(db, rule_name="ZZEXP月限额1000", expense_type="office",
|
||||
limit_type="monthly", limit_amount=1000.0, cycle="monthly")
|
||||
_mk_reimb(db, 2, "ZZEXP_E2_MONTH_900", amount=900.0, status="pending",
|
||||
expense_date=datetime(2026, 9, 5))
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(EXPENSES + "/reimbursements", headers=auth_header(token1),
|
||||
json={"expense_type": "office", "title": "e1月度单",
|
||||
"amount": 200.0, "expense_date": "2026-09-15"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
d = r.json()["data"]
|
||||
assert d["auto_check_passed"] is True, (
|
||||
f"他账套在途金额污染了本账套月度判定: {d['check_detail']}")
|
||||
assert d["status"] == "pending"
|
||||
|
||||
entry = _check_entry(d["check_detail"], "月度累计")
|
||||
assert entry["used"] == 0.0, f"月度累计 used 含他账套金额: {entry}"
|
||||
assert entry["actual"] == 200.0
|
||||
assert entry["passed"] is True
|
||||
|
||||
|
||||
def test_monthly_cumulative_own_entity_still_counted(client: TestClient, db: Session):
|
||||
"""本账套月度累计仍照常计入:e1 已有 500,再报 600 → 超 1000 自动打回"""
|
||||
_seed_two_entities(db)
|
||||
_mk_rule(db, rule_name="ZZEXP月限额1000b", expense_type="office",
|
||||
limit_type="monthly", limit_amount=1000.0, cycle="monthly")
|
||||
_mk_reimb(db, 1, "ZZEXP_E1_MONTH_500", amount=500.0, status="pending",
|
||||
expense_date=datetime(2026, 9, 5))
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(EXPENSES + "/reimbursements", headers=auth_header(token1),
|
||||
json={"expense_type": "office", "title": "e1月度超限",
|
||||
"amount": 600.0, "expense_date": "2026-09-16"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
d = r.json()["data"]
|
||||
assert d["auto_check_passed"] is False, (
|
||||
f"本账套累计未生效(收口过度): {d['check_detail']}")
|
||||
assert d["status"] == "returned"
|
||||
entry = _check_entry(d["check_detail"], "月度累计")
|
||||
assert entry["used"] == 500.0, f"本账套月度累计 used 应为 500: {entry}"
|
||||
assert entry["actual"] == 1100.0
|
||||
|
||||
|
||||
def test_yearly_cumulative_cross_entity_excluded(client: TestClient, db: Session):
|
||||
"""e2 在途金额不得计入 e1 年度累计(跨月同年度):e1 报 200 / 年限额 1000 应通过"""
|
||||
_seed_two_entities(db)
|
||||
_mk_rule(db, rule_name="ZZEXP年限额1000", expense_type="office",
|
||||
limit_type="yearly", limit_amount=1000.0, cycle="yearly")
|
||||
_mk_reimb(db, 2, "ZZEXP_E2_YEAR_900", amount=900.0, status="approved",
|
||||
expense_date=datetime(2026, 3, 5))
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(EXPENSES + "/reimbursements", headers=auth_header(token1),
|
||||
json={"expense_type": "office", "title": "e1年度单",
|
||||
"amount": 200.0, "expense_date": "2026-09-15"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
d = r.json()["data"]
|
||||
assert d["auto_check_passed"] is True, (
|
||||
f"他账套在途金额污染了本账套年度判定: {d['check_detail']}")
|
||||
entry = _check_entry(d["check_detail"], "年度累计")
|
||||
assert entry["used"] == 0.0, f"年度累计 used 含他账套金额: {entry}"
|
||||
assert entry["actual"] == 200.0
|
||||
|
||||
|
||||
def test_yearly_cumulative_own_entity_still_counted(client: TestClient, db: Session):
|
||||
"""本账套年度累计仍照常计入:e1 已有 900(3月),再报 200 → 超 1000 自动打回"""
|
||||
_seed_two_entities(db)
|
||||
_mk_rule(db, rule_name="ZZEXP年限额1000b", expense_type="office",
|
||||
limit_type="yearly", limit_amount=1000.0, cycle="yearly")
|
||||
_mk_reimb(db, 1, "ZZEXP_E1_YEAR_900", amount=900.0, status="approved",
|
||||
expense_date=datetime(2026, 3, 5))
|
||||
token1 = _login(client, 1)
|
||||
|
||||
r = client.post(EXPENSES + "/reimbursements", headers=auth_header(token1),
|
||||
json={"expense_type": "office", "title": "e1年度超限",
|
||||
"amount": 200.0, "expense_date": "2026-09-16"})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
d = r.json()["data"]
|
||||
assert d["auto_check_passed"] is False, (
|
||||
f"本账套年度累计未生效(收口过度): {d['check_detail']}")
|
||||
entry = _check_entry(d["check_detail"], "年度累计")
|
||||
assert entry["used"] == 900.0, f"本账套年度累计 used 应为 900: {entry}"
|
||||
assert entry["actual"] == 1100.0
|
||||
@@ -0,0 +1,275 @@
|
||||
"""CMA 跨实体隔离 — AI 建议「忽略」与「应用目标 KPI」账套收敛(2026-09-18 派生单)
|
||||
|
||||
任务:fullstack-20260918-2030-dismiss-suggestion-entity-scope
|
||||
父单:fullstack-20260918-2025-read-side-entity-scope-gaps
|
||||
(其 §五 范围外发现 第 1/2 条:写侧同族漏网点)
|
||||
|
||||
缺陷(同文件 ai_suggestions.py 的写侧漏网点):
|
||||
1. dismiss_suggestion — POST /{suggestion_id}/dismiss 按 id 直取、无账套条件,
|
||||
且会改状态(status="dismissed")
|
||||
⇒ 本账套会话带对端账套建议 id 可跨账套忽略对端建议(数据被改写)
|
||||
2. _apply_kpi_target — 目标 KPIDefinition 只按 id 查,不校验归属会话账套
|
||||
3. _apply_budget_adjust — 同上(BudgetPlan 侧已按 sug.entity_id,KPI 侧未校验)
|
||||
⇒ 建议自身归属校验通过后,其 suggestion_data 指向对端账套 KPI 时,
|
||||
应用动作会改到对端账套的 target_value / 预算行(纵深防御缺口)
|
||||
|
||||
对端记录口径 = 404「建议不存在」/「KPI {id} 不存在」(对齐同文件
|
||||
apply_suggestion 与 1905b/2015 的写法)。
|
||||
|
||||
本用例(改前失败条目见 _reports/evidence-fullstack-20260918-2030-red.txt):
|
||||
- T1 忽略建议:对端账套 → 404 且建议状态保持 unapplied(双向);本账套 → 200
|
||||
且状态 dismissed(双向正对照)
|
||||
- T2 忽略幂等:同一建议重复忽略仍 200、状态仍 dismissed(既有语义不得改动)
|
||||
- T3 应用 kpi_target:目标 KPI 非本账套 → 404,对端 KPI target_value 逐值未变,
|
||||
建议状态保持 unapplied;本账套应用正常(正对照,目标值已写入)
|
||||
- T4 应用 budget_adjust:目标 KPI 非本账套 → 404,不落任何预算行(对端/本账套都不落)
|
||||
- T5 鉴权底线:dismiss 无 token → 401(改前改后都应通过)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AISuggestion, BudgetPlan, Entity, KPIDefinition
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
SUGG = "/api/cma/ai/suggestions"
|
||||
|
||||
# kpi_code 全局唯一 → 每账套独立 code
|
||||
E1_KPI = "ZZ_DSE1_KPI"
|
||||
E2_KPI = "ZZ_DSE2_KPI"
|
||||
E1_SUG_TITLE = "ZZ_DS e1 私有建议"
|
||||
E2_SUG_TITLE = "ZZ_DS e2 私有建议"
|
||||
E1_CROSS_TITLE = "ZZ_DS e2 指向对端 KPI 的建议"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具(沿用同族风格:双向登录 + 同账套正对照)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int, target: float = 1000.0) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=target,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _mk_suggestion(client: TestClient, token: str, title: str,
|
||||
target_id: int, kpi_id: int, target_value: float = 2000.0) -> int:
|
||||
body = {
|
||||
"suggestion_type": "kpi_target",
|
||||
"target_type": "kpi",
|
||||
"target_id": target_id,
|
||||
"title": title,
|
||||
"content": "目标偏低,建议上调",
|
||||
"suggestion_data": {"kpi_id": kpi_id, "target_value": target_value},
|
||||
}
|
||||
r = client.post(SUGG, json=body, headers=auth_header(token))
|
||||
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
|
||||
return r.json()["data"]["id"]
|
||||
|
||||
|
||||
def _sug_status(db: Session, sid: int) -> str:
|
||||
db.expire_all()
|
||||
row = db.query(AISuggestion).filter(AISuggestion.id == sid).first()
|
||||
assert row is not None, f"建议 #{sid} 不存在"
|
||||
return row.status
|
||||
|
||||
|
||||
def _kpi_target(db: Session, kid: int) -> float:
|
||||
db.expire_all()
|
||||
row = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
|
||||
assert row is not None, f"KPI #{kid} 不存在"
|
||||
return row.target_value
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T1. 忽略建议按会话账套限定(双向 + 正对照)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_dismiss_suggestion_scoped_to_session_entity(client: TestClient, db: Session):
|
||||
"""对端账套建议 → 404 且状态保持 unapplied;本账套 → 200 且 dismissed(双向)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
tok1 = _login(client, 1)
|
||||
tok2 = _login(client, 2)
|
||||
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
|
||||
s2 = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id)
|
||||
|
||||
# 方向 A:e2 会话忽略 e1 建议 → 404 且 e1 建议状态未改
|
||||
r_a = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok2))
|
||||
assert r_a.status_code == 404, (
|
||||
f"e2 会话忽略 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}"
|
||||
" —— dismiss_suggestion 按 id 直取、未按会话账套限定"
|
||||
)
|
||||
assert _sug_status(db, s1) == "unapplied", (
|
||||
"跨账套忽略竟改动了对端账套建议状态(应为 unapplied)"
|
||||
)
|
||||
|
||||
# 方向 B:e1 会话忽略 e2 建议 → 404 且 e2 建议状态未改
|
||||
r_b = client.post(f"{SUGG}/{s2}/dismiss", headers=auth_header(tok1))
|
||||
assert r_b.status_code == 404, (
|
||||
f"e1 会话忽略 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}"
|
||||
)
|
||||
assert _sug_status(db, s2) == "unapplied", (
|
||||
"跨账套忽略竟改动了对端账套建议状态(应为 unapplied)"
|
||||
)
|
||||
|
||||
# 正对照:本账套忽略自己的建议 → 200 且状态 dismissed
|
||||
r_ok1 = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok1))
|
||||
assert r_ok1.status_code == 200, f"e1 忽略自己的建议应 200,实际 {r_ok1.status_code}"
|
||||
assert _sug_status(db, s1) == "dismissed", "本账套忽略未落状态 dismissed"
|
||||
|
||||
r_ok2 = client.post(f"{SUGG}/{s2}/dismiss", headers=auth_header(tok2))
|
||||
assert r_ok2.status_code == 200, f"e2 忽略自己的建议应 200,实际 {r_ok2.status_code}"
|
||||
assert _sug_status(db, s2) == "dismissed", "本账套忽略未落状态 dismissed"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T2. 忽略幂等:既有语义不得改动
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_dismiss_suggestion_repeat_keeps_existing_semantics(client: TestClient, db: Session):
|
||||
"""已 dismissed 的建议重复忽略仍 200、状态仍 dismissed(加账套条件不得改动既有语义)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
tok1 = _login(client, 1)
|
||||
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
|
||||
|
||||
assert client.post(f"{SUGG}/{s1}/dismiss",
|
||||
headers=auth_header(tok1)).status_code == 200
|
||||
r2 = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok1))
|
||||
assert r2.status_code == 200, (
|
||||
f"重复忽略应维持既有 200 语义,实际 {r2.status_code} {r2.text[:200]}"
|
||||
)
|
||||
assert _sug_status(db, s1) == "dismissed", "重复忽略后状态异常"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T3. 应用 kpi_target:目标 KPI 归属校验
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_apply_kpi_target_foreign_kpi_404(client: TestClient, db: Session):
|
||||
"""建议自身属本账套、但其目标 KPI 属对端 → 404;对端 KPI target_value 逐值未变"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0)
|
||||
_mk_kpi(db, E2_KPI, 2, target=3000.0)
|
||||
tok1 = _login(client, 1)
|
||||
tok2 = _login(client, 2)
|
||||
|
||||
# e2 会话创建建议(归属 e2),但 suggestion_data/target_id 指向 e1 的 KPI
|
||||
# (模拟手填数据 / 迁移遗留 / Bot 通道写入的不自洽数据)
|
||||
s_cross = _mk_suggestion(client, tok2, E1_CROSS_TITLE, k1.id, k1.id, target_value=88888.0)
|
||||
|
||||
before_k1 = _kpi_target(db, k1.id)
|
||||
assert before_k1 == 1000.0, f"前置:e1 KPI 目标值异常 {before_k1}"
|
||||
|
||||
r = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
|
||||
json={"action": "kpi_target", "target_value": 99999.0})
|
||||
assert r.status_code == 404, (
|
||||
f"目标 KPI 属对端账套应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
" —— _apply_kpi_target 只按 id 查 KPIDefinition,未校验归属会话账套"
|
||||
)
|
||||
assert _kpi_target(db, k1.id) == before_k1, (
|
||||
f"对端账套 KPI target_value 被改写:{before_k1} → {_kpi_target(db, k1.id)}"
|
||||
)
|
||||
assert _sug_status(db, s_cross) == "unapplied", (
|
||||
"越权应用竟改动了建议状态(应保持 unapplied)"
|
||||
)
|
||||
|
||||
# 正对照:本账套建议 + 本账套 KPI → 200 且目标值已写入
|
||||
k2 = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == E2_KPI).first()
|
||||
s_ok = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id, target_value=4500.0)
|
||||
r_ok = client.post(f"{SUGG}/{s_ok}/apply", headers=auth_header(tok2),
|
||||
json={"action": "kpi_target", "target_value": 4500.0})
|
||||
assert r_ok.status_code == 200, f"本账套应用应 200,实际 {r_ok.status_code} {r_ok.text[:200]}"
|
||||
assert _kpi_target(db, k2.id) == 4500.0, "本账套应用未写入目标值"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T4. 应用 budget_adjust:目标 KPI 归属校验
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_apply_budget_adjust_foreign_kpi_404(client: TestClient, db: Session):
|
||||
"""budget_adjust 目标 KPI 属对端 → 404 且不落任何预算行(含对端账套)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0)
|
||||
_mk_kpi(db, E2_KPI, 2, target=3000.0)
|
||||
tok2 = _login(client, 2)
|
||||
s_cross = _mk_suggestion(client, tok2, E1_CROSS_TITLE, k1.id, k1.id, target_value=7777.0)
|
||||
|
||||
db.expire_all()
|
||||
plans_before = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).count()
|
||||
|
||||
r = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
|
||||
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 7777.0})
|
||||
assert r.status_code == 404, (
|
||||
f"budget_adjust 目标 KPI 属对端账套应 404,实际 {r.status_code} {r.text[:200]}"
|
||||
" —— _apply_budget_adjust 的 KPI 查询未校验归属会话账套"
|
||||
)
|
||||
db.expire_all()
|
||||
plans_after = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).count()
|
||||
assert plans_after == plans_before, (
|
||||
f"越权 budget_adjust 落了预算行:{plans_before} → {plans_after}"
|
||||
)
|
||||
assert _sug_status(db, s_cross) == "unapplied", "越权应用竟改动了建议状态"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T5. 鉴权底线(dismiss / apply 无 token → 401)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_dismiss_and_apply_without_token_401(client: TestClient, db: Session):
|
||||
"""无 token 一律 401(改前改后都应通过)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
tok1 = _login(client, 1)
|
||||
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
|
||||
|
||||
r1 = client.post(f"{SUGG}/{s1}/dismiss")
|
||||
assert r1.status_code == 401, f"dismiss 无 token 应 401,实际 {r1.status_code}"
|
||||
|
||||
r2 = client.post(f"{SUGG}/{s1}/apply", json={"action": "kpi_target", "target_value": 1})
|
||||
assert r2.status_code == 401, f"apply 无 token 应 401,实际 {r2.status_code}"
|
||||
@@ -2,7 +2,7 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
from tests.conftest import create_test_user, create_test_kpi, get_token_for_user, auth_header
|
||||
|
||||
|
||||
class TestNotificationChannels:
|
||||
@@ -149,16 +149,32 @@ class TestNotificationLogs:
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
def test_logs_with_data(self, client: TestClient, db: Session):
|
||||
"""有了日志记录后可查询"""
|
||||
from app.models import NotificationLog
|
||||
"""有了日志记录后可查询
|
||||
|
||||
2026-09-22(dama-workbench-entity-isolation-fullstack-20260922 §四 B2)行为变更:
|
||||
`notification_logs` 无 entity_id 列 → 改为**经父键 join 归属**
|
||||
(`alert_id → kpi_alerts.id → kpi_alerts.entity_id`)。
|
||||
故日志必须挂到**本账套真实存在**的 KPIAlert 上才可见;
|
||||
`alert_id IS NULL` 或指向不存在父键的孤儿行**不返回**(宁少勿错)。
|
||||
"""
|
||||
from app.models import NotificationLog, KPIAlert
|
||||
from datetime import datetime
|
||||
|
||||
# 直接插入日志
|
||||
log = NotificationLog(
|
||||
alert_id=1, channel="wecom", recipient="测试群",
|
||||
title="测试通知", content="测试内容", status="sent", sent_at=datetime.now(),
|
||||
)
|
||||
db.add(log)
|
||||
kpi = create_test_kpi(db, kpi_code="NOTIF_LOG_KPI", entity_id=1)
|
||||
alert = KPIAlert(entity_id=1, kpi_id=kpi.id, alert_level="yellow",
|
||||
alert_message="通知日志用例预警", status="pending")
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
db.add_all([
|
||||
NotificationLog(
|
||||
alert_id=alert.id, channel="wecom", recipient="测试群",
|
||||
title="测试通知", content="测试内容", status="sent", sent_at=datetime.now(),
|
||||
),
|
||||
# 孤儿行:无父键可归属 → 一律不返回
|
||||
NotificationLog(alert_id=None, channel="wecom", title="无归属通知", status="sent"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
create_test_user(db)
|
||||
@@ -166,8 +182,9 @@ class TestNotificationLogs:
|
||||
|
||||
resp = client.get("/api/cma/notifications/logs", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] >= 1
|
||||
assert len(resp.json()["data"]) >= 1
|
||||
assert resp.json()["total"] == 1, resp.json()
|
||||
assert len(resp.json()["data"]) == 1
|
||||
assert resp.json()["data"][0]["title"] == "测试通知"
|
||||
|
||||
|
||||
class TestAlertPush:
|
||||
|
||||
@@ -252,16 +252,27 @@ class TestGrowthQuality:
|
||||
assert "overall" in data or "level" in data or "score" in data
|
||||
|
||||
def test_growth_quality_entity2(self, client: TestClient, db: Session):
|
||||
"""博海增长质量(需绑定 entity2 的会话 token,鉴权收口后不再靠 body entity_id)"""
|
||||
"""博海增长质量:企业名取库内 Entity.name,且不采信请求体指标(账套隔离 2026-09-21)
|
||||
|
||||
契约变更:原断言硬编码 ENTITY_DATA 的 "陕西博海网络科技"(该硬编码兜底已按
|
||||
batch5 派单要求移除)→ 改为断言库内来源;无 kpi_values 时如实返回未采集。
|
||||
"""
|
||||
token2 = _token_for_entity(client, db, 2)
|
||||
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2},
|
||||
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2, "rebateRate": 99},
|
||||
headers=auth_header(token2))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "陕西博海网络科技"
|
||||
assert data["entity_id"] == 2
|
||||
assert data["entity_name"] == "企业2", "企业名应取库内 Entity.name,而非硬编码"
|
||||
assert data["collected"] is False
|
||||
assert data["overall"] is None, "库内无数据不得编造评分"
|
||||
|
||||
def test_growth_quality_custom(self, client: TestClient, db: Session):
|
||||
"""自定义企业数据(低分场景)"""
|
||||
"""自定义企业数据(低分场景)→ 契约变更:请求体指标不再被采信
|
||||
|
||||
原用例靠请求体 rebateRate/mgmtRatio 等打分得 danger;该回退已按 batch5 派单
|
||||
要求移除 → 改为断言请求体指标被忽略、无库内数据即未采集(不编造评分)。
|
||||
"""
|
||||
token99 = _token_for_entity(client, db, 99)
|
||||
resp = client.post(f"{BASE}/growth-quality", json={
|
||||
"entity_id": 99,
|
||||
@@ -271,8 +282,7 @@ class TestGrowthQuality:
|
||||
}, headers=auth_header(token99))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "测试企业"
|
||||
assert data["overall"] < 2
|
||||
assert data["level_type"] == "danger"
|
||||
# 低分诊断
|
||||
assert "越增长越重" in data["diagnosis"]
|
||||
assert data["entity_name"] == "企业99", "企业名取库内,不取请求体 name"
|
||||
assert data["collected"] is False
|
||||
assert data["overall"] is None and data["level_type"] is None
|
||||
assert data["dimensions"] == {} and data["diagnosis"] is None
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""CMA 跨实体隔离 — 读侧(只读端点)账套收敛缺口(2026-09-18 派生单)
|
||||
|
||||
任务:fullstack-20260918-2025-read-side-entity-scope-gaps
|
||||
父单:fullstack-20260918-2015-actionplan-entity-write-residual-sites
|
||||
(其范围外只读侦查登记的三处同族只读缺口)
|
||||
|
||||
缺陷(三处按 id 直取、未按会话账套限定):
|
||||
1. app/api/ai_suggestions.py::get_suggestion — GET /{suggestion_id} 无账套条件
|
||||
2. app/api/ai_suggestions.py::preview_suggestion — GET /{suggestion_id}/preview
|
||||
已有 entity_id 依赖但未用于过滤
|
||||
3. app/api/deviation_push.py::get_map_nodes — GET /map-nodes/{map_id}
|
||||
无账套校验且端点无 get_entity_id 依赖
|
||||
|
||||
后果:本账套会话带任意 id 即可读到对端账套的建议全文 / 应用前预览参数 /
|
||||
战略地图全部节点(维度·目标·KPI 编码)。对端记录口径 = 404「不存在」
|
||||
(对齐 alerts.py::create_action_plan_from_alert 与 1905b/2015 的写侧写法)。
|
||||
|
||||
本用例(改前失败条目见 _reports/evidence-<task>-red.txt):
|
||||
- T1 建议详情:对端账套 → 404 且响应体不含对端字段;本账套 → 200(双向正对照)
|
||||
- T2 应用前预览:对端账套 → 404 且不泄漏对端 KPI 名;本账套 → 200 字段完整(双向)
|
||||
- T3 地图节点:对端账套 → 404 且不泄漏对端目标名;本账套 → 200 节点正常(双向)
|
||||
- T4 鉴权底线:三处无 token → 401(改前改后都应通过)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Entity, KPIDefinition, StrategicMap
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
SUGG = "/api/cma/ai/suggestions"
|
||||
MAP_NODES = "/api/cma/deviation-push/map-nodes"
|
||||
|
||||
# kpi_code 全局唯一 → 每账套独立 code
|
||||
E1_KPI = "ZZ_RSG_E1_KPI"
|
||||
E2_KPI = "ZZ_RSG_E2_KPI"
|
||||
E1_MAP_KPI = "ZZ_RSG_E1_MAP_KPI"
|
||||
E2_MAP_KPI = "ZZ_RSG_E2_MAP_KPI"
|
||||
|
||||
E1_OBJ = "ZZ_RSG e1 目标"
|
||||
E2_OBJ = "ZZ_RSG e2 目标"
|
||||
E1_SUG_TITLE = "ZZ_RSG e1 私有建议"
|
||||
E2_SUG_TITLE = "ZZ_RSG e2 私有建议"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具(沿用 1905b/2015 同族风格:双向登录 + 同账套正对照)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status="active")
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int, target: float = 1000.0) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=target,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _mk_map(db: Session, eid: int, title: str, kpi_code: str, obj_name: str) -> StrategicMap:
|
||||
m = StrategicMap(
|
||||
entity_id=eid,
|
||||
title=title,
|
||||
version="v1.0",
|
||||
status="draft",
|
||||
dimensions=[{
|
||||
"key": "finance", "name": "财务维度", "icon": "F", "color": "#409eff",
|
||||
"objectives": [{"name": obj_name, "kpis": [kpi_code]}],
|
||||
}],
|
||||
canvas_data={"connections": []},
|
||||
)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "酣客")
|
||||
_ensure_entity(db, 2, "博海")
|
||||
create_test_user(db)
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _mk_suggestion(client: TestClient, token: str, kpi: KPIDefinition, title: str,
|
||||
target_value: float = 2000.0) -> int:
|
||||
body = {
|
||||
"suggestion_type": "kpi_target",
|
||||
"target_type": "kpi",
|
||||
"target_id": kpi.id,
|
||||
"title": title,
|
||||
"content": "目标偏低,建议上调",
|
||||
"suggestion_data": {"kpi_id": kpi.id, "target_value": target_value},
|
||||
}
|
||||
r = client.post(SUGG, json=body, headers=auth_header(token))
|
||||
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
|
||||
return r.json()["data"]["id"]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T1. 建议详情 GET /{suggestion_id}
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_suggestion_detail_scoped_to_session_entity(client: TestClient, db: Session):
|
||||
"""对端账套建议 → 404 且响应体不泄漏;本账套建议 → 200(双向)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
tok1 = _login(client, 1)
|
||||
tok2 = _login(client, 2)
|
||||
s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE)
|
||||
s2 = _mk_suggestion(client, tok2, k2, E2_SUG_TITLE)
|
||||
|
||||
# 方向 A:e2 会话读 e1 建议 → 404,响应体不得含 e1 字段
|
||||
r_a = client.get(f"{SUGG}/{s1}", headers=auth_header(tok2))
|
||||
assert r_a.status_code == 404, (
|
||||
f"e2 会话读 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}"
|
||||
" —— get_suggestion 按 id 直取、未按会话账套限定"
|
||||
)
|
||||
assert E1_SUG_TITLE not in r_a.text, "越权响应体泄漏了对端账套建议标题"
|
||||
|
||||
# 方向 B:e1 会话读 e2 建议 → 404
|
||||
r_b = client.get(f"{SUGG}/{s2}", headers=auth_header(tok1))
|
||||
assert r_b.status_code == 404, (
|
||||
f"e1 会话读 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}"
|
||||
)
|
||||
assert E2_SUG_TITLE not in r_b.text, "越权响应体泄漏了对端账套建议标题"
|
||||
|
||||
# 正对照:本账套读自己的建议正常
|
||||
r_ok1 = client.get(f"{SUGG}/{s1}", headers=auth_header(tok1))
|
||||
assert r_ok1.status_code == 200, f"e1 读自己的建议应 200,实际 {r_ok1.status_code}"
|
||||
assert r_ok1.json()["data"]["id"] == s1
|
||||
assert r_ok1.json()["data"]["title"] == E1_SUG_TITLE
|
||||
r_ok2 = client.get(f"{SUGG}/{s2}", headers=auth_header(tok2))
|
||||
assert r_ok2.status_code == 200, f"e2 读自己的建议应 200,实际 {r_ok2.status_code}"
|
||||
assert r_ok2.json()["data"]["title"] == E2_SUG_TITLE
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T2. 应用前预览 GET /{suggestion_id}/preview
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_suggestion_preview_scoped_to_session_entity(client: TestClient, db: Session):
|
||||
"""对端账套预览 → 404 且不泄漏对端 KPI 名;本账套预览 → 200 字段完整(双向)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2, target=3000.0)
|
||||
tok1 = _login(client, 1)
|
||||
tok2 = _login(client, 2)
|
||||
s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE, target_value=2500.0)
|
||||
s2 = _mk_suggestion(client, tok2, k2, E2_SUG_TITLE, target_value=4500.0)
|
||||
|
||||
# 方向 A:e2 预览 e1 建议 → 404 + 不泄漏 e1 KPI 名
|
||||
r_a = client.get(f"{SUGG}/{s1}/preview", headers=auth_header(tok2))
|
||||
assert r_a.status_code == 404, (
|
||||
f"e2 预览 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}"
|
||||
" —— preview_suggestion 有 entity_id 依赖但未用于过滤"
|
||||
)
|
||||
assert str(k1.kpi_name) not in r_a.text, "越权预览泄漏了对端账套 KPI 名"
|
||||
|
||||
# 方向 B:e1 预览 e2 建议 → 404
|
||||
r_b = client.get(f"{SUGG}/{s2}/preview", headers=auth_header(tok1))
|
||||
assert r_b.status_code == 404, (
|
||||
f"e1 预览 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}"
|
||||
)
|
||||
assert str(k2.kpi_name) not in r_b.text, "越权预览泄漏了对端账套 KPI 名"
|
||||
|
||||
# 正对照:本账套预览自己的建议,字段完整
|
||||
r_ok = client.get(f"{SUGG}/{s1}/preview", headers=auth_header(tok1))
|
||||
assert r_ok.status_code == 200, f"e1 预览自己的建议应 200,实际 {r_ok.status_code}"
|
||||
data = r_ok.json()["data"]
|
||||
assert data["type"] == "kpi_target", f"预览类型异常: {data}"
|
||||
assert data["kpi_name"] == k1.kpi_name, f"预览 KPI 名异常: {data}"
|
||||
assert data["current_target"] == 1000.0, f"预览当前值异常: {data}"
|
||||
assert data["new_target"] == 2500.0, f"预览新值异常: {data}"
|
||||
|
||||
r_ok2 = client.get(f"{SUGG}/{s2}/preview", headers=auth_header(tok2))
|
||||
assert r_ok2.status_code == 200, f"e2 预览自己的建议应 200,实际 {r_ok2.status_code}"
|
||||
assert r_ok2.json()["data"]["new_target"] == 4500.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T3. 战略地图节点 GET /map-nodes/{map_id}
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_map_nodes_scoped_to_session_entity(client: TestClient, db: Session):
|
||||
"""对端账套地图节点 → 404 且不泄漏对端目标名;本账套 → 200 节点正常(双向)"""
|
||||
_seed_two_entities(db)
|
||||
_mk_kpi(db, E1_MAP_KPI, 1)
|
||||
_mk_kpi(db, E2_MAP_KPI, 2)
|
||||
m1 = _mk_map(db, 1, "ZZ_RSG e1 地图", E1_MAP_KPI, E1_OBJ)
|
||||
m2 = _mk_map(db, 2, "ZZ_RSG e2 地图", E2_MAP_KPI, E2_OBJ)
|
||||
tok1 = _login(client, 1)
|
||||
tok2 = _login(client, 2)
|
||||
|
||||
# 方向 A:e2 读 e1 地图节点 → 404 + 不泄漏对端目标名
|
||||
r_a = client.get(f"{MAP_NODES}/{m1.id}", headers=auth_header(tok2))
|
||||
assert r_a.status_code == 404, (
|
||||
f"e2 会话读 e1 地图节点应 404,实际 {r_a.status_code} {r_a.text[:200]}"
|
||||
" —— get_map_nodes 无账套校验、端点无 get_entity_id 依赖"
|
||||
)
|
||||
assert E1_OBJ not in r_a.text, "越权响应体泄漏了对端账套目标名"
|
||||
assert E1_MAP_KPI not in r_a.text, "越权响应体泄漏了对端账套 KPI 编码"
|
||||
|
||||
# 方向 B:e1 读 e2 地图节点 → 404
|
||||
r_b = client.get(f"{MAP_NODES}/{m2.id}", headers=auth_header(tok1))
|
||||
assert r_b.status_code == 404, (
|
||||
f"e1 会话读 e2 地图节点应 404,实际 {r_b.status_code} {r_b.text[:200]}"
|
||||
)
|
||||
assert E2_OBJ not in r_b.text, "越权响应体泄漏了对端账套目标名"
|
||||
|
||||
# 正对照:本账套读自己的地图节点正常
|
||||
r_ok = client.get(f"{MAP_NODES}/{m1.id}", headers=auth_header(tok1))
|
||||
assert r_ok.status_code == 200, f"e1 读自己的地图节点应 200,实际 {r_ok.status_code}"
|
||||
nodes = r_ok.json()["data"]
|
||||
assert len(nodes) == 1, f"节点数异常: {nodes}"
|
||||
assert nodes[0]["objective_name"] == E1_OBJ, f"节点目标名异常: {nodes}"
|
||||
assert nodes[0]["kpi_codes"] == [E1_MAP_KPI], f"节点 KPI 编码异常: {nodes}"
|
||||
|
||||
r_ok2 = client.get(f"{MAP_NODES}/{m2.id}", headers=auth_header(tok2))
|
||||
assert r_ok2.status_code == 200, f"e2 读自己的地图节点应 200,实际 {r_ok2.status_code}"
|
||||
assert r_ok2.json()["data"][0]["objective_name"] == E2_OBJ
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# T4. 鉴权底线(三处无 token → 401)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def test_read_side_endpoints_without_token_401(client: TestClient, db: Session):
|
||||
"""三处只读端点无 token 一律 401(改前改后都应通过)"""
|
||||
_seed_two_entities(db)
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
m1 = _mk_map(db, 1, "ZZ_RSG e1 鉴权地图", E1_KPI, E1_OBJ)
|
||||
tok1 = _login(client, 1)
|
||||
s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE)
|
||||
|
||||
r1 = client.get(f"{SUGG}/{s1}")
|
||||
assert r1.status_code == 401, f"建议详情无 token 应 401,实际 {r1.status_code}"
|
||||
|
||||
r2 = client.get(f"{SUGG}/{s1}/preview")
|
||||
assert r2.status_code == 401, f"建议预览无 token 应 401,实际 {r2.status_code}"
|
||||
|
||||
r3 = client.get(f"{MAP_NODES}/{m1.id}")
|
||||
assert r3.status_code == 401, f"地图节点无 token 应 401,实际 {r3.status_code}"
|
||||
@@ -0,0 +1,109 @@
|
||||
"""测试期生产 Redis 隔离 —— 回归断言(project-20260918-P1-test-redis-token-fix)
|
||||
|
||||
背景:测试套件通过真实 /api/cma/auth/login 签发 token,而 app.auth_middleware 在 import 时
|
||||
连接 127.0.0.1:6379 db=1(生产 Redis)、ping 成功即 _redis_available=True → create_token()
|
||||
把 token setex 进生产库(TTL 24h)。测试库首个用户 id=1 与生产 users.id=1 撞号,且
|
||||
require_auth() 只按 user_id 反查用户,故每轮全量测试都会留下可直接使用的生产会话 token。
|
||||
|
||||
本文件断言隔离生效(可复现、无副作用):
|
||||
1) 测试期 auth_middleware / utils.cache 的 Redis 通道均关闭(客户端对象为 None);
|
||||
2) 生产 Redis 客户端构造被 conftest 阻断器全部拦下(计数 ≥ 2:auth_middleware + cache);
|
||||
3) 登录签发的 token 只落进程内 _token_store,且经 require_auth 仍可正常鉴权(语义不变)。
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app import auth_middleware
|
||||
from app.utils import cache as cache_util
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
|
||||
def test_auth_middleware_redis_channel_is_off_in_tests():
|
||||
"""测试期 auth_middleware 必须走进程内 _token_store 降级分支(生产 Redis 零写入的前提)"""
|
||||
assert auth_middleware._redis_available is False
|
||||
assert auth_middleware._redis is None
|
||||
|
||||
|
||||
def test_cache_redis_channel_is_off_in_tests():
|
||||
"""测试夹具会调 cache.delete('ai'),同源 db=1 —— 一并关闭,保证整轮零写命令"""
|
||||
assert cache_util._available is False
|
||||
assert cache_util._client is None
|
||||
|
||||
|
||||
def test_production_redis_constructor_is_blocked(production_redis_isolation_state):
|
||||
"""redis.Redis 构造器被阻断器替换:导入期的两次构造尝试全部被拒(无生产连接)"""
|
||||
state = production_redis_isolation_state
|
||||
assert state["constructor_attempts"] >= 2, state
|
||||
# 阻断即无客户端实例 → 不存在可用的生产 Redis 通道
|
||||
assert auth_middleware._redis is None
|
||||
assert cache_util._client is None
|
||||
|
||||
|
||||
def test_login_token_lands_in_process_store_only(db, client):
|
||||
"""登录 token 只落进程内 _token_store(不产生任何生产 Redis 键)"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
assert token and len(token) == 64
|
||||
assert token in auth_middleware._token_store
|
||||
assert json.loads(auth_middleware._token_store[token]) == {
|
||||
"user_id": user.id,
|
||||
"entity_id": 1,
|
||||
}
|
||||
# token 只能从进程内存储解析(Redis 通道关闭)
|
||||
assert auth_middleware._resolve_token_data(token) == {"user_id": user.id, "entity_id": 1}
|
||||
assert auth_middleware._resolve_user_id(token) == user.id
|
||||
|
||||
|
||||
def test_process_store_token_still_authenticates(db, client):
|
||||
"""降级分支不影响鉴权语义:进程内 token 经 require_auth 正常通过(/me 200)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/auth/me", headers=auth_header(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["username"] == "testadmin"
|
||||
assert body["role"] == "ceo"
|
||||
assert body["entity_id"] == 1
|
||||
|
||||
# 无效 token 仍按原语义 401(隔离未放宽鉴权)
|
||||
assert client.get("/api/cma/auth/me", headers=auth_header("0" * 64)).status_code == 401
|
||||
|
||||
|
||||
def test_create_token_writes_process_store_not_redis(db):
|
||||
"""create_token() 直接调用:写入进程内存储、解析一致、Redis 侧无客户端可写"""
|
||||
user = create_test_user(db, username="redis_iso_user")
|
||||
|
||||
token = auth_middleware.create_token(user.id, 1)
|
||||
assert token in auth_middleware._token_store
|
||||
assert auth_middleware.get_token_entity_id(token) == 1
|
||||
assert auth_middleware._redis is None, "测试期不应存在生产 Redis 客户端实例"
|
||||
|
||||
|
||||
def test_token_store_is_process_local(db, client):
|
||||
"""token 仅存于本进程:模拟另一个进程/worker 的独立存储看不到它(跨 worker 不可见即无共享 Redis)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
fresh_store: dict = {} # 模拟另一个 worker 的独立进程内存储
|
||||
assert token not in fresh_store
|
||||
assert token in auth_middleware._token_store # 本进程可见
|
||||
# 有 Redis 通道时 token 会在生产库跨 worker 共享 —— 这正是被本单关闭的路径
|
||||
assert auth_middleware._redis is None
|
||||
|
||||
|
||||
def test_production_redis_boundary_invariant():
|
||||
"""不变量:只要 _redis_available 为 False,create_token 不可能触达 Redis(唯一写入分支)"""
|
||||
assert auth_middleware._redis_available is False
|
||||
# 观察既有代码结构:写入分支受 _redis_available 单点控制(本断言防未来改动绕开开关)
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(auth_middleware.create_token)
|
||||
assert "if _redis_available:" in src
|
||||
assert src.count("_redis.setex(") == 1
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
@@ -222,9 +222,8 @@ class TestDashboardDemo:
|
||||
data = resp.json()
|
||||
assert "burden" in data and "invoice" in data and "ss" in data
|
||||
|
||||
@pytest.mark.xfail(reason="SQLite DateTime 不接受字符串日期(生产MySQL可隐式转换);demo-data 为MySQL-only端点", strict=False)
|
||||
def test_demo_data(self, client: TestClient, db: Session):
|
||||
"""生成演示数据(MySQL-only:demo日期为字符串,SQLite DateTime严格模式不兼容)"""
|
||||
"""生成演示数据(batch5 起 invoice_date 经 _parse_date,SQLite 亦可跑通)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/demo-data", headers=auth_header(token), json={})
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
"""工作台跨实体污染 · 治本回归测试 — 2026-09-22
|
||||
|
||||
任务:`dama-workbench-entity-isolation-fullstack-20260922`
|
||||
(DAMA 诊断 / 项目Bot batch5 §B 方案段 / 任总 2026-09-22「都批」)
|
||||
|
||||
事故:任总 2026-09-22 报「陕西酣客工作台大量显示陕西博海数据」。
|
||||
根因族 = `system_configs` / `role_permissions` 的**全局唯一键**没有账套维度
|
||||
(`config_key` / `key` 均 unique)→ 一次写入全平台生效。
|
||||
|
||||
本单五项(逐条对应一个测试类):
|
||||
|
||||
P0 前端取数修正 — 工作台取数按**会话 entity**,不得用 `current_tenant` 全局键
|
||||
(含 current_tenant 收敛为「仅 it/ceo 可读」的项目Bot A2A 状态记录)
|
||||
B1 `cash.current_balance` → `cash.current_balance.<entity_id>`(零 DDL)
|
||||
B3a `alignment_config` → `alignment_config.<entity_id>`(零 DDL)
|
||||
B3b `bot_bridge_v2` 预警 KPI 归属:删 `kpi_id or 1` 兜底,改显式校验(跳过错挂 + 留痕)
|
||||
B2 `notification_logs` 经父键 join 归属(→ kpi_alerts.entity_id);零迁移
|
||||
|
||||
外加:迁移脚本 `scripts/migrate_cash_balance_key_entity_20260922.py` 的
|
||||
check/apply/down 幂等与「原值只落目标账套、其余留空」口径。
|
||||
|
||||
全部用例只跑内存 SQLite / 临时文件 SQLite(tests/conftest.py 机制),
|
||||
**零生产库 / 零生产 Redis 连接**(conftest 已硬阻断)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
Entity,
|
||||
KPIDefinition,
|
||||
KPIAlert,
|
||||
KPIValue,
|
||||
NotificationLog,
|
||||
OperationLog,
|
||||
RolePermission,
|
||||
SystemConfig,
|
||||
)
|
||||
from app.database import Base
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
DASH = "/api/cma/dashboard"
|
||||
CASH = "/api/cma/cash"
|
||||
ALIGN = "/api/cma/alignment"
|
||||
NOTIF = "/api/cma/notifications"
|
||||
TENANT = "/api/cma/tenant"
|
||||
|
||||
# 两账套的资源编码(kpi_code 库内唯一)
|
||||
E1_KPI = "ZZ_WB_E1_KPI"
|
||||
E2_KPI = "ZZ_WB_E2_KPI"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str, status: str = "active") -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status=status)
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _mk_kpi(db: Session, code: str, eid: int, **kw) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
entity_id=eid,
|
||||
kpi_code=code,
|
||||
kpi_name=f"{code}-名称",
|
||||
dimension="finance",
|
||||
category="revenue_growth",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
status="active",
|
||||
)
|
||||
kpi.__dict__.update(kw)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _login(client: TestClient, entity_id: int) -> str:
|
||||
return _login_as(client, "testadmin", entity_id)
|
||||
|
||||
|
||||
def _login_as(client: TestClient, username: str, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": username,
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 {username}@entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
"""两账套各一个 active KPI(双会话双实体互不串的最小底座)"""
|
||||
_ensure_entity(db, 1, "陕西酣客文化传媒")
|
||||
_ensure_entity(db, 2, "陕西博海网络科技")
|
||||
k1 = _mk_kpi(db, E1_KPI, 1)
|
||||
k2 = _mk_kpi(db, E2_KPI, 2)
|
||||
# 各写一个最新值(便于断言「实际值」口径也隔离)
|
||||
for k, v in ((k1, 111.0), (k2, 222.0)):
|
||||
db.add(KPIValue(kpi_id=k.id, period="2026-09", actual_value=v,
|
||||
source_type="test", data_status="verified"))
|
||||
db.commit()
|
||||
return k1, k2
|
||||
|
||||
|
||||
def _write_current_tenant(db: Session, entity_id: int, name: str) -> None:
|
||||
"""模拟事故现场:把全局键 current_tenant 写成某个账套"""
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "current_tenant").first()
|
||||
payload = json.dumps({"tenant_id": f"company_{entity_id}", "entity_id": entity_id,
|
||||
"name": name, "short_name": name,
|
||||
"source": "test-polluter",
|
||||
"switched_at": "2026-09-04T09:28:38"}, ensure_ascii=False)
|
||||
if cfg:
|
||||
cfg.config_value = payload
|
||||
else:
|
||||
db.add(SystemConfig(config_key="current_tenant", config_value=payload,
|
||||
description="当前租户状态记录"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# P0 前端取数修正:工作台按会话 entity,全局键不得当数据维度
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
class TestP0WorkbenchSessionEntity:
|
||||
"""事故直接触发点:工作台取数必须用**会话 entity**,与 current_tenant 全局键无关
|
||||
|
||||
「双会话双实体互不串」= 同一进程内两个独立 Bearer token(e1 / e2),
|
||||
即使全局键被写成对方账套,各自仍只看到本账套数据。
|
||||
"""
|
||||
|
||||
def test_my_kpis_isolated_while_global_tenant_key_points_elsewhere(
|
||||
self, client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db) # role=ceo
|
||||
_write_current_tenant(db, 2, "陕西博海网络科技") # 事故现场:全局键=博海
|
||||
|
||||
t1 = _login(client, 1)
|
||||
t2 = _login(client, 2)
|
||||
|
||||
r1 = client.get(f"{DASH}/my-kpis", headers=auth_header(t1))
|
||||
assert r1.status_code == 200, r1.text
|
||||
codes1 = {k["kpi_code"] for k in r1.json()["data"]}
|
||||
assert E1_KPI in codes1 and E2_KPI not in codes1, \
|
||||
f"酣客(e1)会话不应看到博海 KPI(全局键指向 e2 时也不许):{sorted(codes1)}"
|
||||
|
||||
r2 = client.get(f"{DASH}/my-kpis", headers=auth_header(t2))
|
||||
assert r2.status_code == 200, r2.text
|
||||
codes2 = {k["kpi_code"] for k in r2.json()["data"]}
|
||||
assert E2_KPI in codes2 and E1_KPI not in codes2, \
|
||||
f"博海(e2)会话不应看到酣客 KPI:{sorted(codes2)}"
|
||||
|
||||
# 反转全局键 → 两会话结果必须**不变**(证明取数不依赖全局键)
|
||||
_write_current_tenant(db, 1, "陕西酣客文化传媒")
|
||||
codes1b = {k["kpi_code"] for k in
|
||||
client.get(f"{DASH}/my-kpis", headers=auth_header(t1)).json()["data"]}
|
||||
codes2b = {k["kpi_code"] for k in
|
||||
client.get(f"{DASH}/my-kpis", headers=auth_header(t2)).json()["data"]}
|
||||
assert codes1b == codes1, "全局键翻转后 e1 工作台数据变了 ⇒ 取数依赖了全局键"
|
||||
assert codes2b == codes2, "全局键翻转后 e2 工作台数据变了 ⇒ 取数依赖了全局键"
|
||||
|
||||
def test_my_dashboard_and_summary_scoped_by_session(
|
||||
self, client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
_write_current_tenant(db, 2, "陕西博海网络科技")
|
||||
|
||||
t1 = _login(client, 1)
|
||||
t2 = _login(client, 2)
|
||||
|
||||
d1 = client.get(f"{DASH}/my-dashboard", headers=auth_header(t1))
|
||||
d2 = client.get(f"{DASH}/my-dashboard", headers=auth_header(t2))
|
||||
assert d1.status_code == 200 and d2.status_code == 200
|
||||
codes1 = {k["kpi_code"] for k in d1.json()["kpis"]}
|
||||
codes2 = {k["kpi_code"] for k in d2.json()["kpis"]}
|
||||
assert not (codes1 & codes2), f"两会话工作台 KPI 出现交集:{codes1 & codes2}"
|
||||
|
||||
s1 = client.get(f"{DASH}/summary", headers=auth_header(t1))
|
||||
s2 = client.get(f"{DASH}/summary", headers=auth_header(t2))
|
||||
assert s1.status_code == 200 and s2.status_code == 200
|
||||
|
||||
def test_workbench_endpoints_all_use_session_entity(self, client: TestClient, db: Session):
|
||||
"""工作台类端点逐个复核:带 token 时按 token 账套取数(不看全局键)"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
_write_current_tenant(db, 2, "陕西博海网络科技")
|
||||
t1 = _login(client, 1)
|
||||
for path in (f"{DASH}/summary", f"{DASH}/kpis", f"{DASH}/my-kpis",
|
||||
f"{DASH}/my-dashboard"):
|
||||
r = client.get(path, headers=auth_header(t1))
|
||||
assert r.status_code == 200, f"{path} 期望 200,实际 {r.status_code}: {r.text[:200]}"
|
||||
# 显式传他账套 entity_id → 403(会话账套为准)
|
||||
r = client.get(f"{DASH}/my-kpis?entity_id=2", headers=auth_header(t1))
|
||||
assert r.status_code == 403, r.text
|
||||
assert "无权访问" in r.json()["detail"]
|
||||
|
||||
|
||||
#: 工作台类端点矩阵(§二.3「前端/接口两侧都要复核」的接口侧证据)
|
||||
WORKBENCH_ENDPOINTS = [
|
||||
"/api/cma/dashboard/summary",
|
||||
"/api/cma/dashboard/kpis",
|
||||
"/api/cma/dashboard/my-kpis",
|
||||
"/api/cma/dashboard/my-dashboard",
|
||||
"/api/cma/dashboard/finance-analysis",
|
||||
"/api/cma/dashboard/predict",
|
||||
"/api/cma/kpis?page_size=5",
|
||||
"/api/cma/alerts",
|
||||
"/api/cma/cash/dashboard",
|
||||
"/api/cma/cash/balance",
|
||||
"/api/cma/budget/plans",
|
||||
"/api/cma/budget/config",
|
||||
"/api/cma/expenses/reimbursements",
|
||||
"/api/cma/expenses/stats",
|
||||
"/api/cma/alignment/config",
|
||||
"/api/cma/alignment/tree",
|
||||
"/api/cma/notifications/logs",
|
||||
"/api/cma/data-quality/logs",
|
||||
]
|
||||
|
||||
|
||||
class TestP0WorkbenchEndpointMatrix:
|
||||
"""工作台类端点「双会话双实体」矩阵复核:任一端点都不得把对方账套数据带出来
|
||||
|
||||
方法论(可复算):两个独立 Bearer token(e1 / e2)逐个打同一端点,
|
||||
断言 ①两会话状态码一致(不得出现「一方 200 一方 403」的不对称)
|
||||
②任一响应体里**不含**对方账套的独有标记(kpi_code)。
|
||||
同时把三个全局键都写成 e2(模拟事故现场),证明取数与全局键无关。
|
||||
"""
|
||||
|
||||
def test_no_cross_entity_marker_in_any_workbench_endpoint(
|
||||
self, client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
_write_current_tenant(db, 2, "陕西博海网络科技")
|
||||
db.add(SystemConfig(config_key="cash.current_balance", config_value="-9.3",
|
||||
description="历史全局键(事故值)"))
|
||||
db.commit()
|
||||
|
||||
t1 = _login(client, 1)
|
||||
t2 = _login(client, 2)
|
||||
report = []
|
||||
violations = []
|
||||
for path in WORKBENCH_ENDPOINTS:
|
||||
r1 = client.get(path, headers=auth_header(t1))
|
||||
r2 = client.get(path, headers=auth_header(t2))
|
||||
report.append(f"{path} → e1:{r1.status_code} e2:{r2.status_code}")
|
||||
if r1.status_code != r2.status_code:
|
||||
violations.append(f"{path} 状态码不对称 e1={r1.status_code} e2={r2.status_code}")
|
||||
if E2_KPI in r1.text:
|
||||
violations.append(f"{path} e1 会话响应含 e2 标记 {E2_KPI}")
|
||||
if E1_KPI in r2.text:
|
||||
violations.append(f"{path} e2 会话响应含 e1 标记 {E1_KPI}")
|
||||
print("\n[工作台端点矩阵]\n" + "\n".join(report))
|
||||
assert not violations, "跨实体泄漏/不对称:\n" + "\n".join(violations)
|
||||
|
||||
|
||||
class TestP0CurrentTenantConvergence:
|
||||
|
||||
def test_no_token_401(self, client: TestClient, db: Session):
|
||||
assert client.get(f"{TENANT}/current").status_code == 401
|
||||
|
||||
def test_non_admin_role_403(self, client: TestClient, db: Session):
|
||||
_ensure_entity(db, 1, "测试企业")
|
||||
create_test_user(db) # testadmin / ceo
|
||||
create_test_user(db, username="fin_user", role="finance")
|
||||
tk_fin = _login_as(client, "fin_user", 1)
|
||||
tk_ceo = _login(client, 1)
|
||||
r = client.get(f"{TENANT}/current", headers=auth_header(tk_fin))
|
||||
assert r.status_code == 403, f"finance 会话读 current_tenant 应 403,实际 {r.status_code}"
|
||||
# ceo(业务负责人)与 it(admin 动作权限持有者)放行
|
||||
assert client.get(f"{TENANT}/current", headers=auth_header(tk_ceo)).status_code == 200
|
||||
|
||||
def test_switch_writes_audit_log_and_is_not_a_data_dimension(
|
||||
self, client: TestClient, db: Session):
|
||||
"""POST /switch 落 operation_logs(修复前无审计);写入后不影响他账套取数"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
t1 = _login(client, 1)
|
||||
r = client.post(f"{TENANT}/switch", headers=auth_header(t1),
|
||||
json={"entity_id": 2, "source": "test"})
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
n = db.query(OperationLog).filter(OperationLog.action == "switch_tenant_state").count()
|
||||
assert n == 1, f"switch 应落 1 条审计,实际 {n}"
|
||||
# 审计行必须可归属(2026-09-22 DAMA 独立回查 §4b):操作人 + 账套目标维度
|
||||
log = (db.query(OperationLog)
|
||||
.filter(OperationLog.action == "switch_tenant_state")
|
||||
.order_by(OperationLog.id.desc()).first())
|
||||
assert log.user_id is not None, "审计行 user_id 不得为 None(事后须能追操作人)"
|
||||
assert log.target_type == "entity" and log.target_id == 2, (
|
||||
f"审计须按账套可过滤(target_type/target_id),实际 {log.target_type}/{log.target_id}")
|
||||
assert (log.detail or {}).get("entity_id") == 2, f"detail 须含 entity_id:{log.detail}"
|
||||
# 全局键虽被改成 e2,e1 会话工作台仍只见 e1 数据
|
||||
codes1 = {k["kpi_code"] for k in
|
||||
client.get(f"{DASH}/my-kpis", headers=auth_header(t1)).json()["data"]}
|
||||
assert E2_KPI not in codes1
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# B1 现金基线拆账套
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
class TestB1CashBalancePerEntity:
|
||||
|
||||
def test_two_entities_do_not_overwrite_each_other(self, client: TestClient, db: Session):
|
||||
"""e1 / e2 分别设基线后各自读回本账套值(修复前共用一个唯一键)"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
t1 = _login(client, 1)
|
||||
t2 = _login(client, 2)
|
||||
|
||||
assert client.post(f"{CASH}/balance", headers=auth_header(t1),
|
||||
json={"current_cash": 11.5}).status_code == 200
|
||||
assert client.post(f"{CASH}/balance", headers=auth_header(t2),
|
||||
json={"current_cash": 22.5}).status_code == 200
|
||||
|
||||
v1 = client.get(f"{CASH}/balance", headers=auth_header(t1)).json()
|
||||
v2 = client.get(f"{CASH}/balance", headers=auth_header(t2)).json()
|
||||
assert v1["current_cash"] == 11.5, f"e1 基线被覆盖:{v1}"
|
||||
assert v2["current_cash"] == 22.5, f"e2 基线被覆盖:{v2}"
|
||||
assert v1["entity_id"] == 1 and v2["entity_id"] == 2
|
||||
|
||||
db.expire_all()
|
||||
keys = {r[0] for r in db.query(SystemConfig.config_key)
|
||||
.filter(SystemConfig.config_key.like("cash.current_balance%")).all()}
|
||||
assert keys == {"cash.current_balance.1", "cash.current_balance.2"}, keys
|
||||
# 不再写全局唯一键
|
||||
assert "cash.current_balance" not in keys
|
||||
|
||||
def test_no_cross_entity_fallback_to_legacy_key(self, client: TestClient, db: Session):
|
||||
"""旧全局键有值(事故值 -9.3),本账套未设基线 → 返回 None,**不借用**他账套值"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
db.add(SystemConfig(config_key="cash.current_balance", config_value="-9.3",
|
||||
description="历史全局键(事故值)"))
|
||||
db.commit()
|
||||
t2 = _login(client, 2)
|
||||
body = client.get(f"{CASH}/balance", headers=auth_header(t2)).json()
|
||||
assert body["current_cash"] is None, \
|
||||
f"e2 未设基线却读到旧全局键(跨账套借用):{body}"
|
||||
|
||||
def test_body_entity_id_not_trusted(self, client: TestClient, db: Session):
|
||||
"""带 token 时 body 里的 entity_id 不可信(仍写会话账套)"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
t1 = _login(client, 1)
|
||||
r = client.post(f"{CASH}/balance", headers=auth_header(t1),
|
||||
json={"current_cash": 33.0, "entity_id": 2})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["entity_id"] == 1, r.json()
|
||||
db.expire_all()
|
||||
assert db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == "cash.current_balance.2").first() is None
|
||||
assert db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == "cash.current_balance.1").first() is not None
|
||||
|
||||
def test_set_helper_requires_entity(self, db: Session):
|
||||
"""set_current_cash_balance 必须传 entity_id(防漏传落默认账套)"""
|
||||
from app.utils.cash_forecast_engine import set_current_cash_balance
|
||||
with pytest.raises(TypeError):
|
||||
set_current_cash_balance(db, 1.0) # 缺 entity_id
|
||||
with pytest.raises(ValueError):
|
||||
set_current_cash_balance(db, 1.0, None)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# B3(a) 对齐模式拆账套
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
class TestB3aAlignmentPerEntity:
|
||||
|
||||
def test_config_isolated_per_entity(self, client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
t1 = _login(client, 1)
|
||||
t2 = _login(client, 2)
|
||||
|
||||
assert client.post(f"{ALIGN}/config", headers=auth_header(t1),
|
||||
json={"mode": "vertical_decomposition"}).status_code == 200
|
||||
assert client.post(f"{ALIGN}/config", headers=auth_header(t2),
|
||||
json={"mode": "bsc_chain"}).status_code == 200
|
||||
|
||||
c1 = client.get(f"{ALIGN}/config", headers=auth_header(t1)).json()
|
||||
c2 = client.get(f"{ALIGN}/config", headers=auth_header(t2)).json()
|
||||
assert c1["mode"]["mode"] == "vertical_decomposition", c1
|
||||
assert c2["mode"]["mode"] == "bsc_chain", c2
|
||||
assert c1["entity_id"] == 1 and c2["entity_id"] == 2
|
||||
|
||||
db.expire_all()
|
||||
keys = {r[0] for r in db.query(RolePermission.key)
|
||||
.filter(RolePermission.key.like("alignment_config%")).all()}
|
||||
assert keys == {"alignment_config.1", "alignment_config.2"}, keys
|
||||
assert "alignment_config" not in keys
|
||||
|
||||
def test_unconfigured_entity_not_inheriting_other_entity(self, client: TestClient, db: Session):
|
||||
"""e1 已配置、e2 未配置 → e2 不得继承 e1 的模式(修复前全局键必然继承)"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
t1 = _login(client, 1)
|
||||
t2 = _login(client, 2)
|
||||
assert client.post(f"{ALIGN}/config", headers=auth_header(t1),
|
||||
json={"mode": "bsc_chain"}).status_code == 200
|
||||
|
||||
c2 = client.get(f"{ALIGN}/config", headers=auth_header(t2)).json()
|
||||
assert c2["configured"] is False and c2["mode"] is None, \
|
||||
f"e2 未配置却继承了 e1 的对齐模式:{c2}"
|
||||
# e2 的 /tree 应提示先初始化(400),而不是拿到 e1 的模式
|
||||
r = client.get(f"{ALIGN}/tree", headers=auth_header(t2))
|
||||
assert r.status_code == 400, f"e2 未配置应 400,实际 {r.status_code}: {r.text[:200]}"
|
||||
# e1 正常
|
||||
assert client.get(f"{ALIGN}/tree", headers=auth_header(t1)).status_code == 200
|
||||
|
||||
def test_tree_uses_own_entity_mode(self, client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
t1 = _login(client, 1)
|
||||
t2 = _login(client, 2)
|
||||
client.post(f"{ALIGN}/config", headers=auth_header(t1),
|
||||
json={"mode": "vertical_decomposition"})
|
||||
client.post(f"{ALIGN}/config", headers=auth_header(t2), json={"mode": "bsc_chain"})
|
||||
b1 = client.get(f"{ALIGN}/tree", headers=auth_header(t1)).json()
|
||||
b2 = client.get(f"{ALIGN}/tree", headers=auth_header(t2)).json()
|
||||
assert b1["mode"] == "vertical_decomposition"
|
||||
assert b2["mode"] == "bsc_chain"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# B3(b) 预警 KPI 归属:删 kpi_id or 1 兜底
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
class TestB3bAlertKpiAttribution:
|
||||
|
||||
def test_missing_kpi_definition_skips_alert_instead_of_kpi_id_1(self, db: Session):
|
||||
"""e2 无 C_REBATE_RATE 定义时:不建预警(不挂 kpi_id=1)+ 落 OperationLog 留痕"""
|
||||
from app.api.bot_bridge_v2 import receive_mpm_result
|
||||
from app.models import MpmResult
|
||||
|
||||
_ensure_entity(db, 1, "陕西酣客文化传媒")
|
||||
_ensure_entity(db, 2, "陕西博海网络科技")
|
||||
first = _mk_kpi(db, "ZZ_WB_FIRST_KPI", 1) # 全库第 1 个 KPI(旧兜底目标)
|
||||
assert first.id == 1, f"夹具前提:第一个 KPI id 应为 1,实际 {first.id}"
|
||||
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
|
||||
before_alerts = db.query(KPIAlert).count()
|
||||
before_logs = db.query(OperationLog).count()
|
||||
|
||||
r = receive_mpm_result(
|
||||
data={"entity_id": 2, "period": "2026-09",
|
||||
"results": {"channel_rebate_rate": 80.0}},
|
||||
bridge_bot="test-bridge", db=db)
|
||||
db.expire_all()
|
||||
|
||||
assert r["alerts_triggered"] == 0, r
|
||||
assert r["alerts_skipped_missing_kpi"] == 1, r
|
||||
assert db.query(KPIAlert).count() == before_alerts, "KPI 定义缺失不应建预警"
|
||||
assert db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == 2, KPIAlert.kpi_id == 1).first() is None, \
|
||||
"不得把 e2 的预警错挂到 kpi_id=1(可能是他账套的 KPI)"
|
||||
logs = db.query(OperationLog).filter(OperationLog.action == "skip_alert").all()
|
||||
assert len(logs) == 1 and db.query(OperationLog).count() == before_logs + 1
|
||||
assert logs[0].detail["reason"] == "kpi_definition_not_found_for_entity"
|
||||
assert logs[0].detail["entity_id"] == 2
|
||||
|
||||
def test_present_kpi_definition_still_creates_alert(self, db: Session):
|
||||
"""正对照:本账套有 KPI 定义 → 照常建预警且归属正确"""
|
||||
from app.api.bot_bridge_v2 import receive_mpm_result
|
||||
from app.models import MpmResult
|
||||
|
||||
_ensure_entity(db, 1, "陕西酣客文化传媒")
|
||||
k = _mk_kpi(db, "C_REBATE_RATE", 1)
|
||||
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
|
||||
r = receive_mpm_result(
|
||||
data={"entity_id": 1, "period": "2026-09",
|
||||
"results": {"channel_rebate_rate": 80.0}},
|
||||
bridge_bot="test-bridge", db=db)
|
||||
db.expire_all()
|
||||
assert r["alerts_triggered"] == 1, r
|
||||
assert r["alerts_skipped_missing_kpi"] == 0
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.entity_id == 1).first()
|
||||
assert alert is not None and alert.kpi_id == k.id
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# B2 缺列五表:Log 类经父键 join 归属(零迁移)
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
class TestB2NotificationLogScoping:
|
||||
|
||||
def test_logs_scoped_by_alert_entity(self, client: TestClient, db: Session):
|
||||
k1, k2 = _seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
a1 = KPIAlert(entity_id=1, kpi_id=k1.id, alert_level="red",
|
||||
alert_message="e1 预警", status="pending")
|
||||
a2 = KPIAlert(entity_id=2, kpi_id=k2.id, alert_level="red",
|
||||
alert_message="e2 预警", status="pending")
|
||||
db.add_all([a1, a2])
|
||||
db.commit()
|
||||
db.refresh(a1)
|
||||
db.refresh(a2)
|
||||
db.add_all([
|
||||
NotificationLog(alert_id=a1.id, channel="wecom", title="e1 通知", status="sent"),
|
||||
NotificationLog(alert_id=a2.id, channel="wecom", title="e2 通知", status="sent"),
|
||||
NotificationLog(alert_id=None, channel="wecom", title="无归属通知", status="sent"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
t1 = _login(client, 1)
|
||||
t2 = _login(client, 2)
|
||||
l1 = client.get(f"{NOTIF}/logs", headers=auth_header(t1)).json()
|
||||
l2 = client.get(f"{NOTIF}/logs", headers=auth_header(t2)).json()
|
||||
|
||||
titles1 = {x["title"] for x in l1["data"]}
|
||||
titles2 = {x["title"] for x in l2["data"]}
|
||||
assert titles1 == {"e1 通知"}, f"e1 会话通知日志混入他账套/无归属行:{titles1}"
|
||||
assert titles2 == {"e2 通知"}, f"e2 会话通知日志混入他账套/无归属行:{titles2}"
|
||||
assert l1["total"] == 1 and l2["total"] == 1
|
||||
assert l1["entity_id"] == 1 and l2["entity_id"] == 2
|
||||
# 口径字段(2026-09-22 DAMA 回查 §5):空页要能解释成口径而非故障
|
||||
for l in (l1, l2):
|
||||
assert l["policy"] == "alert-linked-only", l
|
||||
assert "无父键" in l["note"] and "okr_review_reminder" in l["note"], l
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 迁移脚本:原值搬运口径(目标账套落值 / 其余留空 / 幂等 / 可回退)
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
MIGRATION = "scripts.migrate_cash_balance_key_entity_20260922"
|
||||
|
||||
|
||||
class TestMigrationScript:
|
||||
"""在**临时文件 SQLite**上跑迁移脚本(monkeypatch 引擎,绝不碰 app.database → 生产库)"""
|
||||
|
||||
@pytest.fixture
|
||||
def mig_env(self, tmp_path, monkeypatch):
|
||||
import importlib
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
mod = importlib.import_module(MIGRATION)
|
||||
eng = create_engine(f"sqlite:///{tmp_path/'mig.db'}")
|
||||
Base.metadata.create_all(bind=eng)
|
||||
with eng.begin() as conn:
|
||||
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
|
||||
"VALUES (1, '酣客', '酣客', 'active')"))
|
||||
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
|
||||
"VALUES (2, '博海', '博海', 'active')"))
|
||||
conn.execute(text("INSERT INTO system_configs (config_key, config_value, description) "
|
||||
"VALUES ('cash.current_balance', '-9.3', '历史全局键')"))
|
||||
conn.execute(text("INSERT INTO role_permissions (`key`, value) "
|
||||
"VALUES ('alignment_config', '{\"mode\": \"bsc_chain\"}')"))
|
||||
monkeypatch.setattr(mod, "_engine", lambda: eng)
|
||||
return mod, eng
|
||||
|
||||
def _run(self, mod, argv):
|
||||
import sys as _sys
|
||||
old = _sys.argv
|
||||
_sys.argv = ["migrate"] + argv
|
||||
try:
|
||||
return mod.main()
|
||||
finally:
|
||||
_sys.argv = old
|
||||
|
||||
def test_check_is_readonly(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
assert self._run(mod, ["--check", "--allow-db", "sqlite"]) == 0
|
||||
with eng.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 0
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
|
||||
"WHERE `key` LIKE 'alignment_config.%'")).scalar() == 0
|
||||
|
||||
def test_apply_moves_value_to_target_only_and_is_idempotent(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
|
||||
"--target-entity", "1", "--json"]) == 0
|
||||
with eng.connect() as conn:
|
||||
v = conn.execute(text("SELECT config_value FROM system_configs "
|
||||
"WHERE config_key = 'cash.current_balance.1'")).scalar()
|
||||
assert v == "-9.3"
|
||||
# 其余账套留空(不跨账套借用)
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key = 'cash.current_balance.2'")).scalar() == 0
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
|
||||
"WHERE `key` = 'alignment_config.1'")).scalar() == 1
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
|
||||
"WHERE `key` = 'alignment_config.2'")).scalar() == 0
|
||||
# 旧键保留(回退杠杆)
|
||||
assert conn.execute(text("SELECT config_value FROM system_configs "
|
||||
"WHERE config_key = 'cash.current_balance'")).scalar() == "-9.3"
|
||||
# 二跑零副作用(幂等)
|
||||
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
|
||||
"--target-entity", "1", "--json"]) == 0
|
||||
with eng.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 1
|
||||
assert conn.execute(text("SELECT config_value FROM system_configs "
|
||||
"WHERE config_key = 'cash.current_balance.1'")).scalar() == "-9.3"
|
||||
|
||||
def test_down_removes_new_keys_keeps_legacy(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
self._run(mod, ["--apply", "--allow-db", "sqlite", "--json"])
|
||||
assert self._run(mod, ["--down", "--allow-db", "sqlite", "--json"]) == 0
|
||||
with eng.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 0
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
|
||||
"WHERE `key` LIKE 'alignment_config.%'")).scalar() == 0
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key = 'cash.current_balance'")).scalar() == 1
|
||||
|
||||
def test_prod_db_name_guard(self, mig_env):
|
||||
"""库名保护:非 cma 库未显式豁免 → 拒绝(exit 2)"""
|
||||
mod, eng = mig_env
|
||||
with pytest.raises(SystemExit) as e:
|
||||
self._run(mod, ["--apply", "--json"]) # sqlite → DATABASE() 不可用 → db=''
|
||||
assert e.value.code == 2
|
||||
|
||||
def test_target_entity_must_exist(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
with pytest.raises(SystemExit) as e:
|
||||
self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"])
|
||||
assert e.value.code == 2
|
||||
@@ -1,154 +1,206 @@
|
||||
#!/bin/bash
|
||||
# 管理会计OS — 一键拉取、构建并部署到生产
|
||||
#
|
||||
# 部署源 = **已提交的 origin/main 树**(独立检出,与开发工作区解耦)
|
||||
# cma-deploy-source-decouple-20260921(任总 2026-09-21 10:28「根治」;单 project-20260921-deploy-dirtyguard-rootfix)
|
||||
#
|
||||
# 为什么改:开发区 /root/cma-management → /srv/hermes/shared/cma-management 过去**既是编辑区又是部署源**
|
||||
# (SCRIPT_DIR 即部署根),而 [0] 对**任何** tracked modified 即 exit 1 ⇒ 多 agent 并行下必然反复自锁:
|
||||
# 2026-09-18 frontend/package.json(corepack pnpm build 自加行);
|
||||
# 2026-09-21 10:09 batch5 在途 6 文件;
|
||||
# 2026-09-21 11:07 构建又写出 frontend/components.d.ts(unplugin-vue-components 生成物)。
|
||||
# 现在:部署内容 = 已提交的 origin/main(fetch → checkout --detach -f → clean -fdx),
|
||||
# 开发区只作「入口 + 运行时资产单源(.env / venv / node_modules)」,其脏净**不参与**部署判定,
|
||||
# 部署过程也**不** pull / stash / checkout 开发区(在途改动既不上线、也不被丢弃)。
|
||||
#
|
||||
# 保留不变(不得弱化):[1b] 鉴权面审计闸门(B 类缺口即中止)、构建→铺站→重启→冒烟全流程、一行回滚能力。
|
||||
#
|
||||
# 可选环境变量:
|
||||
# CMA_DEPLOY_SRC 部署树路径(默认 /srv/hermes/shared/cma-deploy)
|
||||
# CMA_WWW_DIR 前端铺站目录(默认 /var/www/cma)
|
||||
# CMA_DEPLOY_MODE deploy(默认)| drill(离线演练:www 必须在 /var/www 之外、systemctl/nginx 必须是桩、
|
||||
# 迁移只跑 --check、不装依赖、不改 unit)
|
||||
# CMA_DEPLOY_SKIP_FETCH=1 跳过 fetch,沿用本地 origin/main 引用(离线演练用)
|
||||
# SMOKE_HEALTH_URL / SMOKE_READY_MAX 冒烟就绪探测覆盖(见下方 cma-readiness-poll 块)
|
||||
set -e
|
||||
|
||||
# pnpm/corepack 需要 /root 下的缓存(profile HOME 缺 bin/pnpm.cjs → pnpm build 报 MODULE_NOT_FOUND)
|
||||
export HOME="${CMA_DEPLOY_HOME:-/root}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_DIR="$SCRIPT_DIR" # 开发区/入口工作区(可脏:仅作入口与资产单源)
|
||||
DEPLOY_SRC="${CMA_DEPLOY_SRC:-/srv/hermes/shared/cma-deploy}" # 部署树:恒等于已提交的 origin/main
|
||||
WWW_DIR="${CMA_WWW_DIR:-/var/www/cma}"
|
||||
MODE="${CMA_DEPLOY_MODE:-deploy}"
|
||||
UNIT_FILE="${CMA_DEPLOY_UNIT_FILE:-/etc/systemd/system/cma-backend.service}"
|
||||
ENV_SRC="$REPO_DIR/backend/.env"
|
||||
VENV_SRC="$REPO_DIR/backend/venv"
|
||||
|
||||
echo "===== 管理会计OS 生产部署 ====="
|
||||
echo " 入口工作区(开发区): $REPO_DIR"
|
||||
echo " 部署树(已提交树) : $DEPLOY_SRC"
|
||||
echo " 模式 : $MODE"
|
||||
echo ""
|
||||
|
||||
# 0. 提交纪律检查:本地有未提交修改则中止,防止 git pull 覆盖丢失
|
||||
echo "[0/5] 提交纪律检查..."
|
||||
cd "$SCRIPT_DIR"
|
||||
DIRTY=$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)
|
||||
if [ -n "$DIRTY" ]; then
|
||||
echo "❌ 检测到本地未提交的修改,中止部署(防止 git pull 覆盖丢失代码):"
|
||||
echo "$DIRTY"
|
||||
# ── 演练模式安全闸(必须最先判定:drill 绝不允许碰到生产面)──
|
||||
if [ "$MODE" = "drill" ]; then
|
||||
case "$WWW_DIR" in
|
||||
/var/www|/var/www/*) echo "❌ 演练模式禁止写生产 www 目录: $WWW_DIR(请用 CMA_WWW_DIR 指向临时目录)"; exit 1;;
|
||||
esac
|
||||
for c in systemctl nginx; do
|
||||
p="$(command -v "$c" 2>/dev/null || true)"
|
||||
case "$p" in
|
||||
""|/usr/bin/*|/bin/*|/usr/sbin/*|/sbin/*)
|
||||
echo "❌ 演练模式要求 $c 指向桩脚本(PATH 前置沙箱 bin 目录),当前='${p:-<缺失>}' —— 中止以免误触生产"
|
||||
exit 1;;
|
||||
esac
|
||||
done
|
||||
echo " ℹ️ 演练模式:www=$WWW_DIR;systemctl=$(command -v systemctl);nginx=$(command -v nginx)"
|
||||
echo ""
|
||||
echo "请先提交代码: git add -A && git commit -m '...' && git push origin main"
|
||||
fi
|
||||
|
||||
# 0. 部署源解析:部署内容 = 已提交的 origin/main(开发区的脏净降级为**告警**,不再 exit)
|
||||
# 旧 [0] 的初衷是「防 git pull 覆盖丢代码」;改为已提交树后该风险不存在(部署树永远从 origin/main 重建,
|
||||
# 开发区不再被 pull)——故不得保留「开发区任何 modified 即中止」。
|
||||
echo "[0/5] 部署源解析(origin/main 已提交树)..."
|
||||
cd "$REPO_DIR"
|
||||
DEV_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
|
||||
DEV_HEAD="$(git rev-parse HEAD)"
|
||||
DIRTY="$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)"
|
||||
if [ -n "$DIRTY" ]; then
|
||||
echo " ⚠️ 开发区存在未提交改动(不阻断部署;这些改动不会上线,也不会被丢弃/覆盖):"
|
||||
echo "$DIRTY" | sed 's/^/ /'
|
||||
else
|
||||
echo " ✓ 开发区工作区干净"
|
||||
fi
|
||||
if [ "${CMA_DEPLOY_SKIP_FETCH:-0}" = "1" ]; then
|
||||
echo " ℹ️ CMA_DEPLOY_SKIP_FETCH=1 → 跳过 fetch,沿用本地 origin/main 引用"
|
||||
else
|
||||
if ! git fetch --quiet origin main; then
|
||||
echo "❌ git fetch origin main 失败 —— 无法确定部署源(失败关闭)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
DEPLOY_SHA="$(git rev-parse origin/main)"
|
||||
DEPLOY_SUBJECT="$(git log -1 --pretty=%s "$DEPLOY_SHA" 2>/dev/null || echo '')"
|
||||
echo " ✓ 部署源: origin/main = ${DEPLOY_SHA:0:8} ${DEPLOY_SUBJECT}"
|
||||
if [ "$DEV_HEAD" != "$DEPLOY_SHA" ]; then
|
||||
AHEAD="$(git rev-list --count origin/main..HEAD 2>/dev/null || echo '?')"
|
||||
echo " ⚠️ 开发区 HEAD($DEV_BRANCH ${DEV_HEAD:0:8}) ≠ 部署源(${DEPLOY_SHA:0:8}),本地领先 $AHEAD 笔"
|
||||
echo " ⇒ 未推送提交不会上线;要上线请先 git push origin main"
|
||||
fi
|
||||
|
||||
# 1. 同步部署树到 origin/main(部署树恒等于已提交树;开发区零触碰)
|
||||
echo "[1/5] 同步部署树 → origin/main ..."
|
||||
if [ ! -e "$DEPLOY_SRC/.git" ]; then
|
||||
echo " ℹ️ 首次使用:创建部署检出 $DEPLOY_SRC(git worktree,detached)"
|
||||
mkdir -p "$(dirname "$DEPLOY_SRC")"
|
||||
if ! git -C "$REPO_DIR" worktree add --detach "$DEPLOY_SRC" "$DEPLOY_SHA"; then
|
||||
echo "❌ 部署树创建失败: $DEPLOY_SRC"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if ! git -C "$DEPLOY_SRC" rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "❌ $DEPLOY_SRC 已存在但不是 git 检出 —— 请人工确认后再部署"
|
||||
exit 1
|
||||
fi
|
||||
git -C "$DEPLOY_SRC" checkout --detach -f "$DEPLOY_SHA" > /dev/null
|
||||
# -e 保留运行时资产(依赖/密钥/日志/迁移 journal),其余一律还原为已提交态
|
||||
git -C "$DEPLOY_SRC" clean -fdx -q \
|
||||
-e backend/venv -e backend/.env -e backend/logs \
|
||||
-e backend/scripts/backups \
|
||||
-e frontend/node_modules -e frontend/.env
|
||||
fi
|
||||
|
||||
# 1.1 部署树自检(起点):必须恒等于已提交树 —— 旧的「脏即中止」只保留在这里(对象是**部署树**,不是开发区)
|
||||
DT_DIRTY="$(git -C "$DEPLOY_SRC" status --porcelain 2>/dev/null | grep -vE "^\?\?" | head -20)"
|
||||
if [ -n "$DT_DIRTY" ]; then
|
||||
echo "❌ 部署树存在未提交修改(部署树必须恒等于已提交树):"
|
||||
echo "$DT_DIRTY" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ 工作区干净,无未提交修改"
|
||||
DT_HEAD="$(git -C "$DEPLOY_SRC" rev-parse HEAD)"
|
||||
if [ "$DT_HEAD" != "$DEPLOY_SHA" ]; then
|
||||
echo "❌ 部署树 HEAD(${DT_HEAD:0:8}) ≠ 部署源(${DEPLOY_SHA:0:8})"; exit 1
|
||||
fi
|
||||
echo " ✓ 部署树 = 已提交树(HEAD=${DEPLOY_SHA:0:8},无 tracked 修改)"
|
||||
|
||||
# 1. 拉取最新代码
|
||||
echo "[1/5] 拉取最新代码..."
|
||||
git pull origin main
|
||||
echo " ✓ 代码已更新"
|
||||
# 1.2 运行时资产接线(单源、幂等):.env / venv 指向开发区主副本,避免密钥与依赖双份漂移
|
||||
if [ ! -e "$ENV_SRC" ]; then
|
||||
echo "❌ 运行时密钥文件缺失: $ENV_SRC(部署树需要它)"; exit 1
|
||||
fi
|
||||
if [ ! -e "$VENV_SRC/bin/python3" ]; then
|
||||
echo "❌ 运行时 venv 缺失: $VENV_SRC/bin/python3(部署树需要它)"; exit 1
|
||||
fi
|
||||
ln -sfn "$ENV_SRC" "$DEPLOY_SRC/backend/.env"
|
||||
ln -sfn "$VENV_SRC" "$DEPLOY_SRC/backend/venv"
|
||||
mkdir -p "$DEPLOY_SRC/backend/logs" "$DEPLOY_SRC/backend/scripts/backups"
|
||||
if [ -f "$REPO_DIR/frontend/.env" ]; then
|
||||
ln -sfn "$REPO_DIR/frontend/.env" "$DEPLOY_SRC/frontend/.env"
|
||||
fi
|
||||
if [ ! -e "$DEPLOY_SRC/frontend/node_modules" ]; then
|
||||
echo " ℹ️ 部署树缺 frontend/node_modules → 尝试 pnpm install --frozen-lockfile --prefer-offline"
|
||||
if ! ( cd "$DEPLOY_SRC/frontend" && pnpm install --frozen-lockfile --prefer-offline ); then
|
||||
if [ -d "$REPO_DIR/frontend/node_modules" ]; then
|
||||
echo " ⚠️ 部署树 pnpm install 失败 → 回退为链接开发区 node_modules(依赖内容与开发区一致;锁定文件仍以已提交为准)"
|
||||
ln -sfn "$REPO_DIR/frontend/node_modules" "$DEPLOY_SRC/frontend/node_modules"
|
||||
else
|
||||
echo "❌ 部署树无 node_modules 且开发区也没有 → 无法构建前端"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1d. 后端可导入自检(接线完成后执行;缺 .env/venv/依赖会让服务起不来 → 重启前失败关闭)
|
||||
if ! timeout 60 sh -c "cd '$DEPLOY_SRC/backend' && ./venv/bin/python3 -c 'import app.main'"; then
|
||||
echo "❌ 部署树后端导入自检失败(app.main 无法导入)→ 中止(生产未被触碰)"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ 部署树后端可导入(app.main)+ 运行时资产已接线"
|
||||
|
||||
# 1b. 鉴权面审计闸门(T1, 2026-09-15):B 类缺口(用户端点无鉴权)存在即中止部署
|
||||
echo "[1b/5] 鉴权面审计闸门..."
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
cd "$DEPLOY_SRC/backend"
|
||||
if ! ./venv/bin/python3 scripts/audit_api_auth_surface.py > /tmp/cma-auth-audit.log 2>&1; then
|
||||
echo "❌ 鉴权面审计发现 B 类缺口(用户端点缺鉴权),中止部署。详见 /tmp/cma-auth-audit.log"
|
||||
tail -20 /tmp/cma-auth-audit.log
|
||||
exit 1
|
||||
fi
|
||||
cd "$SCRIPT_DIR"
|
||||
echo " ✓ 鉴权面审计通过(无 B 类缺口)"
|
||||
|
||||
# 2. 构建前端
|
||||
# 2. 构建前端(在部署树内构建:pnpm/corepack 的生成物落在这里,污染不到开发区)
|
||||
echo "[2/5] 构建前端..."
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
cd "$DEPLOY_SRC/frontend"
|
||||
pnpm build
|
||||
echo " ✓ 构建完成"
|
||||
# 构建工具链(corepack/pnpm、unplugin-auto-import/vue-components)会在部署树里写生成物 ——
|
||||
# 部署树可弃、下次部署 checkout -f + clean 重置,故仅提示;旧版这些生成物落在**开发区**,
|
||||
# 正是发布链自锁的来源之一(2026-09-18 package.json / 2026-09-21 components.d.ts)。
|
||||
DT_POST="$(git -C "$DEPLOY_SRC" status --porcelain 2>/dev/null | head -5)"
|
||||
if [ -n "$DT_POST" ]; then
|
||||
echo " ℹ️ 构建后部署树生成物(仅提示,不影响开发区):"
|
||||
echo "$DT_POST" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# 3. 部署前端到 Nginx
|
||||
echo "[3/5] 部署前端..."
|
||||
rm -rf /var/www/cma/assets/
|
||||
cp -r dist/* /var/www/cma/
|
||||
rm -rf "$WWW_DIR/assets/"
|
||||
cp -r dist/* "$WWW_DIR/"
|
||||
DIFF_OUT="$(diff -rq dist "$WWW_DIR" 2>&1 | head -5 || true)"
|
||||
if [ -n "$DIFF_OUT" ]; then
|
||||
echo " ⚠️ 铺站后 dist 与 $WWW_DIR 存在差异(仅提示):"
|
||||
echo "$DIFF_OUT" | sed 's/^/ /'
|
||||
else
|
||||
echo " ✓ 铺站一致性: dist == $WWW_DIR"
|
||||
fi
|
||||
echo " ✓ 部署完成"
|
||||
|
||||
# 3b. 前端层一致性断言(T2, 2026-09-18):部署链静默替换门
|
||||
# 背景:[3] 只有 rm -rf + cp,缓存层吐旧 index、或 index 指向已被删的旧 chunk 时,现象是
|
||||
# 「首页 200、点进页面 404/白屏」,而其后的冒烟 5 项全为后端项(127.0.0.1:8010)→ 整轮部署全绿。
|
||||
# 三条断言各自独立输出,任一不一致即 exit 1。离线演练只覆盖下面三个变量,无需 build。
|
||||
# >>> cma-frontend-assert(最小可抽取块:scripts/tests 按行首标记 awk 抽取后原样执行,改结构请沿用标记)
|
||||
FRONTEND_DIST_DIR="${FRONTEND_DIST_DIR:-$SCRIPT_DIR/frontend/dist}"
|
||||
CMA_WEBROOT="${CMA_WEBROOT:-/var/www/cma}"
|
||||
SITE_URL="${SITE_URL:-https://cma.sxbh.ltd}"
|
||||
echo "[3b/5] 前端层一致性断言(dist ↔ webroot ↔ 引用链)..."
|
||||
FRONTEND_ASSERT_FAIL=0
|
||||
|
||||
# ① 逐文件 md5 对账:dist/assets 与 webroot/assets 文件集合一致(无多、无少)且同名文件 md5 相同
|
||||
DIST_ASSETS="$FRONTEND_DIST_DIR/assets"
|
||||
WEB_ASSETS="$CMA_WEBROOT/assets"
|
||||
DIST_FILES="$(ls -1 "$DIST_ASSETS" 2>/dev/null | sort)"
|
||||
WEB_FILES="$(ls -1 "$WEB_ASSETS" 2>/dev/null | sort)"
|
||||
MISSING_FILES="$(comm -23 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))"
|
||||
EXTRA_FILES="$(comm -13 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))"
|
||||
MD5_DIFF=""
|
||||
while IFS= read -r F; do
|
||||
[ -n "$F" ] || continue
|
||||
D_MD5="$(md5sum "$DIST_ASSETS/$F" 2>/dev/null | awk '{print $1}')"
|
||||
W_MD5="$(md5sum "$WEB_ASSETS/$F" 2>/dev/null | awk '{print $1}')"
|
||||
[ "$D_MD5" = "$W_MD5" ] || MD5_DIFF="${MD5_DIFF}${MD5_DIFF:+, }$F(dist=$D_MD5 web=$W_MD5)"
|
||||
done <<< "$DIST_FILES"
|
||||
if [ -n "$MISSING_FILES" ] || [ -n "$EXTRA_FILES" ] || [ -n "$MD5_DIFF" ]; then
|
||||
echo " ❌ 断言① 逐文件 md5 对账失败(dist=$DIST_ASSETS)"
|
||||
[ -z "$MISSING_FILES" ] || echo " webroot 缺失: $(printf '%s' "$MISSING_FILES" | tr '\n' ' ')"
|
||||
[ -z "$EXTRA_FILES" ] || echo " webroot 多出: $(printf '%s' "$EXTRA_FILES" | tr '\n' ' ')"
|
||||
[ -z "$MD5_DIFF" ] || echo " md5 不一致: $MD5_DIFF"
|
||||
FRONTEND_ASSERT_FAIL=1
|
||||
else
|
||||
echo " ✅ 断言① 逐文件 md5 对账通过($(printf '%s\n' "$DIST_FILES" | awk 'NF{n++} END{print n+0}') 个文件全部一致)"
|
||||
fi
|
||||
|
||||
# ② 主包一致:webroot/index.html 解析出的入口 chunk 必须在 dist 存在且 md5 相同
|
||||
INDEX_HTML="$CMA_WEBROOT/index.html"
|
||||
MAIN_BUNDLE="$(grep -o '/assets/index-[A-Za-z0-9._-]*\.js' "$INDEX_HTML" 2>/dev/null | head -n 1)"
|
||||
if [ -z "$MAIN_BUNDLE" ]; then
|
||||
echo " ❌ 断言② 主包一致失败: 未能从 $INDEX_HTML 解析出入口 chunk(/assets/index-*.js)"
|
||||
FRONTEND_ASSERT_FAIL=1
|
||||
elif [ ! -f "$FRONTEND_DIST_DIR$MAIN_BUNDLE" ]; then
|
||||
echo " ❌ 断言② 主包一致失败: index.html 引用的 $MAIN_BUNDLE 在 dist 不存在($FRONTEND_DIST_DIR)"
|
||||
FRONTEND_ASSERT_FAIL=1
|
||||
else
|
||||
MAIN_D_MD5="$(md5sum "$FRONTEND_DIST_DIR$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')"
|
||||
MAIN_W_MD5="$(md5sum "$CMA_WEBROOT$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')"
|
||||
if [ -n "$MAIN_D_MD5" ] && [ "$MAIN_D_MD5" = "$MAIN_W_MD5" ]; then
|
||||
echo " ✅ 断言② 主包一致通过($MAIN_BUNDLE dist=web=$MAIN_D_MD5)"
|
||||
else
|
||||
echo " ❌ 断言② 主包一致失败: $MAIN_BUNDLE dist=$MAIN_D_MD5 web=$MAIN_W_MD5"
|
||||
FRONTEND_ASSERT_FAIL=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ③ 引用链可 200:主包内引用的懒加载 chunk 逐个请求 $SITE_URL,必须全部 HTTP 200(无静默跳过分支)
|
||||
# 字面量形态取并集:现网 Vite 产物实测为相对形态 from"./Login-CCqwmqG_.js",另有 "/assets/*.js" 形态;
|
||||
# 两者都归一到 /assets/<name> 再请求。解析不到任何引用 → 显式判失败(不得静默放行)。
|
||||
MAIN_JS_FILE="$CMA_WEBROOT$MAIN_BUNDLE"
|
||||
CHUNKS="$(grep -o -E '(\./|/assets/)[A-Za-z0-9._-]+\.js' "$MAIN_JS_FILE" 2>/dev/null | sed -E 's#^\./#/assets/#' | sort -u)"
|
||||
CHUNK_TOTAL="$(printf '%s\n' "$CHUNKS" | awk 'NF{n++} END{print n+0}')"
|
||||
if [ ! -f "$MAIN_JS_FILE" ]; then
|
||||
echo " ❌ 断言③ 引用链可 200 失败: 主包在 webroot 不存在($MAIN_JS_FILE)"
|
||||
FRONTEND_ASSERT_FAIL=1
|
||||
elif [ "$CHUNK_TOTAL" -eq 0 ]; then
|
||||
echo " ❌ 断言③ 引用链可 200 失败: 主包 $MAIN_BUNDLE 内未解析到任何 chunk 引用(./*.js 或 /assets/*.js)"
|
||||
FRONTEND_ASSERT_FAIL=1
|
||||
else
|
||||
CHUNK_OK=0
|
||||
CHUNK_BAD=""
|
||||
while IFS= read -r C; do
|
||||
[ -n "$C" ] || continue
|
||||
C_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$SITE_URL$C" 2>/dev/null || echo 000)"
|
||||
if [ "$C_CODE" = "200" ]; then
|
||||
CHUNK_OK=$((CHUNK_OK + 1))
|
||||
else
|
||||
CHUNK_BAD="${CHUNK_BAD}${CHUNK_BAD:+, }$C(HTTP=$C_CODE)"
|
||||
fi
|
||||
done <<< "$CHUNKS"
|
||||
if [ -n "$CHUNK_BAD" ]; then
|
||||
echo " ❌ 断言③ 引用链可 200 失败($SITE_URL): $CHUNK_BAD"
|
||||
FRONTEND_ASSERT_FAIL=1
|
||||
else
|
||||
echo " ✅ 断言③ 引用链可 200 通过($CHUNK_OK/$CHUNK_TOTAL chunk @ $SITE_URL)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 三条均为硬门:任一不一致即中止部署(nginx 直接服务静态文件,无需 reload)
|
||||
if [ "$FRONTEND_ASSERT_FAIL" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "🚨 前端层一致性断言未通过 → 中止部署(webroot 可能停留在旧包 / 半包)"
|
||||
echo " dist=$FRONTEND_DIST_DIR webroot=$CMA_WEBROOT site=$SITE_URL"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ 前端层一致性断言通过(① 逐文件 md5 ② 主包 ③ 引用链 200)"
|
||||
# <<< cma-frontend-assert
|
||||
|
||||
# 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署)
|
||||
echo "[4/5] 更新后端依赖..."
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
if [ -f venv/bin/activate ]; then
|
||||
cd "$DEPLOY_SRC/backend"
|
||||
if [ "$MODE" = "drill" ]; then
|
||||
echo " ℹ️ 演练模式:跳过依赖安装(共享 venv 只读)"
|
||||
elif [ -f venv/bin/activate ]; then
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt --quiet --no-cache-dir || echo " ⚠️ 依赖更新失败(不阻断)"
|
||||
deactivate
|
||||
@@ -162,18 +214,58 @@ echo " ✓ 依赖检查完成"
|
||||
# 新模型必须由**有 DDL 权限的账号**在重启前建表;此处失败即中止部署(后端保持旧码运行),
|
||||
# 避免 2026-09-16 那种「启动 create_all 被 cma_api 拒 → 崩溃循环」。
|
||||
echo "[4b/5] 数据库结构迁移(特权账号,重启前)..."
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
if ! ./venv/bin/python3 scripts/db_migrate.py --apply; then
|
||||
cd "$DEPLOY_SRC/backend"
|
||||
MIGRATE_ARG="--apply"
|
||||
if [ "$MODE" = "drill" ]; then MIGRATE_ARG="--check"; echo " ℹ️ 演练模式:只跑 --check(只读,不落 DDL)"; fi
|
||||
if ! ./venv/bin/python3 scripts/db_migrate.py "$MIGRATE_ARG"; then
|
||||
echo "❌ 数据库结构迁移失败 → 中止部署(后端未重启,旧码继续服务)"
|
||||
echo " 排查: cd $SCRIPT_DIR/backend && ./venv/bin/python3 scripts/db_migrate.py --check"
|
||||
echo " 回滚: cd $SCRIPT_DIR/backend && ./venv/bin/python3 scripts/db_migrate.py --rollback latest"
|
||||
echo " 排查: cd $DEPLOY_SRC/backend && ./venv/bin/python3 scripts/db_migrate.py --check"
|
||||
echo " 回滚: cd $DEPLOY_SRC/backend && ./venv/bin/python3 scripts/db_migrate.py --rollback latest"
|
||||
exit 1
|
||||
fi
|
||||
cd "$SCRIPT_DIR"
|
||||
echo " ✓ 结构迁移完成(幂等;journal: backend/scripts/backups/db-migrate-journal/)"
|
||||
cd "$DEPLOY_SRC"
|
||||
echo " ✓ 结构迁移完成(幂等;journal: $DEPLOY_SRC/backend/scripts/backups/db-migrate-journal/)"
|
||||
|
||||
# 5. 重启后端服务
|
||||
# 5. 重启后端服务(运行树 = 部署树:幂等改写 unit,改前备份,附一行回滚)
|
||||
echo "[5/5] 重启后端服务..."
|
||||
if [ "$MODE" = "drill" ]; then
|
||||
echo " ℹ️ 演练模式:跳过 unit 改写/服务重启(下列 systemctl/nginx 为 PATH 桩,仅留调用证据)"
|
||||
else
|
||||
# >>> cma-runtime-unit-switch(最小可抽取块:drill/test 按行首标记 awk 抽取后原样执行,改结构请沿用标记)
|
||||
DESIRED_WD="$DEPLOY_SRC/backend"
|
||||
if grep -qF "WorkingDirectory=$DESIRED_WD" "$UNIT_FILE" 2>/dev/null; then
|
||||
echo " ✓ 运行树已是部署树(WorkingDirectory=$DESIRED_WD)"
|
||||
else
|
||||
if [ ! -r "$UNIT_FILE" ]; then
|
||||
echo "❌ 找不到 systemd unit: $UNIT_FILE"; exit 1
|
||||
fi
|
||||
UNIT_BAK="${UNIT_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
cp -a "$UNIT_FILE" "$UNIT_BAK"
|
||||
cat > "$UNIT_FILE" <<UNITEOF
|
||||
[Unit]
|
||||
Description=管理会计OS后端(运行树=部署树,见 deploy.sh cma-deploy-source-decouple-20260921)
|
||||
After=network-online.target mysql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$DESIRED_WD
|
||||
EnvironmentFile=$DESIRED_WD/.env
|
||||
ExecStart=$DESIRED_WD/venv/bin/python3 -m uvicorn app.main:app --host 127.0.0.1 --port 8010 --workers 1 --limit-max-requests 10000 --timeout-keep-alive 30 --no-access-log
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNITEOF
|
||||
echo " ℹ️ 运行树切到部署树(WorkingDirectory=$DESIRED_WD)"
|
||||
echo " 一行回滚: cp $UNIT_BAK $UNIT_FILE && systemctl daemon-reload && systemctl restart cma-backend"
|
||||
fi
|
||||
# <<< cma-runtime-unit-switch
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl restart cma-backend
|
||||
systemctl reload nginx 2>/dev/null || nginx -s reload
|
||||
@@ -227,10 +319,10 @@ else
|
||||
echo " ❌ BOT桥接API失败"; SMOKE_FAIL=1
|
||||
fi
|
||||
# Schema 一致性检查(数据库与ORM结构对齐)
|
||||
if /root/cma-management/backend/venv/bin/python3 /root/cma-management/backend/scripts/schema_check.py > /dev/null 2>&1; then
|
||||
if "$DEPLOY_SRC/backend/venv/bin/python3" "$DEPLOY_SRC/backend/scripts/schema_check.py" > /dev/null 2>&1; then
|
||||
echo " ✅ Schema一致性通过"
|
||||
else
|
||||
echo " ❌ Schema不一致,请检查: cd /root/cma-management/backend && venv/bin/python3 scripts/schema_check.py"
|
||||
echo " ❌ Schema不一致,请检查: cd $DEPLOY_SRC/backend && venv/bin/python3 scripts/schema_check.py"
|
||||
SMOKE_FAIL=1
|
||||
fi
|
||||
# 凭据同源(2026-09-14 cma-defaultcred-code-fix):登录冒烟不再硬编码默认账号/口令 —— 轮换后必须自动跟随。
|
||||
@@ -267,12 +359,15 @@ fi
|
||||
if [ "$SMOKE_FAIL" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "🚨 冒烟测试未全部通过!请检查后端日志: journalctl -u cma-backend -n 50"
|
||||
echo " 一行回滚: cp ${UNIT_FILE}.bak-* ${UNIT_FILE} && systemctl daemon-reload && systemctl restart cma-backend"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ 全部冒烟测试通过"
|
||||
|
||||
echo ""
|
||||
echo "===== 部署完成 ====="
|
||||
echo " 部署源 : origin/main = ${DEPLOY_SHA:0:8} ${DEPLOY_SUBJECT}"
|
||||
echo " 部署树 : $DEPLOY_SRC"
|
||||
echo " 前端: https://cma.sxbh.ltd"
|
||||
echo " 后端: http://127.0.0.1:8010/docs"
|
||||
echo " 后端状态: systemctl status cma-backend"
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# CMA 部署前「部署源」前置检查(宿主侧,由 gitea-cma-deploy-hook.sh 调用)
|
||||
# ------------------------------------------------------------
|
||||
# 版本: cma-deploy-source-decouple-20260921(单 project-20260921-deploy-dirtyguard-rootfix)
|
||||
#
|
||||
# 旧行为(cma-deploy-dirty-guard-20260918):开发工作区出现**任何** tracked modified 即 exit 1。
|
||||
# 初衷是「防 deploy.sh 里的 `git pull` 覆盖未提交改动」。但开发区同时是部署源 ⇒ 多 agent 并行下
|
||||
# 必然反复自锁(corepack 生成物、在途任务文件、unplugin-vue-components 生成物…),发布链被自己的
|
||||
# 纪律闸门卡死(09-18 / 09-21 两次实测)。
|
||||
#
|
||||
# 新行为:部署内容 = **已提交的 origin/main 树**(deploy.sh 用独立检出 $CMA_DEPLOY_SRC 重建;
|
||||
# 部署过程不再对开发区做 pull/stash/checkout)。开发区的脏净与部署内容无关 ⇒ 本闸门**降级为告警**:
|
||||
# 打印开发区状态供留痕,不因 modified 中止部署。真正的硬闸门仍有三道,且都在 deploy.sh 里:
|
||||
# [0] 部署源解析(fetch 失败即中止) [1c] 部署树 ≠ 已提交树即中止 [1b] 鉴权面审计 B 类缺口即中止。
|
||||
# 本脚本只在「仓库不可用」这一种情况失败关闭(部署本身需要这个仓库)。
|
||||
#
|
||||
# 用法: cma-deploy-dirty-guard.sh [repo_dir] # 默认 /root/cma-management
|
||||
# 退出码: 0 = 可继续部署(无论开发区脏净); 1 = 仓库不可用
|
||||
# 依赖: git。本脚本**不写**开发区任何文件(旧版会把 corepack 生成物 checkout 回去,已移除)。
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
REPO="${1:-/root/cma-management}"
|
||||
|
||||
cd "$REPO" 2>/dev/null || { echo "❌ 仓库不可用: $REPO"; exit 1; }
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "❌ 不是 git 仓库: $REPO"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
|
||||
HEAD_SHA="$(git rev-parse HEAD 2>/dev/null || echo '?')"
|
||||
ORIGIN_SHA="$(git rev-parse origin/main 2>/dev/null || echo '?')"
|
||||
DIRTY="$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)"
|
||||
DIRTY_N="$(git status --porcelain 2>/dev/null | grep -vcE "__pycache__|^\?\?")"
|
||||
|
||||
echo " ℹ️ 开发区 $REPO: 分支=$BRANCH HEAD=${HEAD_SHA:0:8} origin/main=${ORIGIN_SHA:0:8}"
|
||||
if [ "$DIRTY_N" -gt 0 ]; then
|
||||
echo " ⚠️ 开发区有 $DIRTY_N 处未提交 tracked 改动 —— 不阻断部署(这些改动不会上线,也不会被丢弃):"
|
||||
echo "$DIRTY" | sed 's/^/ /'
|
||||
else
|
||||
echo " ✓ 开发区无未提交 tracked 改动"
|
||||
fi
|
||||
echo " ℹ️ 部署内容 = 已提交的 origin/main 树(与开发区脏净解耦)"
|
||||
exit 0
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/bin/bash
|
||||
# Gitea post-receive 部署触发器(由 /root/.ssh/authorized_keys 的 forced command 调用)
|
||||
# 落地:ops-20260918-gitea-deploy-hook-ssh-fix
|
||||
# 容器侧私钥只能触发本脚本(不能执行任意 root 命令),且来源 IP 限定在 gitea 容器网段。
|
||||
# HOME 必须显式为 /root:profile HOME 下 corepack 缓存缺 bin/pnpm.cjs → pnpm build 报 MODULE_NOT_FOUND。
|
||||
# 2026-09-18(0-P1-project-20260918-deploy-dirty-guard):deploy.sh 之前增加「提交纪律」前置闸门
|
||||
# —— frontend/package.json 的 corepack 生成物(packageManager 单字段)不再永久卡死发布链,
|
||||
# 其它任何脏改动仍照旧拦截(deploy.sh 第 0 步原闸门保留,双重保险)。
|
||||
# 2026-09-21(cma-deploy-source-decouple-20260921,单 project-20260921-deploy-dirtyguard-rootfix):
|
||||
# 根治「开发区任何 modified 即中止」——部署内容改为**已提交的 origin/main 树**(deploy.sh 用独立检出
|
||||
# 重建;部署过程不再 pull 开发区)⇒ 本前置闸门降级为**告警**:打印开发区状态供留痕,不因 modified 拦截;
|
||||
# 仅在「仓库不可用」时失败关闭(部署本身仍需这个仓库)。
|
||||
set -uo pipefail
|
||||
export HOME=/root
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export LANG=C.UTF-8
|
||||
|
||||
# ============================================================
|
||||
# [gate-trail] 动作前留痕(树外执行体入口,协议 v1.0.0)
|
||||
# chokepoint : gitea-cma-deploy-hook(channel=remote-trigger)
|
||||
# 注册表 : /etc/gate-trail/governed-entries.json
|
||||
# 留痕 : /var/log/gate-trail/trail.jsonl(append-only)
|
||||
# 覆盖语义 : 父级入口覆盖下游子步骤(deploy.sh → db_migrate.py --apply),
|
||||
# 单点挂载即覆盖整条链,下游不再各自留痕(见注册表 also_covered_by)
|
||||
# 依据 : 派单 yanxue-20260921-deploy-channel-gate-trail-mount
|
||||
# (协议母版 yanxue-20260918-051623-tree-external-unit-gate-trail)
|
||||
# ⚠️ 失败关闭 : begin 返回 2(HIGH/CRITICAL 且无有效审批)→ 中止部署
|
||||
# ⚠️ 取值纪律 : 必须先接住输出再取 $?;写成 `gate-trail ... | python3` 会把 exit 2 读成 0
|
||||
# ⚠️ 收尾纪律 : 用 EXIT trap 闭合(含 dirty-guard 拦截等提前 exit 路径)
|
||||
# ============================================================
|
||||
TRAIL_CHANNEL="remote-trigger"
|
||||
TRAIL_ACTION="Gitea push → post-receive → SSH forced-command → CMA 部署链(deploy.sh)"
|
||||
TRAIL_CMD="bash /root/cma-management/deploy.sh (trigger: gitea post-receive / authorized_keys forced-command)"
|
||||
TRAIL_TASK="${GATE_TRAIL_TASK:-}"
|
||||
TRAIL_ID=""
|
||||
TRAIL_CLOSED=""
|
||||
|
||||
# 收尾:任何退出路径都闭合留痕(BEGIN 之后才注册 trap,避免无 ID 时空转)
|
||||
trail_closeup() {
|
||||
local _rc=$?
|
||||
if [ -z "${TRAIL_CLOSED:-}" ] && [ -n "${TRAIL_ID:-}" ] && command -v gate-trail >/dev/null 2>&1; then
|
||||
if [ "$_rc" -eq 0 ]; then
|
||||
gate-trail end --id "$TRAIL_ID" --result pass \
|
||||
--evidence "deploy-hook exit=0 @ $(date '+%F %T %Z')" >/dev/null 2>&1 || true
|
||||
else
|
||||
gate-trail end --id "$TRAIL_ID" --result fail \
|
||||
--evidence "deploy-hook exit=$_rc @ $(date '+%F %T %Z')(含前置闸门拦截等中止路径)" >/dev/null 2>&1 || true
|
||||
fi
|
||||
TRAIL_CLOSED=1
|
||||
fi
|
||||
return "$_rc"
|
||||
}
|
||||
|
||||
if command -v gate-trail >/dev/null 2>&1; then
|
||||
TRAIL_JSON=$(gate-trail begin --channel "$TRAIL_CHANNEL" --action "$TRAIL_ACTION" \
|
||||
--cmd "$TRAIL_CMD" --task "$TRAIL_TASK" \
|
||||
--asset /root/cma-management --json 2>/dev/null)
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
echo "[deploy-hook] ⛔ gate-trail 拒绝执行(高危且无有效审批)—— 中止部署" >&2
|
||||
exit 2
|
||||
fi
|
||||
TRAIL_ID=$(printf '%s' "$TRAIL_JSON" | python3 -c 'import json,sys
|
||||
try:
|
||||
print(json.load(sys.stdin).get("trail_id",""))
|
||||
except Exception:
|
||||
print("")')
|
||||
if [ -z "$TRAIL_ID" ]; then
|
||||
echo "[deploy-hook] ⛔ gate-trail 未返回 trail_id —— 按失败关闭处理,中止部署" >&2
|
||||
exit 2
|
||||
fi
|
||||
echo "[deploy-hook] 🧾 gate-trail 留痕已开: $TRAIL_ID"
|
||||
trap trail_closeup EXIT
|
||||
else
|
||||
echo "[deploy-hook] ⚠️ gate-trail 不可用,本次动作将无留痕(违反 gate-trail 协议)" >&2
|
||||
fi
|
||||
|
||||
|
||||
echo "[deploy-hook] 宿主机已接受 SSH(key=gitea-deploy-hook@sxbh.ltd, forced-command)"
|
||||
echo "[deploy-hook] 请求命令(被忽略/仅记录)=${SSH_ORIGINAL_COMMAND:-<none>}"
|
||||
|
||||
cd /root/cma-management || { echo "[deploy-hook] ✗ /root/cma-management 不可用"; exit 1; }
|
||||
|
||||
# [0/5] 部署源前置检查(实现见 cma-deploy-dirty-guard.sh)
|
||||
# 2026-09-21 起:开发区脏净**不再**中止部署(部署内容 = 已提交的 origin/main 树,见 deploy.sh);
|
||||
# 本步只打印开发区状态供留痕,仅「仓库不可用」失败关闭。
|
||||
DIRTY_GUARD=/usr/local/sbin/cma-deploy-dirty-guard.sh
|
||||
echo "[deploy-hook] [0/5] 部署源前置检查(开发区脏净仅告警)..."
|
||||
if [ ! -r "$DIRTY_GUARD" ]; then
|
||||
echo "[deploy-hook] ✗ 前置检查脚本缺失或不可读: $DIRTY_GUARD(按失败关闭中止部署)"
|
||||
exit 1
|
||||
fi
|
||||
if ! bash "$DIRTY_GUARD" /root/cma-management; then
|
||||
echo "[deploy-hook] ✗ 仓库不可用 → 中止部署"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash deploy.sh 2>&1
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "[deploy-hook] ✓ 部署成功"
|
||||
else
|
||||
echo "[deploy-hook] ✗ 部署失败 (exit: $rc)"
|
||||
fi
|
||||
exit "$rc"
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 宿主部署链组件安装/升级(CMA)
|
||||
# cma-deploy-source-decouple-20260921(单 project-20260921-deploy-dirtyguard-rootfix)
|
||||
#
|
||||
# 把本仓 deploy/ 下的宿主侧组件安装到 /usr/local/sbin/(改前自动备份、改后校验 md5)。
|
||||
# 组件(宿主侧,容器内 gitea post-receive → SSH forced-command 进入本仓库时执行):
|
||||
# gitea-cma-deploy-hook.sh → /usr/local/sbin/gitea-cma-deploy-hook.sh
|
||||
# cma-deploy-dirty-guard.sh → /usr/local/sbin/cma-deploy-dirty-guard.sh
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/install-host-deploy-hook.sh # 安装/升级(含备份 + 校验)
|
||||
# bash deploy/install-host-deploy-hook.sh --check # 只比对(不写任何文件)
|
||||
#
|
||||
# 说明:
|
||||
# - 备份目录 /root/backups/cma-deploy-source-decouple-<YYYYMMDD-HHMMSS>/
|
||||
# - 一行回滚: cp <备份目录>/<文件名> /usr/local/sbin/<文件名> && md5sum -c <备份目录>/MD5SUMS.rollback
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SRC_DIR="$REPO_DIR/deploy"
|
||||
DST_DIR="/usr/local/sbin"
|
||||
FILES=(gitea-cma-deploy-hook.sh cma-deploy-dirty-guard.sh)
|
||||
MODE="${1:-install}"
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
[ -r "$SRC_DIR/$f" ] || { echo "❌ 源文件缺失: $SRC_DIR/$f"; exit 1; }
|
||||
done
|
||||
|
||||
if [ "$MODE" = "--check" ]; then
|
||||
rc=0
|
||||
for f in "${FILES[@]}"; do
|
||||
if [ -r "$DST_DIR/$f" ] && cmp -s "$SRC_DIR/$f" "$DST_DIR/$f"; then
|
||||
echo "✓ 一致: $DST_DIR/$f"
|
||||
else
|
||||
echo "≠ 不一致: $DST_DIR/$f(需要安装)"; rc=1
|
||||
fi
|
||||
done
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
BAK_DIR="/root/backups/cma-deploy-source-decouple-$TS"
|
||||
mkdir -p "$BAK_DIR"
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
if [ -e "$DST_DIR/$f" ]; then
|
||||
cp -a "$DST_DIR/$f" "$BAK_DIR/$f"
|
||||
md5sum "$DST_DIR/$f" >> "$BAK_DIR/MD5SUMS.rollback"
|
||||
else
|
||||
echo "<absent>" >> "$BAK_DIR/MD5SUMS.rollback"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
install -m 0755 -o root -g root "$SRC_DIR/$f" "$DST_DIR/$f"
|
||||
done
|
||||
|
||||
echo "备份目录: $BAK_DIR"
|
||||
for f in "${FILES[@]}"; do
|
||||
echo "已安装: $DST_DIR/$f md5=$(md5sum "$DST_DIR/$f" | awk '{print $1}')"
|
||||
done
|
||||
bash -n "$DST_DIR/gitea-cma-deploy-hook.sh" && echo "✓ 语法检查通过"
|
||||
echo "✓ 安装完成(生效于下一次 push 触发的部署)"
|
||||
@@ -0,0 +1,67 @@
|
||||
# 口径登记:审计留痕(`operation_logs`)与通知发送记录(`notification_logs`)
|
||||
|
||||
- **立档**: wecom-fullstack,2026-09-22
|
||||
- **来源**: DAMA 独立回查 `dama-recheck-workbench-entity-isolation-20260922`(2026-09-22 11:07–11:20)
|
||||
§4b(审计字面与库内既有口径不一致 / 无 `entity_id` 列 / `user_id=None`)与
|
||||
§5(B2 上线后 `/notifications/logs` 在 e1/e2 均为 0 条 = 口径副作用)两处待办
|
||||
- **性质**: 口径登记 + 代码收口说明。**不含**任何数据订正(数据订正另立单 + 审批)
|
||||
|
||||
---
|
||||
|
||||
## 一、`operation_logs` 审计字面字典(换账套类)
|
||||
|
||||
| action 字面 | 写入端点 | 语义 | target_type | target_id |
|
||||
|---|---|---|---|---|
|
||||
| `switch_entity` | `post /api/cma/auth/switch-entity`(`app/api/auth.py`) | 改**用户自己的会话账套**(重签 token、旧 token 下线) | `entity` | 目标 `entity_id` |
|
||||
| `switch_tenant_state` | `post /api/cma/tenant/switch`(`app/api/tenant.py`) | 改**平台全局状态记录** `system_configs.current_tenant`(项目Bot 分发 A2A 任务用;**非数据维度**) | `entity` | 目标 `entity_id` |
|
||||
|
||||
**为什么两个字面不合并**:两者作用域不同(个人会话 vs 全局单值键),合并后审计上不可分辨
|
||||
「谁把自己切走了」与「谁改了全平台状态」。DAMA 复核提出「统一为 `switch_entity` 或登记字典」——
|
||||
本档取**后者(登记不合并)**,原因是前者会丢语义可分辨性;若后续裁定要求合并,改动面为
|
||||
`app/api/tenant.py` 一行字面 + 本表一行(可逆)。
|
||||
|
||||
**可归属与可过滤(2026-09-22 收口,随本档同一提交落地)**:
|
||||
|
||||
- `operation_logs` **无** `entity_id` 列(仅 `user_id / action / target_type / target_id / detail / created_at`)。
|
||||
故「按账套查换账套记录」不靠新列,而靠**目标维度**:
|
||||
```sql
|
||||
SELECT * FROM operation_logs WHERE target_type='entity' AND target_id=? ORDER BY id DESC;
|
||||
```
|
||||
- `/tenant/switch` 原写 `user_id=None`(端点无当前用户注入)⇒ 事后无法追操作人。
|
||||
已改为注入会话用户(`user=Depends(require_auth_401)` → `user_id=user.id`),
|
||||
并把 `target_type='entity'` / `target_id=entity_id` 与 `switch_entity` 对齐。
|
||||
- `detail` JSON 仍保留 `entity_id / entity_name / source / previous`(人读明细)。
|
||||
|
||||
**现网实况(只读核对,2026-09-22 11:15,`cma_reader` 16/16 PASS)**:
|
||||
`switch_entity` = 49 行、`switch_tenant_state` = 0 行(本单尚未部署 ⇒ 字面是新引入的,
|
||||
上线前库里无该字面属正常)。ops 止血那次 `POST /tenant/switch`(10:36:01,部署码)**没有**审计行——
|
||||
部署码该端点无审计,正是本单补齐项。
|
||||
|
||||
---
|
||||
|
||||
## 二、`/api/cma/notifications/logs` 口径(发送记录页)
|
||||
|
||||
- **归属口径**:`notification_logs` 无 `entity_id` 列 ⇒ 经**父键** join 归属:
|
||||
`notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`;按会话账套过滤,
|
||||
与 batch5 对 `kpi_data_quality_log` 的处置同口径(**宁少勿错**)。
|
||||
- **`alert_id IS NULL` 的行**:无可归属父键 ⇒ **不返回给任何账套**(不跨账套暴露)。
|
||||
现库 8 行历史记录(2026-09-11/12「OKR 季度复盘提醒」)**100% 属此类**,
|
||||
写入方 = `backend/scripts/okr_review_reminder.py`(复用本表留存审计,`alert_id` 留空)。
|
||||
- **上线后现象(须预期,不是故障)**:`/notifications/logs` 在 e1/e2 均返回 `total=0`,
|
||||
前端发送记录页为空。现役预警推送写入器 `app/utils/notifier.py` **必带** `alert_id`,
|
||||
故此后由预警产生的新记录会正常显示;只有「无父键历史行」永不出现在该页。
|
||||
- **页面与接口的提示(随本档同一提交落地)**:
|
||||
- 接口返回 `policy="alert-linked-only"` + `note`(说明「仅显示与预警关联的推送」);
|
||||
- 前端 `NotificationManage.vue` 发送记录表 `empty-text` 写明同一口径。
|
||||
- **未做(留待 P2 裁定)**:为无父键通知单列只读视图(需先给 `notification_logs` 补账套维度,
|
||||
属 DDL/迁移,超出「零迁移 B2」范围)。
|
||||
|
||||
---
|
||||
|
||||
## 三、本档未覆盖(另立单,见对应任务文件)
|
||||
|
||||
1. `kpi_alerts` id 589/590 跨账套错挂(`entity_id=1` 挂 e2 的 `BH_ADM_EXPENSE`):数据订正须
|
||||
另立单 + 任总放行(生产写)。
|
||||
2. `kpi_id=1` 兜底残留清点(9 行、其中 1 行 `pending`)与归属裁定。
|
||||
3. 同源全局键继续拆账套:`kpi_alert_higher_better`、`kpi_visibility`(DAMA §7 反向挑错新增漏项)、
|
||||
`budget_mode` / `budget_driver_mode`(batch5 §七 已记录未施工)。
|
||||
@@ -202,6 +202,7 @@ export const actionPlanApi = {
|
||||
update: (id: number, data: any) => api.put(`/action-plans/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/action-plans/${id}`),
|
||||
cosoChecklist: (params?: any) => api.get('/action-plans/coso-checklist', { params }),
|
||||
cosoGenerateActions: (data?: any) => api.post('/action-plans/coso-generate-actions', data || {}),
|
||||
stats: () => api.get('/action-plans/stats'),
|
||||
}
|
||||
|
||||
|
||||
@@ -61,21 +61,24 @@
|
||||
<div v-if="prevRevenue" class="pb-compare">
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">营收较上期</span>
|
||||
<span :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
<span v-if="revChg != null" :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ revChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(revChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
<span v-else style="color:#909399">缺上期数据</span>
|
||||
</div>
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">净利润较上期</span>
|
||||
<span :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
<span v-if="profitChg != null" :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ profitChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(profitChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
<span v-else style="color:#909399">缺上期数据</span>
|
||||
</div>
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">净利率较上期</span>
|
||||
<span :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
<span v-if="rateChg != null" :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ rateChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(rateChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
<span v-else style="color:#909399">缺上期数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,19 +132,22 @@ const costRate = computed(() => {
|
||||
return (props.cost / props.revenue * 100).toFixed(1) + '%'
|
||||
})
|
||||
|
||||
const revChg = computed(() => {
|
||||
if (!props.prevRevenue) return 0
|
||||
// 缺失值 NULL 语义(财务裁定 2026-09-21 §2):上期缺失/为 0 时返回 null,
|
||||
// 不得回落 0(0 会被显示成「持平」,把「无数据」当真实 0 参与同环比)。
|
||||
const revChg = computed<number | null>(() => {
|
||||
if (props.prevRevenue == null || props.prevRevenue === 0) return null
|
||||
return (props.revenue - props.prevRevenue) / props.prevRevenue
|
||||
})
|
||||
const profitChg = computed(() => {
|
||||
if (!props.prevProfit) return 0
|
||||
const profitChg = computed<number | null>(() => {
|
||||
if (props.prevProfit == null || props.prevProfit === 0) return null
|
||||
return (props.profit - props.prevProfit) / Math.abs(props.prevProfit)
|
||||
})
|
||||
const rateChg = computed(() => {
|
||||
if (!props.prevRevenue || !props.prevProfit) return 0
|
||||
const rateChg = computed<number | null>(() => {
|
||||
if (props.prevRevenue == null || props.prevProfit == null || props.prevRevenue === 0) return null
|
||||
const cur = props.profit / props.revenue
|
||||
const prev = props.prevProfit / props.prevRevenue
|
||||
return (cur - prev) / Math.abs(prev || 0.01)
|
||||
if (prev === 0) return null
|
||||
return (cur - prev) / Math.abs(prev)
|
||||
})
|
||||
|
||||
const detailItems = computed(() => {
|
||||
|
||||
@@ -294,7 +294,8 @@
|
||||
<!-- 企业只读标识(不可点):账套由登录窗口选定,页内无切换入口 -->
|
||||
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
|
||||
<el-button size="small" @click="loadCosoChecklist" :loading="cosoLoading">刷新</el-button>
|
||||
<el-button v-if="cosoData" type="primary" size="small" style="margin-left:12px;">生成改善行动</el-button>
|
||||
<el-button v-if="cosoData" type="primary" size="small" style="margin-left:12px;"
|
||||
:loading="cosoGenerating" @click="generateCosoActions">生成改善行动<span v-if="cosoFailedCount">({{ cosoFailedCount }})</span></el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="cosoData">
|
||||
@@ -667,6 +668,38 @@ async function loadCosoChecklist() {
|
||||
cosoLoading.value = false
|
||||
}
|
||||
|
||||
const cosoGenerating = ref(false)
|
||||
// 自检未通过(passed=false)条目数——即「生成改善行动」将覆盖的范围
|
||||
const cosoFailedCount = computed(() => {
|
||||
const els = cosoData.value?.elements || []
|
||||
return els.reduce((n: number, el: any) => n + (el.items || []).filter((it: any) => !it.passed).length, 0)
|
||||
})
|
||||
|
||||
async function generateCosoActions() {
|
||||
if (!cosoData.value) return
|
||||
const n = cosoFailedCount.value
|
||||
if (n === 0) {
|
||||
ElMessage.info('当前自检没有未通过条目,无需生成改善行动')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将把自检中 ${n} 条未通过条目生成改善行动(已存在的不会重复生成),是否继续?`,
|
||||
'生成改善行动'
|
||||
)
|
||||
} catch { return }
|
||||
cosoGenerating.value = true
|
||||
try {
|
||||
const r: any = await actionPlanApi.cosoGenerateActions({})
|
||||
const created = r?.created ?? 0
|
||||
const skipped = r?.skipped ?? 0
|
||||
ElMessage.success(`已生成 ${created} 条改善行动${skipped ? `,跳过 ${skipped} 条已存在` : ''}`)
|
||||
await Promise.all([loadData(), loadStats()])
|
||||
if (created > 0) activeTab.value = 'plans' // 切到行动方案列表即可确认
|
||||
} catch { ElMessage.error('生成改善行动失败') }
|
||||
cosoGenerating.value = false
|
||||
}
|
||||
|
||||
// ── 初始化 ──
|
||||
onMounted(async () => {
|
||||
loadStats()
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
</template></el-table-column>
|
||||
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
|
||||
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
|
||||
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
|
||||
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress v-if="row.execution_rate != null" :percentage="row.execution_rate" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /><span v-else style="color:#909399">-</span></template></el-table-column>
|
||||
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -1094,7 +1094,7 @@ async function loadVersionDiff() { if (selectedVersions.value.length !== 2) retu
|
||||
const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([])
|
||||
async function loadExecutionReport() {
|
||||
execLoading.value = true
|
||||
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true, entity_id: getEntityId() }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: i.budget_value && i.budget_value > 0 ? Math.round((i.actual_value || 0) / i.budget_value * 100) : 0, deviation_value: (i.actual_value || 0) - (i.budget_value || 0) })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const ar = hb.length > 0 ? Math.round(hb.reduce((s, i) => s + (i.execution_rate || 0), 0) / hb.length) : 0; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: `${ar}%`, color: ar > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
|
||||
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true, entity_id: getEntityId() }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: (i.budget_value && i.budget_value > 0 && i.actual_value != null) ? Math.round(i.actual_value / i.budget_value * 100) : null, deviation_value: i.actual_value != null ? i.actual_value - (i.budget_value ?? 0) : null })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const hbR = hb.filter((i: any) => i.execution_rate != null); const ar = hbR.length > 0 ? Math.round(hbR.reduce((s: number, i: any) => s + i.execution_rate, 0) / hbR.length) : null; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: ar == null ? '-' : `${ar}%`, color: (ar ?? 0) > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
|
||||
execLoading.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -342,19 +342,20 @@ async function loadTrend() {
|
||||
const keyB = b.period || ''
|
||||
return keyA.localeCompare(keyB)
|
||||
})
|
||||
// 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual/budget 缺失保留 null(不回落 0)
|
||||
trendData.value = monthly.map((i: any) => ({
|
||||
period: i.period,
|
||||
actual: i.actual_value || 0,
|
||||
budget: i.budget_value || 0,
|
||||
actual: i.actual_value ?? null,
|
||||
budget: i.budget_value ?? null,
|
||||
deviation: i.deviation_rate,
|
||||
}))
|
||||
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual, d.budget]), 1)
|
||||
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual ?? 0, d.budget ?? 0]), 1)
|
||||
} catch (e) { /* ignore */ }
|
||||
trendLoading.value = false
|
||||
}
|
||||
|
||||
function barHeight(val: number, max: number) {
|
||||
if (!max) return 0
|
||||
function barHeight(val: number | null, max: number) {
|
||||
if (!max || val == null) return 0 // 缺失值 NULL 语义:不画柱,不以 0 参与
|
||||
return Math.max(4, (val / max) * 150)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<!-- 模式选择器 -->
|
||||
<div v-if="showSelector" class="align-setup">
|
||||
<el-alert v-if="!alignConfigured" title="系统尚未配置KPI对齐模式" type="warning" :closable="false" show-icon class="section-gap" />
|
||||
<el-alert type="info" :closable="false" show-icon title="系统级设置,影响全部账套"
|
||||
description="对齐模式保存在系统级配置中,切换后将同时作用于全部账套。" style="margin-bottom:12px;" />
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||
<el-card v-for="mode in alignModes" :key="mode.key"
|
||||
:class="{ 'mode-card': true, 'selected-mode': selectedMode === mode.key }"
|
||||
@@ -136,7 +138,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
// 统一走 api/index.ts 的 axios 实例(拦截器自动带 Bearer / 401 跳登录)
|
||||
import { alignmentApi, mapApi } from '../api/index'
|
||||
|
||||
@@ -286,6 +288,14 @@ async function loadConfig() {
|
||||
|
||||
async function saveAlignConfig() {
|
||||
if (!selectedMode.value) return
|
||||
// 系统级设置二次确认:对齐模式存于全局配置(role_permissions 无 entity_id),切换影响全部账套
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`对齐模式为系统级设置,切换后将立即作用于全部账套。确认切换为「${selectedModeName.value}」?`,
|
||||
'系统级设置二次确认',
|
||||
{ type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
saving.value = true
|
||||
try {
|
||||
// 修复:原 `'Bearer ' + token` 模板字面量 bug(header 值被写死成字面量 → 必 401)
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<div class="section-header">
|
||||
<span class="section-icon">📊</span>
|
||||
<span class="section-title">我的KPI</span>
|
||||
<span v-if="dataAsOf" class="section-asof">数据截至 {{ dataAsOf }}</span>
|
||||
<span class="section-action" @click="$router.push('/kpis')">查看全部 →</span>
|
||||
</div>
|
||||
<div v-if="kpis.length === 0" class="empty-card">
|
||||
@@ -70,17 +71,22 @@
|
||||
<span class="kpi-tag" :class="'tag-' + k.level">{{ k.level === 'green' ? '达标' : k.level === 'yellow' ? '预警' : k.level === 'red' ? '危险' : '未评' }}</span>
|
||||
</div>
|
||||
<div class="kpi-value-row">
|
||||
<div class="kpi-actual" :class="{ 'kpi-actual-empty': k.actual_value == null }">
|
||||
<div class="kpi-actual" :class="{ 'kpi-actual-empty': k.actual_value == null, 'kpi-actual-stale': isStale(k) }">
|
||||
{{ k.actual_value == null ? '暂无数据' : periodActual(k) }}
|
||||
</div>
|
||||
<div class="kpi-target">{{ targetLabel(k) }} {{ k.target_value ?? '-' }}</div>
|
||||
<div class="kpi-target">
|
||||
<span v-if="isStale(k)" class="kpi-stale-tag">非当期</span>{{ targetLabel(k) }} {{ k.target_value ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual 缺失时不得显示 0% 进度 -->
|
||||
<el-progress
|
||||
:percentage="k.target_value ? Math.min(100, Math.round((k.actual_value || 0) / k.target_value * 100)) : 0"
|
||||
v-if="k.actual_value != null && k.target_value"
|
||||
:percentage="Math.min(100, Math.round(k.actual_value / k.target_value * 100))"
|
||||
:status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
/>
|
||||
<div v-else style="height:6px;line-height:6px;color:#c0c4cc;font-size:11px">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,6 +180,23 @@ function targetLabel(k: any) {
|
||||
return '目标(月)'
|
||||
}
|
||||
|
||||
// 2026-09-18 口径小改(工作台期间缺口):月期间正则 + 页面最新可得月
|
||||
const MONTH_PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/
|
||||
const latestMonth = computed(() => {
|
||||
const months = kpis.value
|
||||
.map((k: any) => String(k.period || ''))
|
||||
.filter((p: string) => MONTH_PERIOD_RE.test(p))
|
||||
.sort()
|
||||
return months.length ? months[months.length - 1] : ''
|
||||
})
|
||||
// 页头「数据截至 X 月」= 本页各卡中最新的月期间(无月期间时不出该文案,不编造时点)
|
||||
const dataAsOf = computed(() => (latestMonth.value ? fmtPeriod(latestMonth.value) : ''))
|
||||
// 非当期 = 该卡的值期间早于页面最新可得月(仍保留原期间标签,只加灰标提示,不隐藏)
|
||||
function isStale(k: any): boolean {
|
||||
const p = String(k.period || '')
|
||||
return MONTH_PERIOD_RE.test(p) && !!latestMonth.value && p < latestMonth.value
|
||||
}
|
||||
|
||||
function planIcon(p: any) {
|
||||
if (p.status === 'completed') return '✅'
|
||||
if (p.overdue) return '🔴'
|
||||
@@ -262,6 +285,8 @@ onMounted(async () => {
|
||||
}
|
||||
.section-action { font-size: 13px; color: #409eff; cursor: pointer; }
|
||||
.section-action:hover { text-decoration: underline; }
|
||||
/* 数据截至 X 月(2026-09-18):说明本页各卡的最新可得期间,不改渲染结构 */
|
||||
.section-asof { font-size: 12px; color: #999; margin-right: 10px; }
|
||||
.loading-wrap { padding: 40px; }
|
||||
|
||||
/* ── 空状态 ── */
|
||||
@@ -310,6 +335,12 @@ onMounted(async () => {
|
||||
.kpi-value-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
|
||||
.kpi-actual { font-size: 24px; font-weight: 700; }
|
||||
.kpi-actual-empty { font-size: 15px; font-weight: 500; color: #999; }
|
||||
/* 非当期灰标(2026-09-18):值期间落后于页面最新可得月时置灰,期间标签照旧显示 */
|
||||
.kpi-actual-stale { color: #999; }
|
||||
.kpi-stale-tag {
|
||||
font-size: 11px; color: #999; background: #f0f0f0;
|
||||
padding: 0 6px; border-radius: 4px; margin-right: 6px;
|
||||
}
|
||||
.kpi-target { font-size: 12px; color: #999; }
|
||||
|
||||
/* ── 改善行动 ── */
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
|
||||
<!-- 发送记录 -->
|
||||
<h3 class="section-title">发送记录</h3>
|
||||
<el-table :data="logs" style="width:100%;margin-top:8px;" size="small">
|
||||
<el-table :data="logs" style="width:100%;margin-top:8px;" size="small"
|
||||
empty-text="仅显示与预警推送关联的记录;无账套归属的历史通知不在此列">
|
||||
<el-table-column prop="created_at" label="时间" width="160" />
|
||||
<el-table-column prop="title" label="标题" min-width="200" />
|
||||
<el-table-column prop="channel" label="渠道" width="80" />
|
||||
|
||||
Reference in New Issue
Block a user