Compare commits
55
Commits
cb318115e8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cccaa6cfc | ||
|
|
2241a89fd1 | ||
|
|
c45dc554ed | ||
|
|
e34d375b01 | ||
|
|
e216565d2b | ||
|
|
d691cd7d2e | ||
|
|
dc89b14479 | ||
|
|
0a9b3d5bf6 | ||
|
|
815acda0da | ||
|
|
26e0c8da53 | ||
|
|
79bc877f31 | ||
|
|
74dc9baff5 | ||
|
|
72072dda8c | ||
|
|
076bd0dae0 | ||
|
|
df93b635b3 | ||
|
|
136fe3811f | ||
|
|
27d8269667 | ||
|
|
db0f7aa591 | ||
|
|
6910d90288 | ||
|
|
2da4cfc42d | ||
|
|
ad68471b29 | ||
|
|
5b920df8a0 | ||
|
|
974ec48564 | ||
|
|
0c79ba32c8 | ||
|
|
c36ed35348 | ||
|
|
240a9fa899 | ||
|
|
497f378957 | ||
|
|
94aeb14e95 | ||
|
|
3bc68fa1c6 | ||
|
|
b9c624fd7e | ||
|
|
1a6ea8afae | ||
|
|
758f820970 | ||
|
|
5ceda333e2 | ||
|
|
4864d5762e | ||
|
|
fb99a5e5b0 | ||
|
|
fd814462a2 | ||
|
|
3c64dc767e | ||
|
|
e82a6af59c | ||
|
|
ec5304a1d0 | ||
|
|
9f787e628b | ||
|
|
fdc42d443d | ||
|
|
6b479bfe7d | ||
|
|
61936302b2 | ||
|
|
87cb9e7db2 | ||
|
|
9b61c48051 | ||
|
|
95b73c9b90 | ||
|
|
e330545d59 | ||
|
|
35974514da | ||
|
|
ba8e2f112b | ||
|
|
37223d2afb | ||
|
|
b9076e6818 | ||
|
|
a253eadeb0 | ||
|
|
9d712046f3 | ||
|
|
a64f184525 | ||
|
|
6af4366a84 |
@@ -11,3 +11,6 @@ __pycache__/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
venv/
|
venv/
|
||||||
backend/logs/
|
backend/logs/
|
||||||
|
|
||||||
|
# 因果链验证报告(生成物)
|
||||||
|
backend/scripts/reports/
|
||||||
|
|||||||
+20
-10
@@ -22,6 +22,16 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- path: frontend/**
|
- path: frontend/**
|
||||||
|
|
||||||
|
frontend-typecheck:
|
||||||
|
image: node:20-alpine
|
||||||
|
commands:
|
||||||
|
- cd frontend
|
||||||
|
- npm install -g pnpm
|
||||||
|
- pnpm install
|
||||||
|
- pnpm vue-tsc --noEmit || true
|
||||||
|
when:
|
||||||
|
- path: frontend/**
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
commands:
|
commands:
|
||||||
@@ -32,6 +42,15 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- path: frontend/**
|
- path: frontend/**
|
||||||
|
|
||||||
|
backend-test:
|
||||||
|
image: python:3.12
|
||||||
|
commands:
|
||||||
|
- cd backend
|
||||||
|
- pip install -r requirements.txt --quiet
|
||||||
|
- python -m pytest tests/ -x -q --disable-warnings --timeout=60 || true
|
||||||
|
when:
|
||||||
|
- path: backend/**
|
||||||
|
|
||||||
frontend-deploy:
|
frontend-deploy:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
secrets:
|
secrets:
|
||||||
@@ -49,15 +68,6 @@ steps:
|
|||||||
- SSH_DEPLOY_KEY
|
- SSH_DEPLOY_KEY
|
||||||
commands:
|
commands:
|
||||||
- *ssh_setup
|
- *ssh_setup
|
||||||
- ssh root@git.sxbh.ltd '
|
- ssh root@git.sxbh.ltd 'set -e; cd /root/cma-management; DIRTY=$(git status --porcelain 2>/dev/null | grep -v "__pycache__" | head -10); if [ -n "$DIRTY" ]; then echo "❌ 宿主机有未提交修改,中止部署:"; echo "$DIRTY"; exit 1; fi; git pull origin main; cd backend; pip install -r requirements.txt --quiet --no-cache-dir; systemctl restart cma-backend; sleep 3; curl -sf http://127.0.0.1:8010/health'
|
||||||
cd /root/cma-management &&
|
|
||||||
git pull origin main &&
|
|
||||||
cd backend &&
|
|
||||||
pip install -r requirements.txt --quiet --no-cache-dir &&
|
|
||||||
pkill -f uvicorn 2>/dev/null
|
|
||||||
sleep 2
|
|
||||||
cd /root/cma-management/backend &&
|
|
||||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8010 > /var/log/cma-backend.log 2>&1 &
|
|
||||||
'
|
|
||||||
when:
|
when:
|
||||||
- path: backend/**
|
- path: backend/**
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
worktree测试文件
|
|
||||||
@@ -9,7 +9,8 @@ from calendar import monthrange
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_role, require_auth
|
from app.auth_middleware import require_role, require_auth
|
||||||
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective
|
from app.models import ActionPlan, KPIAlert, KPIDefinition, KPIValue, User, Objective, KR
|
||||||
|
from app.api.verify import build_auto_verify_rule
|
||||||
|
|
||||||
logger = logging.getLogger("cma.action_plans")
|
logger = logging.getLogger("cma.action_plans")
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ def plan_to_dict(p: ActionPlan) -> dict:
|
|||||||
"alert_id": p.alert_id,
|
"alert_id": p.alert_id,
|
||||||
"kpi_id": p.kpi_id,
|
"kpi_id": p.kpi_id,
|
||||||
"objective_id": p.objective_id,
|
"objective_id": p.objective_id,
|
||||||
|
"kr_id": p.kr_id,
|
||||||
"title": p.title,
|
"title": p.title,
|
||||||
"description": p.description,
|
"description": p.description,
|
||||||
"assignee": p.assignee,
|
"assignee": p.assignee,
|
||||||
@@ -87,6 +89,7 @@ def list_plans(
|
|||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
kpi_id: Optional[int] = None,
|
kpi_id: Optional[int] = None,
|
||||||
alert_id: Optional[int] = None,
|
alert_id: Optional[int] = None,
|
||||||
|
keyword: Optional[str] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_auth),
|
current_user: User = Depends(require_auth),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
@@ -100,6 +103,8 @@ def list_plans(
|
|||||||
query = query.filter(ActionPlan.kpi_id == kpi_id)
|
query = query.filter(ActionPlan.kpi_id == kpi_id)
|
||||||
if alert_id:
|
if alert_id:
|
||||||
query = query.filter(ActionPlan.alert_id == alert_id)
|
query = query.filter(ActionPlan.alert_id == alert_id)
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
|
||||||
|
|
||||||
# business角色只看自己的
|
# business角色只看自己的
|
||||||
if current_user.role == "business":
|
if current_user.role == "business":
|
||||||
@@ -115,6 +120,16 @@ def list_plans(
|
|||||||
# 附带KPI名称
|
# 附带KPI名称
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||||
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
|
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
|
||||||
|
item["kpi_code"] = kpi.kpi_code if kpi else None
|
||||||
|
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()
|
||||||
|
item["kr_title"] = kr.title if kr else None
|
||||||
|
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
|
||||||
|
else:
|
||||||
|
item["kr_title"] = None
|
||||||
|
item["kr_metric_kpi_id"] = None
|
||||||
result.append(item)
|
result.append(item)
|
||||||
|
|
||||||
return {"data": result}
|
return {"data": result}
|
||||||
@@ -137,6 +152,16 @@ def create_plan(
|
|||||||
if not kpi_ent or kpi_ent.entity_id != entity_id:
|
if not kpi_ent or kpi_ent.entity_id != entity_id:
|
||||||
raise HTTPException(404, "关联KPI不存在")
|
raise HTTPException(404, "关联KPI不存在")
|
||||||
|
|
||||||
|
# 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业
|
||||||
|
kr_id = data.get("kr_id")
|
||||||
|
if kr_id:
|
||||||
|
kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first()
|
||||||
|
if not kr_ent:
|
||||||
|
raise HTTPException(404, "关联KR不存在")
|
||||||
|
# 未显式传objective_id时从KR继承目标
|
||||||
|
if not data.get("objective_id"):
|
||||||
|
data["objective_id"] = kr_ent.objective_id
|
||||||
|
|
||||||
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
|
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
|
||||||
|
|
||||||
# 校验截止日期与关联Objective的季度匹配
|
# 校验截止日期与关联Objective的季度匹配
|
||||||
@@ -150,6 +175,7 @@ def create_plan(
|
|||||||
alert_id=data.get("alert_id"),
|
alert_id=data.get("alert_id"),
|
||||||
kpi_id=data["kpi_id"],
|
kpi_id=data["kpi_id"],
|
||||||
objective_id=objective_id,
|
objective_id=objective_id,
|
||||||
|
kr_id=kr_id,
|
||||||
title=data["title"],
|
title=data["title"],
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
assignee=data.get("assignee"),
|
assignee=data.get("assignee"),
|
||||||
@@ -158,7 +184,22 @@ def create_plan(
|
|||||||
status="pending",
|
status="pending",
|
||||||
progress=0,
|
progress=0,
|
||||||
created_by=current_user.name or current_user.username,
|
created_by=current_user.name or current_user.username,
|
||||||
|
auto_verify_rule=data.get("auto_verify_rule"), # 显式规则原样保存;None 时下面自动生成
|
||||||
)
|
)
|
||||||
|
# 第三阶段接入(2026-08-31):创建 ActionPlan 自动生成验证规则
|
||||||
|
# 请求体未传 auto_verify_rule 且关联 KPI 存在 → 复用 verify.build_auto_verify_rule 生成默认规则(不改变现有创建行为;auto_close 默认 false)
|
||||||
|
if not data.get("auto_verify_rule"):
|
||||||
|
baseline_value = data.get("baseline_value")
|
||||||
|
if baseline_value is None:
|
||||||
|
# 基线缺省时取 KPI 当前最新值(period <= 当前月),供 kpi_current_before 回填
|
||||||
|
latest_kpi = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi_ent.id,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
KPIValue.period <= datetime.now().strftime("%Y-%m"),
|
||||||
|
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
|
||||||
|
if latest_kpi:
|
||||||
|
baseline_value = latest_kpi.actual_value
|
||||||
|
plan.auto_verify_rule = build_auto_verify_rule(kpi_ent, baseline_value=baseline_value)
|
||||||
db.add(plan)
|
db.add(plan)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(plan)
|
db.refresh(plan)
|
||||||
@@ -214,6 +255,21 @@ def update_plan(
|
|||||||
plan.assignee = data["assignee"]
|
plan.assignee = data["assignee"]
|
||||||
if "priority" in data:
|
if "priority" in data:
|
||||||
plan.priority = data["priority"]
|
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
|
||||||
|
if "objective_id" in data:
|
||||||
|
plan.objective_id = data["objective_id"]
|
||||||
if "due_date" in data:
|
if "due_date" in data:
|
||||||
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
|
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||||
if "status" in data:
|
if "status" in data:
|
||||||
|
|||||||
+263
-13
@@ -2,20 +2,247 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, text as sa_text
|
from sqlalchemy import func, text as sa_text, or_
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan
|
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion
|
||||||
from app.utils.cache import get as cache_get, set as cache_set
|
from app.utils.cache import get as cache_get, set as cache_set
|
||||||
import json, hashlib, httpx, os
|
import json, hashlib, httpx, os, urllib.request, urllib.parse
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
|
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
|
||||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# R1 决策建议生成(规则驱动,稳定可复现,落库 ai_suggestions)
|
||||||
|
# ============================================================
|
||||||
|
def _sug_dict(s: AISuggestion) -> dict:
|
||||||
|
return {
|
||||||
|
"id": s.id,
|
||||||
|
"entity_id": s.entity_id,
|
||||||
|
"source": s.source,
|
||||||
|
"suggestion_type": s.suggestion_type,
|
||||||
|
"target_type": s.target_type,
|
||||||
|
"category": s.category or "decision",
|
||||||
|
"pushed": s.pushed or 0,
|
||||||
|
"target_id": s.target_id,
|
||||||
|
"title": s.title,
|
||||||
|
"content": s.content,
|
||||||
|
"suggestion_data": s.suggestion_data or {},
|
||||||
|
"status": s.status,
|
||||||
|
"applied_by": s.applied_by,
|
||||||
|
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
|
||||||
|
"apply_detail": s.apply_detail or [],
|
||||||
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_unapplied(db: Session, entity_id: int, suggestion_type: str,
|
||||||
|
target_id: int, title: str) -> bool:
|
||||||
|
"""幂等:同entity+类型+目标+标题的未应用建议存在则跳过"""
|
||||||
|
return db.query(AISuggestion).filter(
|
||||||
|
AISuggestion.entity_id == entity_id,
|
||||||
|
AISuggestion.suggestion_type == suggestion_type,
|
||||||
|
AISuggestion.target_id == target_id,
|
||||||
|
AISuggestion.title == title,
|
||||||
|
AISuggestion.status == "unapplied",
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_rule_suggestions(db: Session, entity_id: int,
|
||||||
|
source: str = "dashboard", user_id: int = None,
|
||||||
|
kpi_id: int = None) -> list:
|
||||||
|
"""从数据规则生成决策建议并落库(R1,路线图2026-08-30)
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. KPI执行率<70% → 建议建行动方案(异常类)
|
||||||
|
2. KPI执行率>110% → 建议上调KPI目标(机会类)
|
||||||
|
3. 预算执行率>110% → 建议调预算(预算类)
|
||||||
|
4. 有pending预警 → 建议建行动方案处理预警
|
||||||
|
幂等:同 entity+type+target_id+title+status=unapplied 不重复建。
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
period = now.strftime("%Y-%m")
|
||||||
|
created = []
|
||||||
|
|
||||||
|
def _add(suggestion_type: str, target_type: str, tid: int,
|
||||||
|
title: str, content: str, suggestion_data: dict):
|
||||||
|
nonlocal created
|
||||||
|
if _existing_unapplied(db, entity_id, suggestion_type, tid, title):
|
||||||
|
return
|
||||||
|
sug = AISuggestion(
|
||||||
|
entity_id=entity_id,
|
||||||
|
user_id=user_id,
|
||||||
|
source=source,
|
||||||
|
suggestion_type=suggestion_type,
|
||||||
|
target_type=target_type,
|
||||||
|
category="alert" if target_type == "alert" else "decision",
|
||||||
|
target_id=tid,
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
suggestion_data=suggestion_data,
|
||||||
|
status="unapplied",
|
||||||
|
)
|
||||||
|
db.add(sug)
|
||||||
|
created.append(sug)
|
||||||
|
|
||||||
|
# 查询KPI(可按kpi_id过滤)
|
||||||
|
q = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active")
|
||||||
|
if kpi_id:
|
||||||
|
q = q.filter(KPIDefinition.id == kpi_id)
|
||||||
|
kpis = q.all()
|
||||||
|
|
||||||
|
for k in kpis:
|
||||||
|
latest = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id,
|
||||||
|
or_(
|
||||||
|
KPIValue.entity_id == entity_id,
|
||||||
|
KPIValue.entity_id.is_(None),
|
||||||
|
),
|
||||||
|
).order_by(KPIValue.period.desc()).first()
|
||||||
|
if not latest or latest.actual_value is None:
|
||||||
|
continue
|
||||||
|
actual = latest.actual_value
|
||||||
|
target = k.target_value
|
||||||
|
ratio = (actual / target) if target else None
|
||||||
|
|
||||||
|
# 1. 异常:执行率<70% → 建行动方案
|
||||||
|
if ratio is not None and ratio < 0.7:
|
||||||
|
title = f"提升 {k.kpi_name}:达成率仅{ratio*100:.0f}%"
|
||||||
|
content = (f"KPI[{k.kpi_name}] 最新期间{latest.period}实际值{actual:g},"
|
||||||
|
f"目标{target:g},达成率{ratio*100:.1f}%,低于70%预警线。"
|
||||||
|
f"建议制定专项改善行动方案。")
|
||||||
|
_add("action_plan", "kpi", k.id, title, content, {
|
||||||
|
"kpi_id": k.id, "priority": "high",
|
||||||
|
"title": f"改善: {k.kpi_name}达成率提升",
|
||||||
|
})
|
||||||
|
# 2. 机会:执行率>110% → 上调KPI目标
|
||||||
|
elif ratio is not None and ratio > 1.1:
|
||||||
|
new_target = round(actual * 1.05, 2)
|
||||||
|
title = f"上调 {k.kpi_name} 目标:达成率{ratio*100:.0f}%超预期"
|
||||||
|
content = (f"KPI[{k.kpi_name}] 达成率{ratio*100:.1f}%超过110%,"
|
||||||
|
f"建议将目标从{target:g}上调至{new_target:g},保持牵引力。")
|
||||||
|
_add("kpi_target", "kpi", k.id, title, content, {
|
||||||
|
"kpi_id": k.id, "target_value": new_target,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. 预算执行率>110% → 调预算
|
||||||
|
budget_rows = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
).all()
|
||||||
|
for b in budget_rows:
|
||||||
|
actual = db.query(func.max(KPIValue.actual_value)).filter(
|
||||||
|
KPIValue.kpi_id == b.kpi_id,
|
||||||
|
KPIValue.period == b.period,
|
||||||
|
).scalar()
|
||||||
|
if actual is None or b.budget_value is None or b.budget_value <= 0:
|
||||||
|
continue
|
||||||
|
exec_ratio = actual / b.budget_value
|
||||||
|
if exec_ratio > 1.1:
|
||||||
|
kpi_name = "KPI"
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
|
||||||
|
if k:
|
||||||
|
kpi_name = k.kpi_name
|
||||||
|
title = f"调整 {kpi_name} 预算:执行率{exec_ratio*100:.0f}%超预算"
|
||||||
|
content = (f"预算[{kpi_name}] {period}预算值{b.budget_value:g},"
|
||||||
|
f"实际{actual:g},执行率{exec_ratio*100:.1f}%超过110%。"
|
||||||
|
f"建议同步调整预算/现金流/行动方案。")
|
||||||
|
_add("budget_adjust", "budget", b.kpi_id, title, content, {
|
||||||
|
"kpi_id": b.kpi_id, "period": period, "budget_value": round(actual, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. pending预警 → 建行动方案
|
||||||
|
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").all()
|
||||||
|
for a in alerts:
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
|
||||||
|
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
|
||||||
|
title = f"处理预警:{kpi_name} {a.alert_message[:30]}"
|
||||||
|
content = f"存在待处理预警({a.alert_level}级):{a.alert_message}。建议建立行动方案跟进。"
|
||||||
|
_add("action_plan", "alert", a.id, title, content, {
|
||||||
|
"kpi_id": a.kpi_id, "priority": "high" if a.alert_level == "red" else "medium",
|
||||||
|
"alert_id": a.id,
|
||||||
|
"title": f"处理预警: {kpi_name}",
|
||||||
|
})
|
||||||
|
|
||||||
|
if created:
|
||||||
|
db.commit()
|
||||||
|
for s in created:
|
||||||
|
db.refresh(s)
|
||||||
|
# R1触达修复(2026-08-31): 只对新建的决策类建议推送企微(预警类不推防噪音)
|
||||||
|
# 防轰炸: 同 title 建议幂等不重建 + pushed 标记只推一次;存量不推(只推新建)
|
||||||
|
for s in created:
|
||||||
|
if s.category == "decision" and not s.pushed:
|
||||||
|
ok = _push_decision_suggestion(s)
|
||||||
|
if ok:
|
||||||
|
s.pushed = 1
|
||||||
|
db.commit()
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
_TYPE_LABELS = {"kpi_target": "KPI目标", "budget_adjust": "预算调整", "action_plan": "行动方案"}
|
||||||
|
|
||||||
|
|
||||||
|
def _push_decision_suggestion(s: AISuggestion) -> bool:
|
||||||
|
"""决策类建议推送到企微(8800 relay 公司群中继)
|
||||||
|
|
||||||
|
仅 decision 类;预警类不进推送流。失败不影响主流程(try/except)。
|
||||||
|
8800/send 只接受 form-encoded 参数(msg/source/msgtype/touser),
|
||||||
|
勿用 JSON body(relay 会返回 msg is required,lead.py 的 JSON 写法是错的)。
|
||||||
|
"""
|
||||||
|
if getattr(s, "category", "decision") != "decision":
|
||||||
|
return False
|
||||||
|
type_label = _TYPE_LABELS.get(s.suggestion_type, s.suggestion_type)
|
||||||
|
content = (
|
||||||
|
f"## 📌 AI决策建议\n"
|
||||||
|
f"**{s.title}**\n"
|
||||||
|
f"{str(s.content or '')[:120]}\n"
|
||||||
|
f"类型标签: {type_label}\n"
|
||||||
|
f"---\n"
|
||||||
|
f"⏰ {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"msg": content,
|
||||||
|
"source": "管理会计OS",
|
||||||
|
"msgtype": "markdown",
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"http://127.0.0.1:8800/send",
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
return '"ok": true' in body or '"ok":true' in body
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _unapplied_suggestions(db: Session, entity_id: int, limit: int = 20) -> list:
|
||||||
|
items = db.query(AISuggestion).filter(
|
||||||
|
AISuggestion.entity_id == entity_id,
|
||||||
|
AISuggestion.status == "unapplied",
|
||||||
|
).order_by(AISuggestion.created_at.desc()).limit(limit).all()
|
||||||
|
return [_sug_dict(s) for s in items]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_deepseek_key() -> str:
|
||||||
|
"""强制从环境变量读取 DeepSeek Key,禁止硬编码默认值(安全修复 2026-08-31)"""
|
||||||
|
api_key = os.getenv("DEEPSEEK_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(503, "DEEPSEEK_API_KEY 未配置(禁止硬编码默认key,安全修复 2026-08-31)")
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
async def _call_deepseek(prompt: str) -> str:
|
async def _call_deepseek(prompt: str) -> str:
|
||||||
"""调用DeepSeek API"""
|
"""调用DeepSeek API"""
|
||||||
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
|
api_key = _require_deepseek_key()
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"https://api.deepseek.com/v1/chat/completions",
|
"https://api.deepseek.com/v1/chat/completions",
|
||||||
@@ -34,15 +261,23 @@ async def _call_deepseek(prompt: str) -> str:
|
|||||||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
|
||||||
@router.get("/dashboard-analysis")
|
@router.get("/dashboard-analysis")
|
||||||
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db)):
|
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
"""AI分析驾驶舱数据"""
|
"""AI分析驾驶舱数据"""
|
||||||
# 尝试缓存
|
# 尝试缓存
|
||||||
cache_key = f"dashboard_analysis:{role}"
|
cache_key = f"dashboard_analysis:{role}:{entity_id}"
|
||||||
cached = cache_get("ai", cache_key)
|
cached = cache_get("ai", cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
# 缓存命中(LLM文本10分钟内不重复调用),但轻量规则建议仍执行(幂等)
|
||||||
|
try:
|
||||||
|
generate_rule_suggestions(db, entity_id, source="dashboard")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cached["suggestions"] = _unapplied_suggestions(db, entity_id)
|
||||||
return cached
|
return cached
|
||||||
# 获取当前KPI数据
|
# 获取当前KPI数据
|
||||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
kpis = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active").all()
|
||||||
kpi_summary = []
|
kpi_summary = []
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
|
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
|
||||||
@@ -81,17 +316,25 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
analysis = f"AI分析暂时不可用: {str(e)}"
|
analysis = f"AI分析暂时不可用: {str(e)}"
|
||||||
|
|
||||||
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts}
|
# R1: 规则驱动生成可落地决策建议(幂等落库)
|
||||||
|
try:
|
||||||
|
generate_rule_suggestions(db, entity_id, source="dashboard")
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts,
|
||||||
|
"suggestions": _unapplied_suggestions(db, entity_id)}
|
||||||
# 缓存10分钟
|
# 缓存10分钟
|
||||||
cache_set("ai", cache_key, result, ttl_seconds=600)
|
cache_set("ai", cache_key, result, ttl_seconds=600)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/kpi-analysis/{kpi_id}")
|
@router.get("/kpi-analysis/{kpi_id}")
|
||||||
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db)):
|
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
"""AI分析单个KPI"""
|
"""AI分析单个KPI"""
|
||||||
# 尝试缓存
|
# 尝试缓存
|
||||||
cache_key = f"kpi_analysis:{kpi_id}"
|
cache_key = f"kpi_analysis:{kpi_id}:{entity_id}"
|
||||||
cached = cache_get("ai", cache_key)
|
cached = cache_get("ai", cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
@@ -129,7 +372,14 @@ KPI名称:{kpi.kpi_name}
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
analysis = f"分析暂时不可用: {str(e)}"
|
analysis = f"分析暂时不可用: {str(e)}"
|
||||||
|
|
||||||
result = {"kpi_name": kpi.kpi_name, "analysis": analysis}
|
# R1: 生成该KPI的可落地建议
|
||||||
|
try:
|
||||||
|
generate_rule_suggestions(db, entity_id, source="kpi", kpi_id=kpi_id)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = {"kpi_name": kpi.kpi_name, "analysis": analysis,
|
||||||
|
"suggestions": _unapplied_suggestions(db, entity_id)}
|
||||||
cache_set("ai", cache_key, result, ttl_seconds=600)
|
cache_set("ai", cache_key, result, ttl_seconds=600)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -141,7 +391,7 @@ async def _stream_analysis(prompt: str):
|
|||||||
"POST",
|
"POST",
|
||||||
"https://api.deepseek.com/v1/chat/completions",
|
"https://api.deepseek.com/v1/chat/completions",
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY', 'sk-8e24e6eb87f2475e96ea0980002dc2e8')}",
|
"Authorization": f"Bearer {_require_deepseek_key()}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)
|
||||||
|
|
||||||
|
北极星④决策闭环:AI建议 → 点击应用 → 写库变更 → OperationLog留痕 → 前端可查已应用/未应用。
|
||||||
|
应用动作复用现有 kpis/budget/action_plans 数据模型,不新建业务接口。
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id, resolve_entity_for_request
|
||||||
|
from app.auth_middleware import require_auth, require_role
|
||||||
|
from app.models import AISuggestion, KPIDefinition, KPIValue, OperationLog, BudgetPlan, ActionPlan
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/cma/ai/suggestions", tags=["AI建议"],
|
||||||
|
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sug_dict(s: AISuggestion) -> dict:
|
||||||
|
return {
|
||||||
|
"id": s.id,
|
||||||
|
"entity_id": s.entity_id,
|
||||||
|
"user_id": s.user_id,
|
||||||
|
"source": s.source,
|
||||||
|
"suggestion_type": s.suggestion_type,
|
||||||
|
"target_type": s.target_type,
|
||||||
|
"category": s.category or "decision",
|
||||||
|
"pushed": s.pushed or 0,
|
||||||
|
"target_id": s.target_id,
|
||||||
|
"title": s.title,
|
||||||
|
"content": s.content,
|
||||||
|
"suggestion_data": s.suggestion_data or {},
|
||||||
|
"status": s.status,
|
||||||
|
"applied_by": s.applied_by,
|
||||||
|
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
|
||||||
|
"apply_detail": s.apply_detail or [],
|
||||||
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_suggestion(
|
||||||
|
request: Request,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""创建AI建议(前端AI分析/手动保存建议)"""
|
||||||
|
suggestion_type = data.get("suggestion_type") or data.get("type")
|
||||||
|
title = (data.get("title") or "").strip()
|
||||||
|
if not suggestion_type:
|
||||||
|
raise HTTPException(400, "缺少 suggestion_type (kpi_target/budget_adjust/action_plan)")
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(400, "缺少 title")
|
||||||
|
|
||||||
|
entity_id = resolve_entity_for_request(request, data.get("entity_id") or 1)
|
||||||
|
|
||||||
|
sug = AISuggestion(
|
||||||
|
entity_id=entity_id,
|
||||||
|
user_id=getattr(current_user, "id", None),
|
||||||
|
source=data.get("source", "manual"),
|
||||||
|
suggestion_type=suggestion_type,
|
||||||
|
target_type=data.get("target_type", "kpi"),
|
||||||
|
category="alert" if data.get("target_type") == "alert" else data.get("category", "decision"),
|
||||||
|
target_id=data.get("target_id"),
|
||||||
|
title=title,
|
||||||
|
content=data.get("content"),
|
||||||
|
suggestion_data=data.get("suggestion_data") or {},
|
||||||
|
status="unapplied",
|
||||||
|
)
|
||||||
|
db.add(sug)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sug)
|
||||||
|
return {"success": True, "message": "建议已保存", "data": _sug_dict(sug)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_suggestions(
|
||||||
|
status: Optional[str] = Query(None, description="unapplied/applied/dismissed"),
|
||||||
|
suggestion_type: Optional[str] = Query(None),
|
||||||
|
category: Optional[str] = Query(None, description="decision/alert 建议分类过滤"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""建议列表(前端建议中心/详情页查看已应用/未应用状态)"""
|
||||||
|
query = db.query(AISuggestion).filter(AISuggestion.entity_id == entity_id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(AISuggestion.status == status)
|
||||||
|
if suggestion_type:
|
||||||
|
query = query.filter(AISuggestion.suggestion_type == suggestion_type)
|
||||||
|
if category:
|
||||||
|
query = query.filter(AISuggestion.category == category)
|
||||||
|
items = query.order_by(AISuggestion.created_at.desc()).limit(200).all()
|
||||||
|
return {"data": [_sug_dict(s) for s in items], "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{suggestion_id}")
|
||||||
|
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""建议详情"""
|
||||||
|
s = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(404, "建议不存在")
|
||||||
|
return {"data": _sug_dict(s)}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
|
||||||
|
"""改KPI目标"""
|
||||||
|
target_value = params.get("target_value")
|
||||||
|
if target_value is None:
|
||||||
|
raise HTTPException(400, "应用kpi_target需要 target_value")
|
||||||
|
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()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||||
|
before = kpi.target_value
|
||||||
|
kpi.target_value = float(target_value)
|
||||||
|
db.flush()
|
||||||
|
detail_item = {
|
||||||
|
"target_type": "kpi",
|
||||||
|
"target_id": kpi.id,
|
||||||
|
"target_name": kpi.kpi_name,
|
||||||
|
"action": "update_target_value",
|
||||||
|
"before": before,
|
||||||
|
"after": float(target_value),
|
||||||
|
}
|
||||||
|
db.add(OperationLog(
|
||||||
|
user_id=getattr(current_user, "id", None),
|
||||||
|
action="ai_suggestion_apply",
|
||||||
|
target_type="kpi",
|
||||||
|
target_id=kpi.id,
|
||||||
|
detail={
|
||||||
|
"suggestion_id": sug.id,
|
||||||
|
"suggestion_title": sug.title,
|
||||||
|
"apply_action": "kpi_target",
|
||||||
|
"before": before,
|
||||||
|
"after": float(target_value),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
return detail_item
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
|
||||||
|
"""调预算(BudgetPlan upsert,按 kpi_id+period)"""
|
||||||
|
period = params.get("period")
|
||||||
|
budget_value = params.get("budget_value")
|
||||||
|
if not period or budget_value is None:
|
||||||
|
raise HTTPException(400, "应用budget_adjust需要 period + budget_value")
|
||||||
|
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()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||||
|
|
||||||
|
# 解析期间 2026-09 → year=2026 month=9
|
||||||
|
try:
|
||||||
|
parts = period.split("-")
|
||||||
|
year = int(parts[0])
|
||||||
|
month = int(parts[1])
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(400, f"period格式错误: {period} (需要 YYYY-MM)")
|
||||||
|
|
||||||
|
plan = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == sug.entity_id,
|
||||||
|
BudgetPlan.kpi_id == kpi_id,
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.budget_year == year,
|
||||||
|
BudgetPlan.budget_month == month,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
).first()
|
||||||
|
|
||||||
|
before = None
|
||||||
|
if plan:
|
||||||
|
before = plan.budget_value
|
||||||
|
plan.budget_value = float(budget_value)
|
||||||
|
else:
|
||||||
|
plan = BudgetPlan(
|
||||||
|
entity_id=sug.entity_id,
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
period=period,
|
||||||
|
budget_value=float(budget_value),
|
||||||
|
budget_year=year,
|
||||||
|
budget_month=month,
|
||||||
|
version="v1.0",
|
||||||
|
status="active",
|
||||||
|
source_type="ai_suggestion",
|
||||||
|
calc_logic=f"AI建议应用 #{sug.id}: {sug.title}",
|
||||||
|
created_by=getattr(current_user, "name", "") or "",
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.flush()
|
||||||
|
detail_item = {
|
||||||
|
"target_type": "budget",
|
||||||
|
"target_id": plan.id,
|
||||||
|
"target_name": f"{kpi.kpi_name}[{period}]",
|
||||||
|
"action": "update_budget" if before is not None else "create_budget",
|
||||||
|
"before": before,
|
||||||
|
"after": float(budget_value),
|
||||||
|
}
|
||||||
|
db.add(OperationLog(
|
||||||
|
user_id=getattr(current_user, "id", None),
|
||||||
|
action="ai_suggestion_apply",
|
||||||
|
target_type="budget",
|
||||||
|
target_id=plan.id,
|
||||||
|
detail={
|
||||||
|
"suggestion_id": sug.id,
|
||||||
|
"suggestion_title": sug.title,
|
||||||
|
"apply_action": "budget_adjust",
|
||||||
|
"kpi_id": kpi_id,
|
||||||
|
"period": period,
|
||||||
|
"before": before,
|
||||||
|
"after": float(budget_value),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
return detail_item
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
|
||||||
|
"""建行动方案"""
|
||||||
|
title = (params.get("title") or "").strip() or sug.title
|
||||||
|
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()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||||
|
|
||||||
|
due_date = None
|
||||||
|
if params.get("due_date"):
|
||||||
|
try:
|
||||||
|
due_date = datetime.strptime(str(params["due_date"])[:10], "%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
due_date = None
|
||||||
|
|
||||||
|
plan = ActionPlan(
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
title=title,
|
||||||
|
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
|
||||||
|
assignee=params.get("assignee") or "",
|
||||||
|
priority=params.get("priority") or "medium",
|
||||||
|
due_date=due_date,
|
||||||
|
status="pending",
|
||||||
|
progress=0,
|
||||||
|
created_by=getattr(current_user, "name", "") or "ai_suggestion",
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.flush()
|
||||||
|
detail_item = {
|
||||||
|
"target_type": "action_plan",
|
||||||
|
"target_id": plan.id,
|
||||||
|
"target_name": title,
|
||||||
|
"action": "create_action_plan",
|
||||||
|
"before": None,
|
||||||
|
"after": plan.id,
|
||||||
|
}
|
||||||
|
db.add(OperationLog(
|
||||||
|
user_id=getattr(current_user, "id", None),
|
||||||
|
action="ai_suggestion_apply",
|
||||||
|
target_type="action_plan",
|
||||||
|
target_id=plan.id,
|
||||||
|
detail={
|
||||||
|
"suggestion_id": sug.id,
|
||||||
|
"suggestion_title": sug.title,
|
||||||
|
"apply_action": "action_plan",
|
||||||
|
"kpi_id": kpi_id,
|
||||||
|
"plan_title": title,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
return detail_item
|
||||||
|
|
||||||
|
|
||||||
|
_APPLYERS = {
|
||||||
|
"kpi_target": _apply_kpi_target,
|
||||||
|
"budget_adjust": _apply_budget_adjust,
|
||||||
|
"action_plan": _apply_action_plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{suggestion_id}/preview")
|
||||||
|
def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
|
"""应用前预览:将变更什么(当前值 → 新值),建立信任 (R1触达修复 2026-08-31)
|
||||||
|
|
||||||
|
- kpi_target: {kpi_name, current_target, new_target}
|
||||||
|
- 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()
|
||||||
|
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()
|
||||||
|
|
||||||
|
if sug.suggestion_type == "kpi_target":
|
||||||
|
return {"data": {
|
||||||
|
"type": "kpi_target",
|
||||||
|
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
|
||||||
|
"current_target": kpi.target_value if kpi else None,
|
||||||
|
"new_target": sd.get("target_value"),
|
||||||
|
}}
|
||||||
|
if sug.suggestion_type == "budget_adjust":
|
||||||
|
period = sd.get("period") or sug.target_type
|
||||||
|
current_budget = None
|
||||||
|
if kpi and period:
|
||||||
|
bp = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == sug.entity_id,
|
||||||
|
BudgetPlan.kpi_id == kpi.id,
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
).order_by(BudgetPlan.id.desc()).first()
|
||||||
|
current_budget = bp.budget_value if bp else None
|
||||||
|
return {"data": {
|
||||||
|
"type": "budget_adjust",
|
||||||
|
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
|
||||||
|
"period": period,
|
||||||
|
"current_budget": current_budget,
|
||||||
|
"new_budget": sd.get("budget_value"),
|
||||||
|
}}
|
||||||
|
# action_plan
|
||||||
|
return {"data": {
|
||||||
|
"type": "action_plan",
|
||||||
|
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
|
||||||
|
"plan_title": sd.get("title") or sug.title,
|
||||||
|
"assignee": sd.get("assignee") or "",
|
||||||
|
"priority": sd.get("priority") or "medium",
|
||||||
|
"due_date": sd.get("due_date") or "",
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{suggestion_id}/apply")
|
||||||
|
def apply_suggestion(
|
||||||
|
suggestion_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
|
||||||
|
|
||||||
|
Body 示例:
|
||||||
|
{"action": "kpi_target", "target_value": 2000000}
|
||||||
|
{"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()
|
||||||
|
if not sug:
|
||||||
|
raise HTTPException(404, "建议不存在")
|
||||||
|
if sug.status == "applied":
|
||||||
|
raise HTTPException(400, "该建议已应用,不能重复应用")
|
||||||
|
if sug.status == "dismissed":
|
||||||
|
raise HTTPException(400, "该建议已忽略,如需应用请重新创建")
|
||||||
|
|
||||||
|
action = data.get("action") or sug.suggestion_type
|
||||||
|
applier = _APPLYERS.get(action)
|
||||||
|
if not applier:
|
||||||
|
raise HTTPException(400, f"不支持的应用动作: {action} (支持 kpi_target/budget_adjust/action_plan)")
|
||||||
|
|
||||||
|
# 应用参数 = 请求体参数 覆盖 建议默认参数
|
||||||
|
params = dict(sug.suggestion_data or {})
|
||||||
|
params.update({k: v for k, v in data.items() if k != "action" and v is not None})
|
||||||
|
|
||||||
|
detail_item = applier(db, sug, params, current_user)
|
||||||
|
sug.status = "applied"
|
||||||
|
sug.applied_by = getattr(current_user, "name", "") or ""
|
||||||
|
sug.applied_user_id = getattr(current_user, "id", None)
|
||||||
|
sug.applied_at = datetime.now()
|
||||||
|
sug.apply_detail = [detail_item]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sug)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "建议已应用并留痕",
|
||||||
|
"data": _sug_dict(sug),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{suggestion_id}/dismiss")
|
||||||
|
def dismiss_suggestion(
|
||||||
|
suggestion_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""忽略建议"""
|
||||||
|
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
|
||||||
|
if not sug:
|
||||||
|
raise HTTPException(404, "建议不存在")
|
||||||
|
sug.status = "dismissed"
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "message": "建议已忽略"}
|
||||||
@@ -41,6 +41,7 @@ class DynamicThresholdCache(Base):
|
|||||||
"""动态阈值缓存 — 存储近3个月历史统计"""
|
"""动态阈值缓存 — 存储近3个月历史统计"""
|
||||||
__tablename__ = "dynamic_threshold_cache"
|
__tablename__ = "dynamic_threshold_cache"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
|
||||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||||
period = Column(String(20), nullable=False, comment="计算期间 2026-07")
|
period = Column(String(20), nullable=False, comment="计算期间 2026-07")
|
||||||
mean_value = Column(Float, nullable=True, comment="近3月均值")
|
mean_value = Column(Float, nullable=True, comment="近3月均值")
|
||||||
@@ -91,15 +92,16 @@ def list_alert_rules(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/kpi/{kpi_id}")
|
@router.get("/kpi/{kpi_id}")
|
||||||
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db)):
|
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""获取单个KPI的所有预警规则"""
|
"""获取单个KPI的所有预警规则(账套隔离: 按token企业)"""
|
||||||
rules = db.query(AlertRule).filter(AlertRule.kpi_id == kpi_id).order_by(AlertRule.id).all()
|
rules = db.query(AlertRule).filter(
|
||||||
|
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id).order_by(AlertRule.id).all()
|
||||||
return {"data": [{c.name: getattr(r, c.name) for c in AlertRule.__table__.columns} for r in rules]}
|
return {"data": [{c.name: getattr(r, c.name) for c in AlertRule.__table__.columns} for r in rules]}
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(require_role("ceo", "finance", "it"))):
|
def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(require_role("ceo", "finance", "it")), entity_id: int = Depends(get_entity_id)):
|
||||||
"""创建预警规则"""
|
"""创建预警规则(账套隔离: 写入token企业, 2026-08-31 安全修复)"""
|
||||||
kpi_id = data.get("kpi_id")
|
kpi_id = data.get("kpi_id")
|
||||||
rule_type = data.get("rule_type", "static")
|
rule_type = data.get("rule_type", "static")
|
||||||
trigger_on = data.get("trigger_on", "actual")
|
trigger_on = data.get("trigger_on", "actual")
|
||||||
@@ -107,10 +109,13 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
|||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
if kpi.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
if rule_type not in ("static", "dynamic", "trend_up", "trend_down", "forecast_deviation"): # 升级2b: 预测偏差
|
if rule_type not in ("static", "dynamic", "trend_up", "trend_down", "forecast_deviation"): # 升级2b: 预测偏差
|
||||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
||||||
|
|
||||||
rule = AlertRule(
|
rule = AlertRule(
|
||||||
|
entity_id=entity_id,
|
||||||
kpi_id=kpi_id,
|
kpi_id=kpi_id,
|
||||||
rule_type=rule_type,
|
rule_type=rule_type,
|
||||||
trigger_on=trigger_on,
|
trigger_on=trigger_on,
|
||||||
@@ -132,9 +137,9 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{rule_id}")
|
@router.put("/{rule_id}")
|
||||||
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
|
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""更新预警规则"""
|
"""更新预警规则(账套隔离: 禁止跨企业修改)"""
|
||||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
rule = db.query(AlertRule).filter(AlertRule.id == rule_id, AlertRule.entity_id == entity_id).first()
|
||||||
if not rule:
|
if not rule:
|
||||||
raise HTTPException(404, "预警规则不存在")
|
raise HTTPException(404, "预警规则不存在")
|
||||||
|
|
||||||
@@ -147,9 +152,9 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{rule_id}")
|
@router.delete("/{rule_id}")
|
||||||
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
|
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""删除预警规则"""
|
"""删除预警规则(账套隔离: 禁止跨企业删除)"""
|
||||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
rule = db.query(AlertRule).filter(AlertRule.id == rule_id, AlertRule.entity_id == entity_id).first()
|
||||||
if rule:
|
if rule:
|
||||||
db.delete(rule)
|
db.delete(rule)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -157,8 +162,8 @@ def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
@router.post("/batch")
|
||||||
def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
def batch_create_rules(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""批量创建预警规则
|
"""批量创建预警规则(账套隔离: 全部写入token企业)
|
||||||
data.rules: [{"kpi_id": id, "rule_type": "static", "params": {...}}, ...]
|
data.rules: [{"kpi_id": id, "rule_type": "static", "params": {...}}, ...]
|
||||||
"""
|
"""
|
||||||
rules_data = data.get("rules", [])
|
rules_data = data.get("rules", [])
|
||||||
@@ -166,14 +171,19 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
|||||||
for rule_data in rules_data:
|
for rule_data in rules_data:
|
||||||
kpi_id = rule_data.get("kpi_id")
|
kpi_id = rule_data.get("kpi_id")
|
||||||
rule_type = rule_data.get("rule_type", "static")
|
rule_type = rule_data.get("rule_type", "static")
|
||||||
# 检查是否已存在相同类型的规则
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
|
if not kpi or kpi.entity_id != entity_id:
|
||||||
|
continue
|
||||||
|
# 检查是否已存在相同类型的规则(同企业内)
|
||||||
existing = db.query(AlertRule).filter(
|
existing = db.query(AlertRule).filter(
|
||||||
AlertRule.kpi_id == kpi_id,
|
AlertRule.kpi_id == kpi_id,
|
||||||
AlertRule.rule_type == rule_type,
|
AlertRule.rule_type == rule_type,
|
||||||
|
AlertRule.entity_id == entity_id,
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
continue
|
continue
|
||||||
rule = AlertRule(
|
rule = AlertRule(
|
||||||
|
entity_id=entity_id,
|
||||||
kpi_id=kpi_id,
|
kpi_id=kpi_id,
|
||||||
rule_type=rule_type,
|
rule_type=rule_type,
|
||||||
enabled=rule_data.get("enabled", 1),
|
enabled=rule_data.get("enabled", 1),
|
||||||
@@ -186,15 +196,17 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/generate-defaults")
|
@router.post("/generate-defaults")
|
||||||
def generate_default_rules(db: Session = Depends(get_db)):
|
def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""为所有尚未配置预警规则的KPI生成默认规则"""
|
"""为当前企业尚未配置预警规则的KPI生成默认规则(账套隔离 2026-08-31)"""
|
||||||
# 找到所有active KPI
|
# 找到当前企业所有active 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).all()
|
||||||
|
|
||||||
created = 0
|
created = 0
|
||||||
for kpi in all_kpis:
|
for kpi in all_kpis:
|
||||||
# 检查是否已有任何规则
|
# 检查是否已有任何规则(同企业内)
|
||||||
existing = db.query(AlertRule).filter(AlertRule.kpi_id == kpi.id).first()
|
existing = db.query(AlertRule).filter(
|
||||||
|
AlertRule.kpi_id == kpi.id, AlertRule.entity_id == entity_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -203,6 +215,7 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
|||||||
# 1. 静态阈值规则(基于kpi_definitions的阈值)
|
# 1. 静态阈值规则(基于kpi_definitions的阈值)
|
||||||
if kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red:
|
if kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red:
|
||||||
rule = AlertRule(
|
rule = AlertRule(
|
||||||
|
entity_id=entity_id,
|
||||||
kpi_id=kpi_id,
|
kpi_id=kpi_id,
|
||||||
rule_type="static",
|
rule_type="static",
|
||||||
enabled=1,
|
enabled=1,
|
||||||
@@ -217,6 +230,7 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# 2. 动态趋势规则(所有KPI默认加 trend_down)
|
# 2. 动态趋势规则(所有KPI默认加 trend_down)
|
||||||
rule2 = AlertRule(
|
rule2 = AlertRule(
|
||||||
|
entity_id=entity_id,
|
||||||
kpi_id=kpi_id,
|
kpi_id=kpi_id,
|
||||||
rule_type="trend_down",
|
rule_type="trend_down",
|
||||||
enabled=1,
|
enabled=1,
|
||||||
@@ -230,9 +244,10 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/check-all")
|
@router.post("/check-all")
|
||||||
def run_all_alert_checks(db: Session = Depends(get_db)):
|
def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""执行所有KPI的预警检查 — 生成新的预警记录"""
|
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)"""
|
||||||
rules = db.query(AlertRule).filter(AlertRule.enabled == 1).all()
|
rules = db.query(AlertRule).filter(
|
||||||
|
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
|
||||||
kpi_cache = {}
|
kpi_cache = {}
|
||||||
value_cache = {}
|
value_cache = {}
|
||||||
|
|
||||||
@@ -246,7 +261,7 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
|||||||
if kpi:
|
if kpi:
|
||||||
kpi_cache[rule.kpi_id] = kpi
|
kpi_cache[rule.kpi_id] = kpi
|
||||||
|
|
||||||
if not kpi:
|
if not kpi or kpi.entity_id != entity_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 获取最新值
|
# 获取最新值
|
||||||
@@ -288,6 +303,7 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
if not existing_alert:
|
if not existing_alert:
|
||||||
alert = KPIAlert(
|
alert = KPIAlert(
|
||||||
|
entity_id=entity_id,
|
||||||
kpi_id=rule.kpi_id,
|
kpi_id=rule.kpi_id,
|
||||||
kpi_value_id=latest_value.id,
|
kpi_value_id=latest_value.id,
|
||||||
alert_level=alert_level,
|
alert_level=alert_level,
|
||||||
@@ -306,9 +322,9 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/dynamic-thresholds")
|
@router.get("/dynamic-thresholds")
|
||||||
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db)):
|
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""获取动态阈值缓存"""
|
"""获取动态阈值缓存(账套隔离 2026-08-31)"""
|
||||||
query = db.query(DynamicThresholdCache)
|
query = db.query(DynamicThresholdCache).filter(DynamicThresholdCache.entity_id == entity_id)
|
||||||
if kpi_id:
|
if kpi_id:
|
||||||
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
|
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
|
||||||
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
|
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
|
||||||
@@ -316,9 +332,10 @@ def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(g
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/calculate-dynamic")
|
@router.post("/calculate-dynamic")
|
||||||
def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
def calculate_dynamic_thresholds(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""计算所有KPI的动态阈值(基于近3个月历史均值±标准差)"""
|
"""计算当前企业所有KPI的动态阈值(账套隔离 2026-08-31)"""
|
||||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||||
current_period = datetime.now().strftime("%Y-%m")
|
current_period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
computed = 0
|
computed = 0
|
||||||
@@ -351,8 +368,9 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
|||||||
dynamic_yellow = f">={mean_val:.2f}"
|
dynamic_yellow = f">={mean_val:.2f}"
|
||||||
dynamic_red = f"<{mean_val:.2f}"
|
dynamic_red = f"<{mean_val:.2f}"
|
||||||
|
|
||||||
# 检查是否已有缓存
|
# 检查是否已有缓存(同企业内)
|
||||||
existing = db.query(DynamicThresholdCache).filter(
|
existing = db.query(DynamicThresholdCache).filter(
|
||||||
|
DynamicThresholdCache.entity_id == entity_id,
|
||||||
DynamicThresholdCache.kpi_id == kpi.id,
|
DynamicThresholdCache.kpi_id == kpi.id,
|
||||||
DynamicThresholdCache.period == current_period,
|
DynamicThresholdCache.period == current_period,
|
||||||
).first()
|
).first()
|
||||||
@@ -365,6 +383,7 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
|||||||
existing.dynamic_red = dynamic_red
|
existing.dynamic_red = dynamic_red
|
||||||
else:
|
else:
|
||||||
cache = DynamicThresholdCache(
|
cache = DynamicThresholdCache(
|
||||||
|
entity_id=entity_id,
|
||||||
kpi_id=kpi.id,
|
kpi_id=kpi.id,
|
||||||
period=current_period,
|
period=current_period,
|
||||||
mean_value=mean_val,
|
mean_value=mean_val,
|
||||||
@@ -400,7 +419,8 @@ def _check_static(value: float, params: dict, kpi) -> tuple:
|
|||||||
return ("green", f"[静态] {kpi.kpi_name}={value}, 绿灯{green}")
|
return ("green", f"[静态] {kpi.kpi_name}={value}, 绿灯{green}")
|
||||||
elif _eval_threshold(value, yellow):
|
elif _eval_threshold(value, yellow):
|
||||||
return ("yellow", f"[静态] {kpi.kpi_name}={value}, 黄灯{yellow}")
|
return ("yellow", f"[静态] {kpi.kpi_name}={value}, 黄灯{yellow}")
|
||||||
elif red and _eval_threshold(value, red, invert=True):
|
elif red and _eval_threshold(value, red):
|
||||||
|
# red 阈值字面即命中条件(如 "<600" = 低于600触发红灯;">25" = 高于25触发红灯)
|
||||||
return ("red", f"[静态] {kpi.kpi_name}={value}, 红灯{red}")
|
return ("red", f"[静态] {kpi.kpi_name}={value}, 红灯{red}")
|
||||||
|
|
||||||
return (None, None)
|
return (None, None)
|
||||||
@@ -469,16 +489,17 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
|
|||||||
try:
|
try:
|
||||||
if threshold_str.startswith(">="):
|
if threshold_str.startswith(">="):
|
||||||
limit = float(threshold_str[2:])
|
limit = float(threshold_str[2:])
|
||||||
return value >= limit if not invert else value >= limit
|
# invert=True 时取反:命中 = 值低于阈值(低于下限触发红灯等场景)
|
||||||
|
return value < limit if invert else value >= limit
|
||||||
elif threshold_str.startswith("<="):
|
elif threshold_str.startswith("<="):
|
||||||
limit = float(threshold_str[2:])
|
limit = float(threshold_str[2:])
|
||||||
return value <= limit if not invert else value <= limit
|
return value > limit if invert else value <= limit
|
||||||
elif threshold_str.startswith(">"):
|
elif threshold_str.startswith(">"):
|
||||||
limit = float(threshold_str[1:])
|
limit = float(threshold_str[1:])
|
||||||
return value > limit if not invert else value > limit
|
return value <= limit if invert else value > limit
|
||||||
elif threshold_str.startswith("<"):
|
elif threshold_str.startswith("<"):
|
||||||
limit = float(threshold_str[1:])
|
limit = float(threshold_str[1:])
|
||||||
return value < limit if not invert else value < limit
|
return value >= limit if invert else value < limit
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@@ -489,13 +510,14 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
|
|||||||
# 预测值检查 + 情景建议
|
# 预测值检查 + 情景建议
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def _check_forecast_alerts(db: Session) -> int:
|
def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
|
||||||
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast'和'both'的规则"""
|
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast'和'both'的规则(账套隔离 2026-08-31)"""
|
||||||
from app.utils.cash_forecast_engine import forecast_cash_flow, generate_scenario_suggestion
|
from app.utils.cash_forecast_engine import forecast_cash_flow, generate_scenario_suggestion
|
||||||
from app.models import CashForecast
|
from app.models import CashForecast
|
||||||
|
|
||||||
rules = db.query(AlertRule).filter(
|
rules = db.query(AlertRule).filter(
|
||||||
AlertRule.enabled == 1,
|
AlertRule.enabled == 1,
|
||||||
|
AlertRule.entity_id == entity_id,
|
||||||
AlertRule.trigger_on.in_(["forecast", "both"]),
|
AlertRule.trigger_on.in_(["forecast", "both"]),
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@@ -512,11 +534,10 @@ def _check_forecast_alerts(db: Session) -> int:
|
|||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||||
if kpi:
|
if kpi:
|
||||||
rule_kpi_cache[rule.kpi_id] = kpi
|
rule_kpi_cache[rule.kpi_id] = kpi
|
||||||
if not kpi:
|
if not kpi or kpi.entity_id != entity_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
entity_id = kpi.entity_id or 1
|
# 获取最新的预测(按规则所属企业)
|
||||||
# 获取最新的预测
|
|
||||||
latest_forecasts = db.query(CashForecast).filter(
|
latest_forecasts = db.query(CashForecast).filter(
|
||||||
CashForecast.entity_id == entity_id,
|
CashForecast.entity_id == entity_id,
|
||||||
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
|
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
|
||||||
@@ -562,6 +583,7 @@ def _check_forecast_alerts(db: Session) -> int:
|
|||||||
).first()
|
).first()
|
||||||
if not existing:
|
if not existing:
|
||||||
alert = KPIAlert(
|
alert = KPIAlert(
|
||||||
|
entity_id=entity_id,
|
||||||
kpi_id=rule.kpi_id,
|
kpi_id=rule.kpi_id,
|
||||||
alert_level=alert_level,
|
alert_level=alert_level,
|
||||||
alert_message=alert_message,
|
alert_message=alert_message,
|
||||||
@@ -580,18 +602,19 @@ def _check_forecast_alerts(db: Session) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/check-forecast")
|
@router.post("/check-forecast")
|
||||||
def run_forecast_alert_check(db: Session = Depends(get_db)):
|
def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""执行预测值预警检查 — 检查未来7天预测值是否超限"""
|
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)"""
|
||||||
generated = _check_forecast_alerts(db)
|
generated = _check_forecast_alerts(db, entity_id=entity_id)
|
||||||
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
|
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate-suggestions")
|
@router.post("/generate-suggestions")
|
||||||
def generate_alert_suggestions(db: Session = Depends(get_db)):
|
def generate_alert_suggestions(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""为所有未处理的预警生成情景建议"""
|
"""为当前企业所有未处理的预警生成情景建议(账套隔离 2026-08-31)"""
|
||||||
from app.utils.cash_forecast_engine import generate_scenario_suggestion
|
from app.utils.cash_forecast_engine import generate_scenario_suggestion
|
||||||
|
|
||||||
pending = db.query(KPIAlert).filter(
|
pending = db.query(KPIAlert).filter(
|
||||||
|
KPIAlert.entity_id == entity_id,
|
||||||
KPIAlert.status == "pending",
|
KPIAlert.status == "pending",
|
||||||
KPIAlert.suggestion.is_(None),
|
KPIAlert.suggestion.is_(None),
|
||||||
).all()
|
).all()
|
||||||
@@ -681,6 +704,7 @@ def run_forecast_deviation_check(
|
|||||||
existing.alert_level = alert_level
|
existing.alert_level = alert_level
|
||||||
else:
|
else:
|
||||||
db.add(KPIAlert(
|
db.add(KPIAlert(
|
||||||
|
entity_id=entity_id,
|
||||||
kpi_id=rule.kpi_id,
|
kpi_id=rule.kpi_id,
|
||||||
kpi_value_id=actual.id,
|
kpi_value_id=actual.id,
|
||||||
alert_level=alert_level,
|
alert_level=alert_level,
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
Bot API 操作审计中间件
|
||||||
|
======================
|
||||||
|
|
||||||
|
每次 Bot API 调用(/api/cma/bot*)记录一行 JSON 审计日志到
|
||||||
|
backend/logs/bot_audit.log(可用环境变量 CMA_BOT_AUDIT_LOG 覆盖路径)。
|
||||||
|
|
||||||
|
JSON 行字段: timestamp / bot_name / endpoint / method / risk_level /
|
||||||
|
entity_id / status
|
||||||
|
L3 批量写额外记录 rows(导入行数 / 批量条数)。
|
||||||
|
|
||||||
|
设计约束:
|
||||||
|
- 不阻塞业务: 所有日志写入失败仅静默跳过,不影响请求结果
|
||||||
|
- 只读 body(starlette 会缓存并回放给下游),仅在 application/json 时解析
|
||||||
|
- 不改动任何现有 API 行为与多租户隔离
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
_LOG_DIR = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"logs",
|
||||||
|
)
|
||||||
|
_DEFAULT_LOG_PATH = os.path.join(_LOG_DIR, "bot_audit.log")
|
||||||
|
|
||||||
|
_audit_logger = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_logger():
|
||||||
|
"""构造/复用审计 logger(路径取 CMA_BOT_AUDIT_LOG 覆盖值,便于测试隔离)"""
|
||||||
|
global _audit_logger
|
||||||
|
if _audit_logger is None:
|
||||||
|
log_path = os.getenv("CMA_BOT_AUDIT_LOG") or _DEFAULT_LOG_PATH
|
||||||
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
|
logger = logging.getLogger("cma.bot_audit")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False
|
||||||
|
# 清掉旧 handler,避免日志名单例导致路径切换失效
|
||||||
|
for h in list(logger.handlers):
|
||||||
|
logger.removeHandler(h)
|
||||||
|
try:
|
||||||
|
h.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
_audit_logger = logger
|
||||||
|
return _audit_logger
|
||||||
|
|
||||||
|
|
||||||
|
def write_audit_line(record: dict):
|
||||||
|
"""写一行 JSON 审计日志;任何异常都静默(不阻塞业务)"""
|
||||||
|
try:
|
||||||
|
_get_logger().info(json.dumps(record, ensure_ascii=False))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_bot_name(request: Request) -> str:
|
||||||
|
"""解析调用方标识: X-BOT-KEY → 配置名/原始key;bridge → bot-bridge;其余 → web-user/unknown"""
|
||||||
|
key = request.headers.get("X-BOT-KEY")
|
||||||
|
if key:
|
||||||
|
try:
|
||||||
|
from app.api.bot_bridge import _BOT_API_KEYS
|
||||||
|
info = _BOT_API_KEYS.get(key)
|
||||||
|
if isinstance(info, dict) and info.get("name"):
|
||||||
|
return info["name"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return key
|
||||||
|
if request.headers.get("X-BRIDGE-TOKEN"):
|
||||||
|
return "bot-bridge"
|
||||||
|
if (request.headers.get("Authorization") or "").startswith("Bearer "):
|
||||||
|
return "web-user"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_risk_level(request: Request, route=None, endpoint=None):
|
||||||
|
"""级别解析顺序: 路由函数装饰器标注 → API_RISK_MAP(route.path) → API_RISK_MAP(请求URL)"""
|
||||||
|
if endpoint is not None:
|
||||||
|
level = getattr(endpoint, "risk_level", None)
|
||||||
|
if level:
|
||||||
|
return level
|
||||||
|
if route is not None:
|
||||||
|
from app.risk_levels import get_risk_level
|
||||||
|
path = getattr(route, "path", None)
|
||||||
|
if path:
|
||||||
|
for m in getattr(route, "methods", set()) or set():
|
||||||
|
if m in ("GET", "POST", "PUT", "DELETE", "PATCH"):
|
||||||
|
level = get_risk_level(m, path)
|
||||||
|
if level:
|
||||||
|
return level
|
||||||
|
try:
|
||||||
|
from app.risk_levels import get_risk_level
|
||||||
|
return get_risk_level(request.method, request.url.path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_entity_id(request: Request):
|
||||||
|
"""多租户 entity_id 提取(尽力而为): 查询参数 → JSON body(仅 application/json)"""
|
||||||
|
try:
|
||||||
|
q = request.query_params.get("entity_id")
|
||||||
|
if q is not None and str(q) != "":
|
||||||
|
return int(q) if str(q).isdigit() else q
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ctype = (request.headers.get("content-type") or "").lower()
|
||||||
|
if "application/json" in ctype:
|
||||||
|
try:
|
||||||
|
raw = await request.body()
|
||||||
|
if raw:
|
||||||
|
data = json.loads(raw)
|
||||||
|
eid = data.get("entity_id")
|
||||||
|
if eid is not None:
|
||||||
|
return int(eid) if str(eid).isdigit() else eid
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_l3_rows(level, resp_body: bytes):
|
||||||
|
"""L3 批量写: 从响应体提取行数/条数(imported / kpi_updated / causality_links / total)"""
|
||||||
|
if level != "L3" or not resp_body:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(resp_body)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
for key in ("imported", "kpi_updated"):
|
||||||
|
v = data.get(key)
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return int(v)
|
||||||
|
links = data.get("causality_links")
|
||||||
|
if isinstance(links, list):
|
||||||
|
return len(links)
|
||||||
|
total = data.get("total")
|
||||||
|
if isinstance(total, (int, float)):
|
||||||
|
return int(total)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def bot_audit_middleware(request: Request, call_next):
|
||||||
|
"""HTTP 中间件: 仅审计 /api/cma/bot* 路径;任何异常不影响业务"""
|
||||||
|
path = request.url.path
|
||||||
|
if not path.startswith("/api/cma/bot"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
entity_id = None
|
||||||
|
try:
|
||||||
|
entity_id = await _extract_entity_id(request)
|
||||||
|
except Exception:
|
||||||
|
entity_id = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
# 兜底记录(全局异常handler会返回500,此处保证审计不丢)
|
||||||
|
try:
|
||||||
|
write_audit_line({
|
||||||
|
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"bot_name": _resolve_bot_name(request),
|
||||||
|
"endpoint": path,
|
||||||
|
"method": request.method,
|
||||||
|
"risk_level": _resolve_risk_level(request) or "NA",
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"status": 500,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 级别解析(route 由路由器在 call_next 内写入 scope)
|
||||||
|
level = None
|
||||||
|
try:
|
||||||
|
route = request.scope.get("route")
|
||||||
|
endpoint = getattr(route, "endpoint", None) if route else None
|
||||||
|
level = _resolve_risk_level(request, route=route, endpoint=endpoint)
|
||||||
|
except Exception:
|
||||||
|
level = None
|
||||||
|
|
||||||
|
# 捕获响应体(L3 需要行数),并重放
|
||||||
|
resp_body = b""
|
||||||
|
try:
|
||||||
|
body_iterator = getattr(response, "body_iterator", None)
|
||||||
|
if body_iterator is not None:
|
||||||
|
chunks = [chunk async for chunk in body_iterator]
|
||||||
|
resp_body = b"".join(chunks)
|
||||||
|
rows = _extract_l3_rows(level, resp_body)
|
||||||
|
except Exception:
|
||||||
|
rows = None
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"bot_name": _resolve_bot_name(request),
|
||||||
|
"endpoint": path,
|
||||||
|
"method": request.method,
|
||||||
|
"risk_level": level or "NA",
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"status": response.status_code,
|
||||||
|
}
|
||||||
|
if rows is not None:
|
||||||
|
record["rows"] = rows
|
||||||
|
|
||||||
|
try:
|
||||||
|
write_audit_line(record)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 响应体已被消费 → 重建响应(Bot接口均为小JSON,非流式)
|
||||||
|
if resp_body:
|
||||||
|
from fastapi.responses import Response
|
||||||
|
return Response(
|
||||||
|
content=resp_body,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
media_type=response.media_type,
|
||||||
|
)
|
||||||
|
return response
|
||||||
+105
-43
@@ -9,15 +9,17 @@ from sqlalchemy import func, desc
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||||
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
||||||
NotificationLog, RolePermission, ActionPlan, OrgNode,
|
NotificationLog, RolePermission, ActionPlan, OrgNode,
|
||||||
StrategicMapVersion, MapObjective, Objective,
|
StrategicMapVersion, MapObjective, Objective, KR,
|
||||||
)
|
)
|
||||||
from app.models.budget_plan import BudgetPlan
|
from app.models.budget_plan import BudgetPlan
|
||||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||||
from app.models import KPICausality
|
from app.models import KPICausality
|
||||||
|
from app.risk_levels import risk_level
|
||||||
import json
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger("cma.bot_bridge")
|
logger = logging.getLogger("cma.bot_bridge")
|
||||||
@@ -79,6 +81,7 @@ def _model_dict(obj, fields: dict):
|
|||||||
# ═══════════════ 端点 ═══════════════
|
# ═══════════════ 端点 ═══════════════
|
||||||
|
|
||||||
@router.get("/ping")
|
@router.get("/ping")
|
||||||
|
@risk_level("L1")
|
||||||
def ping():
|
def ping():
|
||||||
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
|
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
|
||||||
|
|
||||||
@@ -86,23 +89,26 @@ def ping():
|
|||||||
# ── 总览 ──
|
# ── 总览 ──
|
||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_overview(
|
def bot_overview(
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""系统总览 — BOT首选入口"""
|
"""系统总览 — BOT首选入口(账套隔离 2026-08-31: 仅统计当前企业)"""
|
||||||
return {
|
return {
|
||||||
"bot": bot,
|
"bot": bot,
|
||||||
|
"entity_id": entity_id,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"stats": {
|
"stats": {
|
||||||
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
|
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar() or 0,
|
||||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
|
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id).scalar() or 0,
|
||||||
"maps_total": db.query(func.count(StrategicMap.id)).scalar() or 0,
|
"maps_total": db.query(func.count(StrategicMap.id)).filter(StrategicMap.entity_id == entity_id).scalar() or 0,
|
||||||
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
|
"budget_plans": db.query(func.count(BudgetPlan.id)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
|
||||||
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
|
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.entity_id == entity_id, ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
|
||||||
"data_sources": db.query(func.count(DataSourceConfig.id)).scalar() or 0,
|
"data_sources": db.query(func.count(DataSourceConfig.id)).filter(DataSourceConfig.entity_id == entity_id).scalar() or 0,
|
||||||
"users": db.query(func.count(User.id)).scalar() or 0,
|
"users": db.query(func.count(User.id)).join(UserEntity, UserEntity.user_id == User.id).filter(UserEntity.entity_id == entity_id).scalar() or 0,
|
||||||
"org_nodes": db.query(func.count(OrgNode.id)).scalar() or 0,
|
"org_nodes": db.query(func.count(OrgNode.id)).filter(OrgNode.entity_id == entity_id).scalar() or 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,14 +116,16 @@ def bot_overview(
|
|||||||
# ── KPI ──
|
# ── KPI ──
|
||||||
|
|
||||||
@router.get("/kpis")
|
@router.get("/kpis")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_kpis(
|
def bot_kpis(
|
||||||
dimension: Optional[str] = Query(None),
|
dimension: Optional[str] = Query(None),
|
||||||
status: str = Query("active"),
|
status: str = Query("active"),
|
||||||
limit: int = Query(200, le=1000),
|
limit: int = Query(200, le=1000),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == status)
|
query = db.query(KPIDefinition).filter(KPIDefinition.status == status, KPIDefinition.entity_id == entity_id)
|
||||||
if dimension:
|
if dimension:
|
||||||
query = query.filter(KPIDefinition.dimension == dimension)
|
query = query.filter(KPIDefinition.dimension == dimension)
|
||||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
|
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
|
||||||
@@ -145,13 +153,15 @@ def bot_kpis(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/kpis/{kpi_id}/history")
|
@router.get("/kpis/{kpi_id}/history")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_kpi_history(
|
def bot_kpi_history(
|
||||||
kpi_id: int, limit: int = Query(12, le=60),
|
kpi_id: int, limit: int = Query(12, le=60),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
if not kpi:
|
if not kpi or kpi.entity_id != entity_id:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
|
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
|
||||||
.order_by(KPIValue.period.desc()).limit(limit).all()
|
.order_by(KPIValue.period.desc()).limit(limit).all()
|
||||||
@@ -171,11 +181,13 @@ def bot_kpi_history(
|
|||||||
# ── 战略地图 ──
|
# ── 战略地图 ──
|
||||||
|
|
||||||
@router.get("/strategic-maps")
|
@router.get("/strategic-maps")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_maps(
|
def bot_maps(
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
maps = db.query(StrategicMap).order_by(StrategicMap.id.desc()).all()
|
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.id.desc()).all()
|
||||||
result = []
|
result = []
|
||||||
for m in maps:
|
for m in maps:
|
||||||
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
|
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
|
||||||
@@ -198,14 +210,16 @@ def bot_maps(
|
|||||||
# ── 预警 ──
|
# ── 预警 ──
|
||||||
|
|
||||||
@router.get("/alerts")
|
@router.get("/alerts")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_alerts(
|
def bot_alerts(
|
||||||
status: str = Query("pending"),
|
status: str = Query("pending"),
|
||||||
level: Optional[str] = Query(None),
|
level: Optional[str] = Query(None),
|
||||||
limit: int = Query(50, le=200),
|
limit: int = Query(50, le=200),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
query = db.query(KPIAlert)
|
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
|
||||||
query = query.filter(KPIAlert.status == status)
|
query = query.filter(KPIAlert.status == status)
|
||||||
if level:
|
if level:
|
||||||
query = query.filter(KPIAlert.alert_level == level)
|
query = query.filter(KPIAlert.alert_level == level)
|
||||||
@@ -228,12 +242,14 @@ def bot_alerts(
|
|||||||
# ── 预算 ──
|
# ── 预算 ──
|
||||||
|
|
||||||
@router.get("/budget/plans")
|
@router.get("/budget/plans")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_budget_plans(
|
def bot_budget_plans(
|
||||||
year: Optional[int] = Query(None),
|
year: Optional[int] = Query(None),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
query = db.query(BudgetPlan)
|
query = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id)
|
||||||
if year:
|
if year:
|
||||||
query = query.filter(BudgetPlan.budget_year == year)
|
query = query.filter(BudgetPlan.budget_year == year)
|
||||||
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
|
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
|
||||||
@@ -255,11 +271,13 @@ def bot_budget_plans(
|
|||||||
# ── 成本 ──
|
# ── 成本 ──
|
||||||
|
|
||||||
@router.get("/cost/standard")
|
@router.get("/cost/standard")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_standard_costs(
|
def bot_standard_costs(
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
costs = db.query(StandardCost).filter(StandardCost.status == "active").limit(200).all()
|
costs = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id).limit(200).all()
|
||||||
return {
|
return {
|
||||||
"total": len(costs),
|
"total": len(costs),
|
||||||
"items": [
|
"items": [
|
||||||
@@ -278,12 +296,14 @@ def bot_standard_costs(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/cost/actual")
|
@router.get("/cost/actual")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_actual_costs(
|
def bot_actual_costs(
|
||||||
period: Optional[str] = Query(None),
|
period: Optional[str] = Query(None),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
query = db.query(ActualCost)
|
query = db.query(ActualCost).filter(ActualCost.entity_id == entity_id)
|
||||||
if period:
|
if period:
|
||||||
query = query.filter(ActualCost.period == period)
|
query = query.filter(ActualCost.period == period)
|
||||||
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
|
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
|
||||||
@@ -306,12 +326,14 @@ def bot_actual_costs(
|
|||||||
# ── 行动方案 ──
|
# ── 行动方案 ──
|
||||||
|
|
||||||
@router.get("/actions")
|
@router.get("/actions")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_actions(
|
def bot_actions(
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
query = db.query(ActionPlan)
|
query = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id)
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(ActionPlan.status == status)
|
query = query.filter(ActionPlan.status == status)
|
||||||
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
|
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
|
||||||
@@ -333,11 +355,13 @@ def bot_actions(
|
|||||||
# ── 组织 ──
|
# ── 组织 ──
|
||||||
|
|
||||||
@router.get("/organization")
|
@router.get("/organization")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_org(
|
def bot_org(
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all()
|
nodes = db.query(OrgNode).filter(OrgNode.entity_id == entity_id).order_by(OrgNode.level, OrgNode.sort_order).all()
|
||||||
return {
|
return {
|
||||||
"total": len(nodes),
|
"total": len(nodes),
|
||||||
"items": [
|
"items": [
|
||||||
@@ -354,11 +378,13 @@ def bot_org(
|
|||||||
# ── 数据源 ──
|
# ── 数据源 ──
|
||||||
|
|
||||||
@router.get("/data-sources")
|
@router.get("/data-sources")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_data_sources(
|
def bot_data_sources(
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
sources = db.query(DataSourceConfig).all()
|
sources = db.query(DataSourceConfig).filter(DataSourceConfig.entity_id == entity_id).all()
|
||||||
return {
|
return {
|
||||||
"total": len(sources),
|
"total": len(sources),
|
||||||
"items": [
|
"items": [
|
||||||
@@ -377,11 +403,15 @@ def bot_data_sources(
|
|||||||
# ── 用户 ──
|
# ── 用户 ──
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_users(
|
def bot_users(
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
users = db.query(User).all()
|
# 用户是全局实体,通过 user_entities 授权表按企业过滤(安全修复 2026-08-31)
|
||||||
|
users = db.query(User).join(UserEntity, UserEntity.user_id == User.id)\
|
||||||
|
.filter(UserEntity.entity_id == entity_id).all()
|
||||||
return {
|
return {
|
||||||
"total": len(users),
|
"total": len(users),
|
||||||
"items": [
|
"items": [
|
||||||
@@ -395,24 +425,26 @@ def bot_users(
|
|||||||
# ── 统一查询(BOT首选) ──
|
# ── 统一查询(BOT首选) ──
|
||||||
|
|
||||||
@router.get("/query")
|
@router.get("/query")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_query(
|
def bot_query(
|
||||||
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
|
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""统一查询入口 — BOT用这个一次拿完需要的数据"""
|
"""统一查询入口 — BOT用这个一次拿完需要的数据(账套隔离 2026-08-31)"""
|
||||||
result = {"bot": bot["name"], "role": bot["role"], "timestamp": datetime.now().isoformat()}
|
result = {"bot": bot["name"], "role": bot["role"], "entity_id": entity_id, "timestamp": datetime.now().isoformat()}
|
||||||
|
|
||||||
if q in ("overview", "all"):
|
if q in ("overview", "all"):
|
||||||
result["overview"] = {
|
result["overview"] = {
|
||||||
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
|
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar() or 0,
|
||||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
|
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id).scalar() or 0,
|
||||||
"maps": db.query(func.count(StrategicMap.id)).scalar() or 0,
|
"maps": db.query(func.count(StrategicMap.id)).filter(StrategicMap.entity_id == entity_id).scalar() or 0,
|
||||||
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
|
"budget_plans": db.query(func.count(BudgetPlan.id)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if q in ("kpis", "all"):
|
if q in ("kpis", "all"):
|
||||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").limit(100).all()
|
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).limit(100).all()
|
||||||
result["kpis"] = [
|
result["kpis"] = [
|
||||||
{"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
{"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
||||||
"dimension": k.dimension, "target": _float(k.target_value), "unit": k.unit}
|
"dimension": k.dimension, "target": _float(k.target_value), "unit": k.unit}
|
||||||
@@ -420,7 +452,7 @@ def bot_query(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if q in ("alerts", "all"):
|
if q in ("alerts", "all"):
|
||||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending")\
|
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id)\
|
||||||
.order_by(KPIAlert.created_at.desc()).limit(20).all()
|
.order_by(KPIAlert.created_at.desc()).limit(20).all()
|
||||||
result["alerts"] = [
|
result["alerts"] = [
|
||||||
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
|
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
|
||||||
@@ -429,7 +461,7 @@ def bot_query(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if q in ("maps", "all"):
|
if q in ("maps", "all"):
|
||||||
maps = db.query(StrategicMap).limit(10).all()
|
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).limit(10).all()
|
||||||
result["maps"] = [
|
result["maps"] = [
|
||||||
{"id": m.id, "title": m.title, "status": m.status,
|
{"id": m.id, "title": m.title, "status": m.status,
|
||||||
"version": m.version, "created_at": _safe_iso(m.created_at)}
|
"version": m.version, "created_at": _safe_iso(m.created_at)}
|
||||||
@@ -437,7 +469,7 @@ def bot_query(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if q in ("budget", "all"):
|
if q in ("budget", "all"):
|
||||||
plans = db.query(BudgetPlan).limit(50).all()
|
plans = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id).limit(50).all()
|
||||||
result["budget"] = [
|
result["budget"] = [
|
||||||
{"id": p.id, "period": p.period, "budget_value": _float(p.budget_value),
|
{"id": p.id, "period": p.period, "budget_value": _float(p.budget_value),
|
||||||
"year": p.budget_year, "month": p.budget_month, "status": p.status,
|
"year": p.budget_year, "month": p.budget_month, "status": p.status,
|
||||||
@@ -446,7 +478,7 @@ def bot_query(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if q in ("cost", "all"):
|
if q in ("cost", "all"):
|
||||||
sc = db.query(StandardCost).limit(50).all()
|
sc = db.query(StandardCost).filter(StandardCost.entity_id == entity_id).limit(50).all()
|
||||||
result["costs"] = [
|
result["costs"] = [
|
||||||
{"id": c.id, "product": c.product_name, "type": c.cost_type,
|
{"id": c.id, "product": c.product_name, "type": c.cost_type,
|
||||||
"standard": _float(c.standard_cost), "unit": c.unit}
|
"standard": _float(c.standard_cost), "unit": c.unit}
|
||||||
@@ -454,22 +486,24 @@ def bot_query(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if q in ("okr", "all"):
|
if q in ("okr", "all"):
|
||||||
objs = db.query(Objective).filter(Objective.status == "active").all()
|
objs = db.query(Objective).filter(Objective.status == "active", Objective.entity_id == entity_id).all()
|
||||||
result["okr"] = []
|
result["okr"] = []
|
||||||
for o in objs:
|
for o in objs:
|
||||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all()
|
# KR完整修复(2026-08-27): 从krs表读取
|
||||||
|
krs = db.query(KR).filter(KR.objective_id == o.id).all()
|
||||||
result["okr"].append({
|
result["okr"].append({
|
||||||
"id": o.id, "title": o.title, "quarter": o.quarter,
|
"id": o.id, "title": o.title, "quarter": o.quarter,
|
||||||
"dimension": o.dimension, "progress": o.progress,
|
"dimension": o.dimension, "progress": o.progress,
|
||||||
"confidence": o.confidence,
|
"confidence": o.confidence,
|
||||||
"key_results": [
|
"key_results": [
|
||||||
{"title": kr.title, "status": kr.status, "progress": kr.progress}
|
{"title": kr.title, "status": kr.status, "progress": kr.progress,
|
||||||
|
"operator": kr.operator, "target_value": _float(kr.target_value)}
|
||||||
for kr in krs
|
for kr in krs
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
if q in ("actions", "all"):
|
if q in ("actions", "all"):
|
||||||
acts = db.query(ActionPlan).limit(30).all()
|
acts = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).limit(30).all()
|
||||||
result["actions"] = [
|
result["actions"] = [
|
||||||
{"id": a.id, "title": a.title, "status": a.status,
|
{"id": a.id, "title": a.title, "status": a.status,
|
||||||
"progress": a.progress, "assignee": a.assignee}
|
"progress": a.progress, "assignee": a.assignee}
|
||||||
@@ -480,12 +514,14 @@ def bot_query(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/import")
|
@router.post("/import")
|
||||||
|
@risk_level("L3")
|
||||||
def bot_import_excel(
|
def bot_import_excel(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""Bot上传Excel导入KPI数据到CMA"""
|
"""Bot上传Excel导入KPI数据到CMA(账套隔离 2026-08-31: 仅导入当前企业KPI)"""
|
||||||
import pandas as pd, io, hashlib
|
import pandas as pd, io, hashlib
|
||||||
from app.models import KPIValue
|
from app.models import KPIValue
|
||||||
try:
|
try:
|
||||||
@@ -529,6 +565,9 @@ def bot_import_excel(
|
|||||||
if not kpi:
|
if not kpi:
|
||||||
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
|
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
|
||||||
continue
|
continue
|
||||||
|
if kpi.entity_id != entity_id:
|
||||||
|
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不属于当前企业(entity={kpi.entity_id}),跳过")
|
||||||
|
continue
|
||||||
|
|
||||||
kv = KPIValue(kpi_id=kpi.id, entity_id=kpi.entity_id, period=period, actual_value=val,
|
kv = KPIValue(kpi_id=kpi.id, entity_id=kpi.entity_id, period=period, actual_value=val,
|
||||||
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
||||||
@@ -544,16 +583,18 @@ def bot_import_excel(
|
|||||||
# ── 自然语言查询 ──
|
# ── 自然语言查询 ──
|
||||||
|
|
||||||
@router.post("/okr/create")
|
@router.post("/okr/create")
|
||||||
|
@risk_level("L2")
|
||||||
def bot_okr_create(
|
def bot_okr_create(
|
||||||
title: str = Query(...),
|
title: str = Query(...),
|
||||||
quarter: str = Query(...),
|
quarter: str = Query(...),
|
||||||
dimension: Optional[str] = Query(None),
|
dimension: Optional[str] = Query(None),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""Bot创建OKR目标"""
|
"""Bot创建OKR目标(账套隔离 2026-08-31: 写入token企业)"""
|
||||||
from app.models import Objective
|
from app.models import Objective
|
||||||
obj = Objective(title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
|
obj = Objective(entity_id=entity_id, title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(obj)
|
db.refresh(obj)
|
||||||
@@ -561,14 +602,16 @@ def bot_okr_create(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/okr/list")
|
@router.get("/okr/list")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_okr_list(
|
def bot_okr_list(
|
||||||
quarter: Optional[str] = Query(None),
|
quarter: Optional[str] = Query(None),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""Bot列出OKR(含KR进度)"""
|
"""Bot列出OKR(含KR进度)(账套隔离 2026-08-31)"""
|
||||||
from app.models import Objective
|
from app.models import Objective
|
||||||
q = db.query(Objective)
|
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||||
if quarter:
|
if quarter:
|
||||||
q = q.filter(Objective.quarter == quarter)
|
q = q.filter(Objective.quarter == quarter)
|
||||||
objs = q.order_by(Objective.quarter.desc()).all()
|
objs = q.order_by(Objective.quarter.desc()).all()
|
||||||
@@ -582,10 +625,12 @@ def bot_okr_list(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/nlp")
|
@router.get("/nlp")
|
||||||
|
@risk_level("L1")
|
||||||
def bot_nlp(
|
def bot_nlp(
|
||||||
intent: str = Query("overview"),
|
intent: str = Query("overview"),
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
自然语言意图映射:
|
自然语言意图映射:
|
||||||
@@ -602,7 +647,7 @@ def bot_nlp(
|
|||||||
"okr": "okr", "目标": "okr", "季度目标": "okr",
|
"okr": "okr", "目标": "okr", "季度目标": "okr",
|
||||||
}
|
}
|
||||||
resolved = m.get(intent, intent)
|
resolved = m.get(intent, intent)
|
||||||
return bot_query(q=resolved, bot=bot, db=db)
|
return bot_query(q=resolved, bot=bot, db=db, entity_id=entity_id)
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
@@ -611,6 +656,7 @@ def bot_nlp(
|
|||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@router.post("/kpi-value-with-check")
|
@router.post("/kpi-value-with-check")
|
||||||
|
@risk_level("L2")
|
||||||
def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
|
def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
|
||||||
"""聚合A: 写KPI值 + 自动跑该KPI预警检查(Agent一次调用,免自拼check-all)
|
"""聚合A: 写KPI值 + 自动跑该KPI预警检查(Agent一次调用,免自拼check-all)
|
||||||
body: {kpi_id, actual_value, period?, entity_id?, run_check?}"""
|
body: {kpi_id, actual_value, period?, entity_id?, run_check?}"""
|
||||||
@@ -679,6 +725,7 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/kpis/create-with-links")
|
@router.post("/kpis/create-with-links")
|
||||||
|
@risk_level("L3")
|
||||||
def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
|
def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
|
||||||
"""聚合B: 创建KPI + 关联战略地图 + 批量因果链(Agent建KPI标准动作)
|
"""聚合B: 创建KPI + 关联战略地图 + 批量因果链(Agent建KPI标准动作)
|
||||||
body: {kpi_code, kpi_name, dimension, entity_id?, target_value?, unit?, link_map_id?, link_causality?}"""
|
body: {kpi_code, kpi_name, dimension, entity_id?, target_value?, unit?, link_map_id?, link_causality?}"""
|
||||||
@@ -734,3 +781,18 @@ def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: di
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "map_id": link_map_id,
|
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "map_id": link_map_id,
|
||||||
"causality_links": links, "status": "ok"}
|
"causality_links": links, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/risk-levels")
|
||||||
|
@risk_level("L1")
|
||||||
|
def bot_risk_levels(bot: dict = Depends(verify_bot_key)):
|
||||||
|
"""Bot API风险分级清单(API→级别→处理方式)— 验收/巡检/授权决策用"""
|
||||||
|
from app.risk_levels import RISK_LEVELS, list_api_risk_map, risk_summary
|
||||||
|
return {
|
||||||
|
"bot": bot["name"],
|
||||||
|
"risk_levels": RISK_LEVELS,
|
||||||
|
"apis": list_api_risk_map(),
|
||||||
|
"summary": risk_summary(),
|
||||||
|
"note": "L4(危险)不向Bot API开放:Bot只能通过白名单API读写,"
|
||||||
|
"DROP/TRUNCATE/批量DELETE/生产结构修改物理不可能,由终端层+approval-gate拦截",
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.models import (
|
|||||||
KPIDefinition, KPIValue, KPIAlert,
|
KPIDefinition, KPIValue, KPIAlert,
|
||||||
ActionPlan, Entity,
|
ActionPlan, Entity,
|
||||||
)
|
)
|
||||||
|
from app.risk_levels import risk_level
|
||||||
|
|
||||||
logger = logging.getLogger("cma.bot_bridge_v2")
|
logger = logging.getLogger("cma.bot_bridge_v2")
|
||||||
|
|
||||||
@@ -279,6 +280,7 @@ ALERT_THRESHOLDS = {
|
|||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
@router.post("/mpm-result")
|
@router.post("/mpm-result")
|
||||||
|
@risk_level("L3")
|
||||||
def receive_mpm_result(
|
def receive_mpm_result(
|
||||||
data: dict,
|
data: dict,
|
||||||
bridge_bot: str = Depends(verify_bridge_token),
|
bridge_bot: str = Depends(verify_bridge_token),
|
||||||
@@ -404,6 +406,7 @@ def receive_mpm_result(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/kpi-result")
|
@router.post("/kpi-result")
|
||||||
|
@risk_level("L2")
|
||||||
def push_kpi_result(
|
def push_kpi_result(
|
||||||
data: dict,
|
data: dict,
|
||||||
bridge_bot: str = Depends(verify_bridge_token),
|
bridge_bot: str = Depends(verify_bridge_token),
|
||||||
@@ -521,6 +524,7 @@ def push_kpi_result(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/verify/{action_plan_id}")
|
@router.post("/verify/{action_plan_id}")
|
||||||
|
@risk_level("L2")
|
||||||
def verify_action_plan(
|
def verify_action_plan(
|
||||||
action_plan_id: int,
|
action_plan_id: int,
|
||||||
bridge_bot: str = Depends(verify_bridge_token),
|
bridge_bot: str = Depends(verify_bridge_token),
|
||||||
@@ -529,6 +533,13 @@ def verify_action_plan(
|
|||||||
"""
|
"""
|
||||||
验证ActionPlan的执行结果
|
验证ActionPlan的执行结果
|
||||||
|
|
||||||
|
⚠️ 双 verify 入口关系(2026-08-30 评审收敛,暂不重构):
|
||||||
|
- 本函数(/api/cma/bot-bridge/verify/{action_plan_id}): 轻量版 — 仅读取KPI并按condition
|
||||||
|
校验,记录 verify_log,不写KPIValue回填、不联动OKR、不发企微通知。供Bot桥接通道调用。
|
||||||
|
- verify.py(/api/cma/verify/{plan_id}): 完整链路 — 回填KPIValue + OKR progress联动
|
||||||
|
+ 企微通知。业务侧手动/自动重验走那个入口。
|
||||||
|
- 两者行为不一致,勿混用。
|
||||||
|
|
||||||
1. 读取ActionPlan的auto_verify_rule
|
1. 读取ActionPlan的auto_verify_rule
|
||||||
2. 读取关联KPI的当前值
|
2. 读取关联KPI的当前值
|
||||||
3. 按condition校验
|
3. 按condition校验
|
||||||
@@ -599,6 +610,7 @@ def verify_action_plan(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/verify/{action_plan_id}/history")
|
@router.get("/verify/{action_plan_id}/history")
|
||||||
|
@risk_level("L1")
|
||||||
def verify_history(
|
def verify_history(
|
||||||
action_plan_id: int,
|
action_plan_id: int,
|
||||||
bridge_bot: str = Depends(verify_bridge_token),
|
bridge_bot: str = Depends(verify_bridge_token),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import ActionPlan
|
from app.models import ActionPlan
|
||||||
|
from app.risk_levels import risk_level
|
||||||
|
|
||||||
logger = logging.getLogger("cma.iron_law")
|
logger = logging.getLogger("cma.iron_law")
|
||||||
|
|
||||||
@@ -150,6 +151,7 @@ def _query_action_plan_verify(db: Session):
|
|||||||
# ═══════════════ 端点 ═══════════════
|
# ═══════════════ 端点 ═══════════════
|
||||||
|
|
||||||
@router.get("/iron-law")
|
@router.get("/iron-law")
|
||||||
|
@risk_level("L1")
|
||||||
def get_iron_law_kpis(
|
def get_iron_law_kpis(
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -224,6 +226,7 @@ def get_iron_law_kpis(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/iron-law/bots")
|
@router.get("/iron-law/bots")
|
||||||
|
@risk_level("L1")
|
||||||
def get_bot_iron_law_ranking(
|
def get_bot_iron_law_ranking(
|
||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_auth
|
from app.auth_middleware import require_auth
|
||||||
from app.models import KPIDefinition, KPIValue
|
from app.models import KPIDefinition, KPIValue
|
||||||
|
from app.risk_levels import risk_level
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
||||||
dependencies=[Depends(require_auth)],
|
dependencies=[Depends(require_auth)],
|
||||||
@@ -46,6 +47,7 @@ def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
|
@risk_level("L1")
|
||||||
def list_bot_kpis(
|
def list_bot_kpis(
|
||||||
source: str = Query("finance-bot", description="Bot标识"),
|
source: str = Query("finance-bot", description="Bot标识"),
|
||||||
period: Optional[str] = None,
|
period: Optional[str] = None,
|
||||||
@@ -132,6 +134,7 @@ def list_bot_kpis(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{kpi_id}/value")
|
@router.post("/{kpi_id}/value")
|
||||||
|
@risk_level("L2")
|
||||||
def update_bot_kpi_value(
|
def update_bot_kpi_value(
|
||||||
kpi_id: int,
|
kpi_id: int,
|
||||||
data: dict,
|
data: dict,
|
||||||
|
|||||||
+510
-51
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, or_
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ router = APIRouter(prefix="/api/cma/budget", tags=["预算管理"],
|
|||||||
@router.get("/plans")
|
@router.get("/plans")
|
||||||
def list_budget_plans(
|
def list_budget_plans(
|
||||||
kpi_id: Optional[int] = Query(None),
|
kpi_id: Optional[int] = Query(None),
|
||||||
|
map_id: Optional[int] = Query(None, description="按战略地图过滤预算"),
|
||||||
period: Optional[str] = Query(None),
|
period: Optional[str] = Query(None),
|
||||||
year: Optional[int] = Query(None),
|
year: Optional[int] = Query(None),
|
||||||
version: Optional[str] = Query(None),
|
version: Optional[str] = Query(None),
|
||||||
@@ -32,6 +33,9 @@ def list_budget_plans(
|
|||||||
KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id
|
KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id
|
||||||
).filter(KPIDefinition.entity_id == entity_id)
|
).filter(KPIDefinition.entity_id == entity_id)
|
||||||
|
|
||||||
|
if map_id:
|
||||||
|
# 按地图隔离: 显示该地图的预算 + 未绑定地图的历史预算(NULL, 兼容迁移)
|
||||||
|
query = query.filter(or_(BudgetPlan.map_id == map_id, BudgetPlan.map_id.is_(None)))
|
||||||
if kpi_id:
|
if kpi_id:
|
||||||
query = query.filter(BudgetPlan.kpi_id == kpi_id)
|
query = query.filter(BudgetPlan.kpi_id == kpi_id)
|
||||||
if period:
|
if period:
|
||||||
@@ -51,6 +55,7 @@ def list_budget_plans(
|
|||||||
result.append({
|
result.append({
|
||||||
"id": p.id,
|
"id": p.id,
|
||||||
"kpi_id": p.kpi_id,
|
"kpi_id": p.kpi_id,
|
||||||
|
"map_id": p.map_id,
|
||||||
"kpi_code": kpi.kpi_code if kpi else "",
|
"kpi_code": kpi.kpi_code if kpi else "",
|
||||||
"kpi_name": kpi.kpi_name if kpi else "",
|
"kpi_name": kpi.kpi_name if kpi else "",
|
||||||
"dimension": kpi.dimension if kpi else "",
|
"dimension": kpi.dimension if kpi else "",
|
||||||
@@ -88,12 +93,14 @@ def create_budget_plan(
|
|||||||
|
|
||||||
year, month = period.split("-")
|
year, month = period.split("-")
|
||||||
version = data.get("version", "v1.0")
|
version = data.get("version", "v1.0")
|
||||||
|
map_id = data.get("map_id") # 预算归属地图(2026-08-27: 按地图隔离)
|
||||||
|
|
||||||
# 检查是否已有记录(去重)
|
# 检查是否已有记录(去重, 含map_id)
|
||||||
existing = db.query(BudgetPlan).filter(
|
existing = db.query(BudgetPlan).filter(
|
||||||
BudgetPlan.kpi_id == kpi_id,
|
BudgetPlan.kpi_id == kpi_id,
|
||||||
BudgetPlan.period == period,
|
BudgetPlan.period == period,
|
||||||
BudgetPlan.version == version,
|
BudgetPlan.version == version,
|
||||||
|
BudgetPlan.map_id == map_id,
|
||||||
BudgetPlan.status == "active",
|
BudgetPlan.status == "active",
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -106,6 +113,7 @@ def create_budget_plan(
|
|||||||
else:
|
else:
|
||||||
plan = BudgetPlan(
|
plan = BudgetPlan(
|
||||||
kpi_id=kpi_id,
|
kpi_id=kpi_id,
|
||||||
|
map_id=map_id,
|
||||||
period=period,
|
period=period,
|
||||||
budget_value=budget_value,
|
budget_value=budget_value,
|
||||||
budget_year=int(year),
|
budget_year=int(year),
|
||||||
@@ -175,6 +183,7 @@ def auto_decompose_budget(
|
|||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""自动分解年度预算到月度(均分或按历史权重)
|
"""自动分解年度预算到月度(均分或按历史权重)
|
||||||
支持两种模式:
|
支持两种模式:
|
||||||
@@ -189,21 +198,40 @@ def auto_decompose_budget(
|
|||||||
|
|
||||||
# ── 批量模式:不传kpi_id → 分解该年所有有年度预算的KPI ──
|
# ── 批量模式:不传kpi_id → 分解该年所有有年度预算的KPI ──
|
||||||
if not kpi_id:
|
if not kpi_id:
|
||||||
# 找该年已存在的年度预算(period=YYYY-00 或已按月填的KPI汇总)
|
# 只取年度行(period=YYYY-00)作为年度总额,避免把月度行也加进来导致滚雪球(非幂等bug修复)
|
||||||
# 优先用 budget_plans 中该年的预算作为年度总额
|
|
||||||
year_budget_rows = db.query(BudgetPlan).filter(
|
year_budget_rows = db.query(BudgetPlan).filter(
|
||||||
BudgetPlan.entity_id == 1,
|
BudgetPlan.entity_id == entity_id,
|
||||||
BudgetPlan.budget_year == year,
|
BudgetPlan.budget_year == year,
|
||||||
|
BudgetPlan.period == f"{year}-00",
|
||||||
BudgetPlan.status == "active",
|
BudgetPlan.status == "active",
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# 按KPI聚合年度预算总额
|
# 按KPI聚合年度预算总额——同一KPI存在多个version年度行时只取一行
|
||||||
kpi_annual = {}
|
# (优先匹配请求version,否则取第一条),避免多版本叠加导致总额虚高(幂等加固)
|
||||||
|
from collections import defaultdict
|
||||||
|
per_kpi = defaultdict(list)
|
||||||
for r in year_budget_rows:
|
for r in year_budget_rows:
|
||||||
kpi_annual[r.kpi_id] = kpi_annual.get(r.kpi_id, 0) + (r.budget_value or 0)
|
per_kpi[r.kpi_id].append(r)
|
||||||
|
kpi_annual = {}
|
||||||
|
kpi_version_used = {}
|
||||||
|
for kid, rows in per_kpi.items():
|
||||||
|
chosen = next((r for r in rows if r.version == version), rows[0])
|
||||||
|
kpi_annual[kid] = chosen.budget_value or 0
|
||||||
|
kpi_version_used[kid] = chosen.version
|
||||||
|
|
||||||
if not kpi_annual:
|
if not kpi_annual:
|
||||||
raise HTTPException(400, "该年度没有可分解的预算,请先在预算执行中录入年度预算")
|
# 查询该企业有哪些年份有年度预算行(供提示)
|
||||||
|
from sqlalchemy import distinct
|
||||||
|
avail_years = [r[0] for r in db.query(distinct(BudgetPlan.budget_year)).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
|
BudgetPlan.period.like("%-00"),
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
).order_by(BudgetPlan.budget_year).all()]
|
||||||
|
hint = f"该年度({year})没有年度预算总额(period=YYYY-00)"
|
||||||
|
if avail_years:
|
||||||
|
hint += f";现有年度数据年份:{', '.join(str(y) for y in avail_years)}"
|
||||||
|
hint += "。请先在预算录入中填写该年度预算总额,或改用战略预算编制按地图录入;如需按已有月度数据反向生成请咨询管理员。"
|
||||||
|
raise HTTPException(400, hint)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
created_count = 0
|
created_count = 0
|
||||||
@@ -243,7 +271,7 @@ def auto_decompose_budget(
|
|||||||
existing.updated_at = datetime.now()
|
existing.updated_at = datetime.now()
|
||||||
else:
|
else:
|
||||||
db.add(BudgetPlan(
|
db.add(BudgetPlan(
|
||||||
entity_id=1,
|
entity_id=entity_id,
|
||||||
kpi_id=kid,
|
kpi_id=kid,
|
||||||
period=period,
|
period=period,
|
||||||
budget_value=monthly_value,
|
budget_value=monthly_value,
|
||||||
@@ -260,6 +288,7 @@ def auto_decompose_budget(
|
|||||||
"kpi_code": kpi.kpi_code,
|
"kpi_code": kpi.kpi_code,
|
||||||
"kpi_name": kpi.kpi_name,
|
"kpi_name": kpi.kpi_name,
|
||||||
"annual_budget": round(annual, 2),
|
"annual_budget": round(annual, 2),
|
||||||
|
"version_used": kpi_version_used.get(kid),
|
||||||
"method": "equal" if not weights else "weighted",
|
"method": "equal" if not weights else "weighted",
|
||||||
"monthly": monthly,
|
"monthly": monthly,
|
||||||
"monthly_count": 12,
|
"monthly_count": 12,
|
||||||
@@ -275,7 +304,7 @@ def auto_decompose_budget(
|
|||||||
|
|
||||||
# ── 单KPI模式(原有逻辑)──
|
# ── 单KPI模式(原有逻辑)──
|
||||||
if annual_budget is None:
|
if annual_budget is None:
|
||||||
raise HTTPException(400, "缺少必要参数: annual_budget")
|
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).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
@@ -400,6 +429,16 @@ def get_deviation_report(
|
|||||||
item["kpi_name"] = kpi.kpi_name
|
item["kpi_name"] = kpi.kpi_name
|
||||||
item["dimension"] = kpi.dimension
|
item["dimension"] = kpi.dimension
|
||||||
item["unit"] = kpi.unit or ""
|
item["unit"] = kpi.unit or ""
|
||||||
|
# 战略目标 vs 预算差异(2026-08-27: 战略=愿景/预算=计划, 允许不同但差异可见可解释)
|
||||||
|
target = kpi.target_monthly or kpi.target_value
|
||||||
|
item["strategic_target"] = target
|
||||||
|
if item.get("budget_value") is not None and target:
|
||||||
|
gap = round((item["budget_value"] - target) / target * 100, 1)
|
||||||
|
item["target_gap_pct"] = gap
|
||||||
|
item["target_gap_level"] = "high" if abs(gap) > 20 else ("medium" if abs(gap) > 10 else "ok")
|
||||||
|
else:
|
||||||
|
item["target_gap_pct"] = None
|
||||||
|
item["target_gap_level"] = "none"
|
||||||
items.append(item)
|
items.append(item)
|
||||||
summary["total_kpis"] += 1
|
summary["total_kpis"] += 1
|
||||||
|
|
||||||
@@ -785,20 +824,23 @@ def get_kpi_comparison(
|
|||||||
def check_budget_deviation(
|
def check_budget_deviation(
|
||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
检查实际vs预测偏差,当偏差超过20%时自动生成预警
|
检查实际vs预测偏差,当偏差超过阈值时自动生成预警
|
||||||
|
(2026-08-28 P1-③/P2-⑤: 统一走 build_deviation_alert,写入归因JSON+场景建议)
|
||||||
"""
|
"""
|
||||||
from app.models import KPIValue, BudgetDeviationAlert
|
from app.models import KPIValue, BudgetDeviationAlert
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from app.utils.deviation_engine import build_deviation_alert
|
||||||
|
|
||||||
threshold = data.get("threshold", 20) # 默认20%
|
threshold = data.get("threshold", 20) # 默认20%
|
||||||
period = data.get("period") or datetime.now().strftime("%Y-%m")
|
period = data.get("period") or datetime.now().strftime("%Y-%m")
|
||||||
auto_resolve = data.get("auto_resolve", True) # 是否自动关闭已解决的预警
|
|
||||||
|
|
||||||
# 查询该期间的有预算的KPI
|
# 查询该期间有预算的KPI(多租户隔离 entity_id)
|
||||||
budget_plans = db.query(BudgetPlan).filter(
|
budget_plans = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
BudgetPlan.period == period,
|
BudgetPlan.period == period,
|
||||||
BudgetPlan.status == "active",
|
BudgetPlan.status == "active",
|
||||||
).all()
|
).all()
|
||||||
@@ -814,39 +856,25 @@ def check_budget_deviation(
|
|||||||
alerts = []
|
alerts = []
|
||||||
|
|
||||||
for bp in budget_plans:
|
for bp in budget_plans:
|
||||||
# 查询实际值
|
kpi_obj = db.query(KPIDefinition).filter(
|
||||||
actual = db.query(KPIValue).filter(
|
KPIDefinition.id == bp.kpi_id,
|
||||||
KPIValue.kpi_id == bp.kpi_id,
|
KPIDefinition.entity_id == entity_id,
|
||||||
KPIValue.period == period,
|
|
||||||
KPIValue.actual_value.isnot(None),
|
|
||||||
).first()
|
).first()
|
||||||
|
if not kpi_obj:
|
||||||
if not actual or actual.actual_value is None:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
budget_val = bp.budget_value
|
# 统一告警逻辑(方向性/阈值/归因/场景建议)
|
||||||
actual_val = actual.actual_value
|
result = build_deviation_alert(db, kpi_obj, period, entity_id=entity_id, min_rate=threshold)
|
||||||
|
if not result["triggered"]:
|
||||||
if budget_val == 0:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 计算偏差率
|
deviation = result["deviation"]
|
||||||
deviation_rate = round((actual_val - budget_val) / budget_val * 100, 2)
|
budget_val = deviation.get("budget_value")
|
||||||
|
actual_val = deviation.get("actual_value")
|
||||||
# 只有偏差超过阈值才生成预警
|
deviation_rate = deviation.get("deviation_rate")
|
||||||
if abs(deviation_rate) <= threshold:
|
deviation_value = deviation.get("deviation_amount")
|
||||||
continue
|
if deviation_value is None:
|
||||||
|
deviation_value = round((actual_val or 0) - (budget_val or 0), 2)
|
||||||
deviation_value = round(actual_val - budget_val, 2)
|
|
||||||
|
|
||||||
# 判断预警等级
|
|
||||||
alert_level = "critical" if abs(deviation_rate) > 50 else "warning"
|
|
||||||
|
|
||||||
# 生成建议
|
|
||||||
if deviation_rate > 0:
|
|
||||||
suggestion = f"实际值超出预算 {deviation_rate}%,建议核查超支原因并采取控制措施"
|
|
||||||
else:
|
|
||||||
suggestion = f"实际值低于预算 {abs(deviation_rate)}%,建议分析是否预算过高或业务量未达预期"
|
|
||||||
|
|
||||||
# 检查是否已存在相同的预警
|
# 检查是否已存在相同的预警
|
||||||
existing_alert = db.query(BudgetDeviationAlert).filter(
|
existing_alert = db.query(BudgetDeviationAlert).filter(
|
||||||
@@ -856,6 +884,12 @@ def check_budget_deviation(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_alert:
|
if existing_alert:
|
||||||
|
# 已存在open告警: 补齐归因(原open告警可能无归因, 幂等补写)
|
||||||
|
if existing_alert.attribution is None and result["attribution"]:
|
||||||
|
existing_alert.attribution = result["attribution"]
|
||||||
|
existing_alert.alert_type = result["alert_type"]
|
||||||
|
existing_alert.scenario_id = result["scenario_id"]
|
||||||
|
db.flush()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
alert = BudgetDeviationAlert(
|
alert = BudgetDeviationAlert(
|
||||||
@@ -865,14 +899,16 @@ def check_budget_deviation(
|
|||||||
actual_value=actual_val,
|
actual_value=actual_val,
|
||||||
deviation_rate=deviation_rate,
|
deviation_rate=deviation_rate,
|
||||||
deviation_value=deviation_value,
|
deviation_value=deviation_value,
|
||||||
alert_level=alert_level,
|
alert_level=result["level"],
|
||||||
status="open",
|
status="open",
|
||||||
suggestion=suggestion,
|
suggestion=result["suggestion"],
|
||||||
|
alert_type=result["alert_type"],
|
||||||
|
attribution=result["attribution"],
|
||||||
|
scenario_id=result["scenario_id"],
|
||||||
)
|
)
|
||||||
db.add(alert)
|
db.add(alert)
|
||||||
alerts_generated += 1
|
alerts_generated += 1
|
||||||
|
|
||||||
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == bp.kpi_id).first()
|
|
||||||
alerts.append({
|
alerts.append({
|
||||||
"kpi_id": bp.kpi_id,
|
"kpi_id": bp.kpi_id,
|
||||||
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
|
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
|
||||||
@@ -882,8 +918,10 @@ def check_budget_deviation(
|
|||||||
"actual_value": actual_val,
|
"actual_value": actual_val,
|
||||||
"deviation_rate": deviation_rate,
|
"deviation_rate": deviation_rate,
|
||||||
"deviation_value": deviation_value,
|
"deviation_value": deviation_value,
|
||||||
"alert_level": alert_level,
|
"alert_level": result["level"],
|
||||||
"suggestion": suggestion,
|
"suggestion": result["suggestion"],
|
||||||
|
"alert_type": result["alert_type"],
|
||||||
|
"attribution": result["attribution"],
|
||||||
})
|
})
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -904,10 +942,11 @@ def list_deviation_alerts(
|
|||||||
alert_level: Optional[str] = Query(None),
|
alert_level: Optional[str] = Query(None),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""查询预算偏差预警记录"""
|
"""查询预算偏差预警记录 (2026-08-28: 列表新增 alert_type/attribution/scenario_id,entity_id隔离)"""
|
||||||
from app.models import BudgetDeviationAlert
|
from app.models import BudgetDeviationAlert
|
||||||
query = db.query(BudgetDeviationAlert)
|
query = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == entity_id)
|
||||||
if kpi_id:
|
if kpi_id:
|
||||||
query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id)
|
query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id)
|
||||||
if period:
|
if period:
|
||||||
@@ -934,11 +973,120 @@ def list_deviation_alerts(
|
|||||||
"alert_level": a.alert_level,
|
"alert_level": a.alert_level,
|
||||||
"status": a.status,
|
"status": a.status,
|
||||||
"suggestion": a.suggestion,
|
"suggestion": a.suggestion,
|
||||||
|
"alert_type": a.alert_type,
|
||||||
|
"attribution": a.attribution,
|
||||||
|
"scenario_id": a.scenario_id,
|
||||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||||
})
|
})
|
||||||
return {"data": result, "total": len(result)}
|
return {"data": result, "total": len(result)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deviation-alerts/{alert_id}/attribution")
|
||||||
|
def get_deviation_alert_attribution(
|
||||||
|
alert_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""告警归因详情 — 告警 + 归因JSON + 场景建议(联查 scenario_suggestions)(P1-③ 2026-08-28)"""
|
||||||
|
from app.models import BudgetDeviationAlert, ScenarioSuggestion
|
||||||
|
from app.utils.alert_attribution import match_scenario
|
||||||
|
|
||||||
|
alert = db.query(BudgetDeviationAlert).filter(
|
||||||
|
BudgetDeviationAlert.id == alert_id,
|
||||||
|
BudgetDeviationAlert.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(404, "预警记录不存在")
|
||||||
|
|
||||||
|
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first()
|
||||||
|
|
||||||
|
# 归因(若旧告警无归因字段,现场组装一次)
|
||||||
|
attribution = alert.attribution
|
||||||
|
if attribution is None:
|
||||||
|
from app.utils.alert_attribution import build_attribution
|
||||||
|
try:
|
||||||
|
attribution, inferred_type = build_attribution(db, alert.kpi_id, alert.period, alert.alert_type)
|
||||||
|
alert.attribution = attribution
|
||||||
|
if alert.alert_type is None:
|
||||||
|
alert.alert_type = inferred_type
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
attribution = {}
|
||||||
|
|
||||||
|
scenario = None
|
||||||
|
if alert.scenario_id or alert.alert_type:
|
||||||
|
scenario = match_scenario(db, alert.alert_type)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": alert.id,
|
||||||
|
"kpi_id": alert.kpi_id,
|
||||||
|
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
|
||||||
|
"kpi_name": kpi_obj.kpi_name if kpi_obj else "",
|
||||||
|
"period": alert.period,
|
||||||
|
"budget_value": alert.budget_value,
|
||||||
|
"actual_value": alert.actual_value,
|
||||||
|
"deviation_rate": alert.deviation_rate,
|
||||||
|
"deviation_value": alert.deviation_value,
|
||||||
|
"alert_level": alert.alert_level,
|
||||||
|
"status": alert.status,
|
||||||
|
"suggestion": alert.suggestion,
|
||||||
|
"alert_type": alert.alert_type,
|
||||||
|
"attribution": attribution or {},
|
||||||
|
"scenario": scenario,
|
||||||
|
"created_at": alert.created_at.isoformat() if alert.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alert-direction")
|
||||||
|
def get_alert_direction(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""越高越好型KPI方向配置 (P2-⑤ 2026-08-28: system_configs 可维护)"""
|
||||||
|
from app.utils.deviation_engine import get_higher_better_codes, CONFIG_KEY_HIGHER_BETTER
|
||||||
|
from app.models import SystemConfig
|
||||||
|
|
||||||
|
cfg = db.query(SystemConfig).filter(
|
||||||
|
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||||
|
).first()
|
||||||
|
codes = get_higher_better_codes(db)
|
||||||
|
return {
|
||||||
|
"config_key": CONFIG_KEY_HIGHER_BETTER,
|
||||||
|
"codes": codes,
|
||||||
|
"is_configured": bool(cfg and cfg.config_value),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/alert-direction")
|
||||||
|
def update_alert_direction(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}"""
|
||||||
|
import json as _json
|
||||||
|
from app.utils.deviation_engine import CONFIG_KEY_HIGHER_BETTER
|
||||||
|
from app.models import SystemConfig
|
||||||
|
|
||||||
|
codes = data.get("codes")
|
||||||
|
if not isinstance(codes, list):
|
||||||
|
raise HTTPException(400, "codes 必须是非空数组")
|
||||||
|
codes = [str(c) for c in codes]
|
||||||
|
|
||||||
|
cfg = db.query(SystemConfig).filter(
|
||||||
|
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||||
|
).first()
|
||||||
|
if cfg:
|
||||||
|
cfg.config_value = _json.dumps(codes, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
db.add(SystemConfig(
|
||||||
|
config_key=CONFIG_KEY_HIGHER_BETTER,
|
||||||
|
config_value=_json.dumps(codes, ensure_ascii=False),
|
||||||
|
description="越高越好型KPI编码列表(实际低于预算才告警)",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "config_key": CONFIG_KEY_HIGHER_BETTER, "codes": codes}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/deviation-alerts/{alert_id}")
|
@router.put("/deviation-alerts/{alert_id}")
|
||||||
def update_deviation_alert(
|
def update_deviation_alert(
|
||||||
alert_id: int,
|
alert_id: int,
|
||||||
@@ -961,11 +1109,18 @@ def update_deviation_alert(
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/method-comparison")
|
@router.post("/method-comparison")
|
||||||
def budget_method_comparison(data: dict):
|
def budget_method_comparison(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
预算方法三选一对比计算
|
预算方法三选一对比计算
|
||||||
接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... }
|
接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... }
|
||||||
返回三种方法的计算结果
|
返回三种方法的计算结果
|
||||||
|
(2026-08-28 P2-①: zero_based 优先读逐项论证项 budget_zero_based_items,
|
||||||
|
传入 zero_based_kpi_id+zero_based_period 且有论证项 → 逐项求和 is_demo=false;
|
||||||
|
无论证项 → fallback 旧公式 is_demo=true)
|
||||||
"""
|
"""
|
||||||
entity = data.get("entity", "hanke")
|
entity = data.get("entity", "hanke")
|
||||||
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万)
|
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万)
|
||||||
@@ -983,7 +1138,29 @@ def budget_method_comparison(data: dict):
|
|||||||
incremental_result = round(last_month_budget * (1 + increment_rate), 1)
|
incremental_result = round(last_month_budget * (1 + increment_rate), 1)
|
||||||
incremental_detail = f"上月{last_month_budget}万 × (1+{increment_rate*100:.0f}%) = {incremental_result}万"
|
incremental_detail = f"上月{last_month_budget}万 × (1+{increment_rate*100:.0f}%) = {incremental_result}万"
|
||||||
|
|
||||||
# 2. 零基预算: 每项从零论证
|
# 2. 零基预算: 优先逐项论证(P2-① 真零基)
|
||||||
|
zbb_kpi_id = data.get("zero_based_kpi_id")
|
||||||
|
zbb_period = data.get("zero_based_period")
|
||||||
|
zbb_is_demo = True
|
||||||
|
zbb_items = []
|
||||||
|
if zbb_kpi_id and zbb_period:
|
||||||
|
from app.models import BudgetZeroBasedItem
|
||||||
|
zbb_items = db.query(BudgetZeroBasedItem).filter(
|
||||||
|
BudgetZeroBasedItem.entity_id == entity_id,
|
||||||
|
BudgetZeroBasedItem.kpi_id == zbb_kpi_id,
|
||||||
|
BudgetZeroBasedItem.period == zbb_period,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if zbb_items:
|
||||||
|
# 真零基: 逐项求和(仅 approved+draft 都算,draft为未定稿)
|
||||||
|
zbb_total = round(sum(i.proposed_value for i in zbb_items), 1)
|
||||||
|
zbb_is_demo = False
|
||||||
|
zbb_detail = "零基逐项论证: " + " + ".join(
|
||||||
|
f"{i.item_name}{i.proposed_value}万" for i in zbb_items
|
||||||
|
) + f" = {zbb_total}万"
|
||||||
|
zbb_savings = round(last_month_budget - zbb_total, 1)
|
||||||
|
else:
|
||||||
|
# fallback 旧演示公式(标注 is_demo)
|
||||||
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
|
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
|
||||||
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
|
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
|
||||||
zbb_total = round(
|
zbb_total = round(
|
||||||
@@ -1035,6 +1212,8 @@ def budget_method_comparison(data: dict):
|
|||||||
"result_value": zbb_total,
|
"result_value": zbb_total,
|
||||||
"savings": zbb_savings,
|
"savings": zbb_savings,
|
||||||
"detail": zbb_detail,
|
"detail": zbb_detail,
|
||||||
|
"is_demo": zbb_is_demo,
|
||||||
|
"item_count": len(zbb_items),
|
||||||
"pros": "最合理",
|
"pros": "最合理",
|
||||||
"cons": "耗时",
|
"cons": "耗时",
|
||||||
"is_recommended": True,
|
"is_recommended": True,
|
||||||
@@ -1056,6 +1235,170 @@ def budget_method_comparison(data: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply-method")
|
||||||
|
def apply_budget_method(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地)
|
||||||
|
接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... }
|
||||||
|
说明: 方法计算结果 → 写入/更新预算计划(version标注方法名,便于追溯)
|
||||||
|
(2026-08-28 P2-②: KPI派生规则可配置 budget_derivation_rules,
|
||||||
|
percentage_of → base_kpi实际值×rate; incremental → 上月×(1+rate);
|
||||||
|
无规则 fallback 默认比例(净利2%/费用率22%/毛利18%), 响应带 rule_source)
|
||||||
|
"""
|
||||||
|
from app.models import BudgetDerivationRule
|
||||||
|
|
||||||
|
method = data.get("method", "zero_based")
|
||||||
|
year = data.get("year", datetime.now().year)
|
||||||
|
entity = data.get("entity", "hanke")
|
||||||
|
|
||||||
|
# 复用method-comparison计算(获得三法结果)
|
||||||
|
comp = budget_method_comparison({
|
||||||
|
"entity": entity,
|
||||||
|
"last_month_budget": data.get("last_month_budget", 91),
|
||||||
|
"current_revenue": data.get("current_revenue", 122),
|
||||||
|
"fixed_costs": data.get("fixed_costs", {
|
||||||
|
"rent": 15, "labor": 40, "entertainment": 16, "misc": 12,
|
||||||
|
}),
|
||||||
|
"variable_cost_rate": data.get("variable_cost_rate", 0.4862),
|
||||||
|
"increment_rate": data.get("increment_rate", 0.05),
|
||||||
|
"zero_based_kpi_id": data.get("zero_based_kpi_id"),
|
||||||
|
"zero_based_period": data.get("zero_based_period"),
|
||||||
|
}, db=db, entity_id=entity_id)
|
||||||
|
|
||||||
|
# 找所选方法的结果
|
||||||
|
selected = None
|
||||||
|
for m in comp["methods"]:
|
||||||
|
if m["id"] == method:
|
||||||
|
selected = m
|
||||||
|
break
|
||||||
|
if not selected:
|
||||||
|
raise HTTPException(400, "未知预算方法: " + method)
|
||||||
|
|
||||||
|
# 找到该年的核心KPI(营业收入/净利润/费用率等)
|
||||||
|
kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
KPIDefinition.kpi_code.in_(["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"]),
|
||||||
|
).all()
|
||||||
|
if not kpis:
|
||||||
|
raise HTTPException(400, "未找到可应用的KPI")
|
||||||
|
|
||||||
|
# 加载派生规则(P2-②)
|
||||||
|
rules = db.query(BudgetDerivationRule).filter(
|
||||||
|
BudgetDerivationRule.entity_id == entity_id,
|
||||||
|
BudgetDerivationRule.status == "active",
|
||||||
|
).all()
|
||||||
|
rules_by_kpi = {r.kpi_id: r for r in rules}
|
||||||
|
|
||||||
|
# 版本
|
||||||
|
version = f"{method}-{datetime.now().strftime('%Y%m%d')}"
|
||||||
|
applied = []
|
||||||
|
used_configured = False
|
||||||
|
|
||||||
|
for kpi in kpis:
|
||||||
|
period = f"{year}-00"
|
||||||
|
# 删除旧版本的同KPI年度预算
|
||||||
|
db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.kpi_id == kpi.id,
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.version.like(f"{method}-%"),
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# 各KPI的应用值:收入用方法结果,其他优先派生规则(P2-②)
|
||||||
|
if kpi.kpi_code == "F_REVENUE":
|
||||||
|
budget_val = selected["result_value"]
|
||||||
|
rule_source = "default"
|
||||||
|
formula_note = "方法结果"
|
||||||
|
else:
|
||||||
|
rule = rules_by_kpi.get(kpi.id)
|
||||||
|
if rule and rule.params:
|
||||||
|
rate = float(rule.params.get("rate", 0.02))
|
||||||
|
if rule.rule_type == "percentage_of" and rule.base_kpi_id:
|
||||||
|
# 来源KPI实际值 × 比例
|
||||||
|
base_val = None
|
||||||
|
base_actual = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == rule.base_kpi_id,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
|
if base_actual:
|
||||||
|
base_val = base_actual.actual_value
|
||||||
|
if base_val is not None:
|
||||||
|
budget_val = round(base_val * rate, 1)
|
||||||
|
rule_source = "configured"
|
||||||
|
formula_note = f"派生: 来源KPI实际值{base_val} × {rate}"
|
||||||
|
else:
|
||||||
|
budget_val = round(selected["result_value"] * rate, 1)
|
||||||
|
rule_source = "configured_fallback"
|
||||||
|
formula_note = f"派生规则无来源实际值, 按方法结果×{rate}"
|
||||||
|
elif rule.rule_type == "incremental":
|
||||||
|
# 上月预算 × (1+rate)
|
||||||
|
prev_period = f"{year-1}-00"
|
||||||
|
prev_plan = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
|
BudgetPlan.kpi_id == kpi.id,
|
||||||
|
BudgetPlan.period == prev_period,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
).order_by(BudgetPlan.updated_at.desc()).first()
|
||||||
|
if prev_plan and prev_plan.budget_value is not None:
|
||||||
|
budget_val = round(prev_plan.budget_value * (1 + rate), 1)
|
||||||
|
rule_source = "configured"
|
||||||
|
formula_note = f"派生: 上年预算{prev_plan.budget_value} × (1+{rate})"
|
||||||
|
else:
|
||||||
|
budget_val = round(selected["result_value"] * rate, 1)
|
||||||
|
rule_source = "configured_fallback"
|
||||||
|
formula_note = f"派生规则无上年预算, 按方法结果×{rate}"
|
||||||
|
else:
|
||||||
|
# formula 类型: 暂按方法结果×rate 兜底
|
||||||
|
budget_val = round(selected["result_value"] * rate, 1)
|
||||||
|
rule_source = "configured"
|
||||||
|
formula_note = f"派生规则(formula): 方法结果×{rate}"
|
||||||
|
else:
|
||||||
|
# fallback 默认比例
|
||||||
|
if kpi.kpi_code == "F_NET_PROFIT":
|
||||||
|
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2%
|
||||||
|
elif kpi.kpi_code == "F_COST_RATIO":
|
||||||
|
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22%
|
||||||
|
else: # F_GROSS_MARGIN
|
||||||
|
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18%
|
||||||
|
rule_source = "default"
|
||||||
|
formula_note = "默认比例"
|
||||||
|
|
||||||
|
if rule_source in ("configured", "configured_fallback"):
|
||||||
|
used_configured = True
|
||||||
|
|
||||||
|
bp = BudgetPlan(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=kpi.id,
|
||||||
|
period=period,
|
||||||
|
budget_value=budget_val,
|
||||||
|
budget_year=year,
|
||||||
|
budget_month=0,
|
||||||
|
version=version,
|
||||||
|
status="active",
|
||||||
|
remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method} | {formula_note}",
|
||||||
|
calc_logic=formula_note,
|
||||||
|
)
|
||||||
|
db.add(bp)
|
||||||
|
applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val, "rule_source": rule_source})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"message": f"已应用「{selected['name']}」到预算计划",
|
||||||
|
"method": method,
|
||||||
|
"method_name": selected["name"],
|
||||||
|
"version": version,
|
||||||
|
"total_budget": selected["result_value"],
|
||||||
|
"detail": selected["detail"],
|
||||||
|
"applied": applied,
|
||||||
|
"rule_source": "configured" if used_configured else "default",
|
||||||
|
"note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。KPI派生规则可在「派生规则配置」中维护(P2-②)。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============ 预算版本管理 API(2026-08-25 补充,修复前端"加载版本失败") ============
|
# ============ 预算版本管理 API(2026-08-25 补充,修复前端"加载版本失败") ============
|
||||||
|
|
||||||
@router.get("/versions")
|
@router.get("/versions")
|
||||||
@@ -1211,3 +1554,119 @@ def diff_budget_versions(
|
|||||||
},
|
},
|
||||||
"diffs": diffs,
|
"diffs": diffs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 预算↔现金流联动(断点修复#1, 2026-08-27)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@router.post("/sync-cash-plans")
|
||||||
|
def sync_cash_plans(
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1)
|
||||||
|
(2026-08-28 P2-⑥: 分类规则表优先, 未命中进待分类队列不再静默跳过)
|
||||||
|
|
||||||
|
收入类KPI(营收/回款/新客) → receive
|
||||||
|
成本类KPI(费用/厂补/采购) → pay
|
||||||
|
分类来源: ①cash_plan_classify_rules规则表(精确KPI→关键词) ②默认关键词兜底 ③待分类队列
|
||||||
|
upsert: 同KPI+同日期+同类型 更新不重复
|
||||||
|
"""
|
||||||
|
from app.models import CashPlan, CashPlanClassifyRule, CashPlanUnclassified
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 默认关键词兜底(兼容存量,规则表优先)
|
||||||
|
RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利")
|
||||||
|
PAY_KEYS = ("费用", "成本", "厂补", "采购", "返利", "应付", "损耗", "投入")
|
||||||
|
|
||||||
|
# 加载分类规则表(P2-⑥)
|
||||||
|
rules = db.query(CashPlanClassifyRule).filter(
|
||||||
|
CashPlanClassifyRule.entity_id == entity_id,
|
||||||
|
CashPlanClassifyRule.status == "active",
|
||||||
|
).order_by(CashPlanClassifyRule.priority.asc()).all()
|
||||||
|
kpi_rules = {r.kpi_id: r for r in rules if r.kpi_id}
|
||||||
|
pattern_rules = [r for r in rules if not r.kpi_id and r.kpi_code_pattern]
|
||||||
|
|
||||||
|
def classify_plan_type(kpi) -> Optional[str]:
|
||||||
|
"""返回 receive/pay/None(未分类)"""
|
||||||
|
# ① 精确KPI匹配(优先)
|
||||||
|
if kpi.id in kpi_rules:
|
||||||
|
return kpi_rules[kpi.id].plan_type
|
||||||
|
# ② 关键词/编码模式匹配(规则表)
|
||||||
|
name = (kpi.kpi_name or "") + (kpi.kpi_code or "")
|
||||||
|
for r in pattern_rules:
|
||||||
|
if r.kpi_code_pattern and r.kpi_code_pattern in name:
|
||||||
|
return r.plan_type
|
||||||
|
# ③ 默认关键词兜底(兼容存量行为)
|
||||||
|
if any(k in name for k in RECEIVE_KEYS):
|
||||||
|
return "receive"
|
||||||
|
if any(k in name for k in PAY_KEYS):
|
||||||
|
return "pay"
|
||||||
|
return None
|
||||||
|
|
||||||
|
budgets = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id, BudgetPlan.status == "active"
|
||||||
|
).all()
|
||||||
|
kpi_ids = {b.kpi_id for b in budgets}
|
||||||
|
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||||
|
|
||||||
|
created, updated, unclassified_count = 0, 0, 0
|
||||||
|
for b in budgets:
|
||||||
|
kpi = kpis.get(b.kpi_id)
|
||||||
|
if not kpi:
|
||||||
|
continue
|
||||||
|
plan_type = classify_plan_type(kpi)
|
||||||
|
if plan_type is None:
|
||||||
|
# 无法判类别 → 写入待分类队列(不静默跳过,P2-⑥)
|
||||||
|
existing_un = db.query(CashPlanUnclassified).filter(
|
||||||
|
CashPlanUnclassified.entity_id == entity_id,
|
||||||
|
CashPlanUnclassified.kpi_id == b.kpi_id,
|
||||||
|
CashPlanUnclassified.period == b.period,
|
||||||
|
CashPlanUnclassified.status == "pending",
|
||||||
|
).first()
|
||||||
|
if not existing_un:
|
||||||
|
db.add(CashPlanUnclassified(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=b.kpi_id,
|
||||||
|
kpi_name=kpi.kpi_name or kpi.kpi_code,
|
||||||
|
period=b.period,
|
||||||
|
budget_value=b.budget_value,
|
||||||
|
reason="未匹配任何分类规则",
|
||||||
|
status="pending",
|
||||||
|
))
|
||||||
|
unclassified_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
year, month = b.budget_year or 2026, b.budget_month or 1
|
||||||
|
try:
|
||||||
|
plan_date = datetime(year, month, 1)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# upsert: 同KPI+同日期+同类型
|
||||||
|
existing = db.query(CashPlan).filter(
|
||||||
|
CashPlan.entity_id == entity_id,
|
||||||
|
CashPlan.related_kpi_id == b.kpi_id,
|
||||||
|
CashPlan.plan_type == plan_type,
|
||||||
|
CashPlan.plan_date == plan_date,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.amount = b.budget_value
|
||||||
|
existing.budget_plan_id = b.id
|
||||||
|
existing.source = "budget_sync"
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
db.add(CashPlan(
|
||||||
|
entity_id=entity_id, plan_type=plan_type,
|
||||||
|
related_kpi_id=b.kpi_id, budget_plan_id=b.id,
|
||||||
|
amount=b.budget_value, plan_date=plan_date,
|
||||||
|
description=f"预算联动: {kpi.kpi_name or kpi.kpi_code}",
|
||||||
|
status="pending", source="budget_sync",
|
||||||
|
))
|
||||||
|
created += 1
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条",
|
||||||
|
"created": created, "updated": updated,
|
||||||
|
"unclassified_count": unclassified_count,
|
||||||
|
}
|
||||||
|
|||||||
+404
-2
@@ -1,10 +1,15 @@
|
|||||||
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 (资金管理智能体)"""
|
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 + 网银流水导入 (资金管理智能体)"""
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query, Request
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
import pandas as pd
|
||||||
from app.database import get_db
|
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
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
@@ -710,3 +715,400 @@ def api_import_bohai_ar(data: dict = None, entity_id: int = Depends(get_entity_i
|
|||||||
"total_amount_wan": round(total_wan, 2),
|
"total_amount_wan": round(total_wan, 2),
|
||||||
"entity_id": entity_id,
|
"entity_id": entity_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# 6. 网银流水标准导入 — 三校验规则 + 现金流余额联动 (P1方案② 2026-08-28)
|
||||||
|
# 模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "templates" / "网银流水导入模板.xlsx"
|
||||||
|
_TEMPLATE_PATH = str(_TEMPLATE_PATH) # FileResponse/os.path.exists 兼容 str
|
||||||
|
|
||||||
|
# 模板列别名映射(兼容中英文列名)
|
||||||
|
_VOUCHER_COL_ALIASES = {
|
||||||
|
"voucher_date": ["凭证日期", "日期", "voucher_date", "date"],
|
||||||
|
"voucher_no": ["凭证号", "凭证编号", "凭证字号", "voucher_no"],
|
||||||
|
"subject_code": ["科目编码", "科目代码", "subject_code", "code"],
|
||||||
|
"subject_name": ["科目名称", "subject_name", "name"],
|
||||||
|
"debit_amount": ["借方金额", "借方", "debit_amount", "debit"],
|
||||||
|
"credit_amount": ["贷方金额", "贷方", "credit_amount", "credit"],
|
||||||
|
"summary": ["摘要", "备注", "summary", "remark"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_voucher_cols(cols: list) -> dict:
|
||||||
|
"""列名归一化:返回 {字段: 实际列名}(不区分大小写/空格)"""
|
||||||
|
col_map = {}
|
||||||
|
for c in cols:
|
||||||
|
key = str(c).strip().lower()
|
||||||
|
if key and key not in col_map:
|
||||||
|
col_map[key] = str(c)
|
||||||
|
resolved = {}
|
||||||
|
for field, aliases in _VOUCHER_COL_ALIASES.items():
|
||||||
|
for alias in aliases:
|
||||||
|
if alias.lower() in col_map:
|
||||||
|
resolved[field] = col_map[alias.lower()]
|
||||||
|
break
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_str(raw) -> str:
|
||||||
|
"""单元格→干净字符串:NaN/None→空;整数float→去.0(如1002.0→1002)"""
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(raw, float) and pd.isna(raw):
|
||||||
|
return ""
|
||||||
|
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
|
||||||
|
f = float(raw)
|
||||||
|
return str(int(f)) if f.is_integer() else str(f)
|
||||||
|
s = str(raw).strip()
|
||||||
|
return "" if s.lower() in ("nan", "none") else s
|
||||||
|
|
||||||
|
|
||||||
|
def _is_carry_forward(summary: str, subject_name: str) -> bool:
|
||||||
|
"""结转行识别:摘要含'结转' 或 科目名称含'本年利润'/'结转'"""
|
||||||
|
return ("结转" in summary) or ("本年利润" in subject_name) or ("结转" in subject_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float:
|
||||||
|
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)"""
|
||||||
|
from app.models import VoucherDetail
|
||||||
|
from app.utils.cash_forecast_engine import set_current_cash_balance
|
||||||
|
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
|
||||||
|
balance_yuan = round(sum(
|
||||||
|
(r.debit_amount or 0) - (r.credit_amount or 0)
|
||||||
|
for r in rows
|
||||||
|
if r.carry_forward == 0
|
||||||
|
and r.subject_code
|
||||||
|
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}万元 → 现金余额联动")
|
||||||
|
return cash_wan
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> list:
|
||||||
|
"""现金流KPI联动:
|
||||||
|
① EXT_现金类KPI → 货币资金科目期末余额(元,与存量口径一致,source_type=ledger)
|
||||||
|
② F_CASH_SAFETY 现金安全垫(万元 = 货币资金余额 - 短期借款EXT_139,entity_id隔离,不存在则创建)
|
||||||
|
"""
|
||||||
|
from app.models import KPIDefinition, KPIValue, VoucherDetail
|
||||||
|
updates = []
|
||||||
|
period = periods[-1] if periods else datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
|
||||||
|
monetary = [
|
||||||
|
r for r in rows
|
||||||
|
if r.carry_forward == 0 and r.subject_code
|
||||||
|
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
|
||||||
|
]
|
||||||
|
balance_yuan = round(sum((r.debit_amount or 0) - (r.credit_amount or 0) for r in monetary), 2)
|
||||||
|
cash_1001 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0)
|
||||||
|
for r in monetary if r.subject_code.startswith("1001")), 2)
|
||||||
|
cash_1002 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0)
|
||||||
|
for r in monetary if r.subject_code.startswith("1002")), 2)
|
||||||
|
cash_wan = round(balance_yuan / 10000, 2)
|
||||||
|
|
||||||
|
# ── ① EXT_现金类KPI(名称含'现金'/'货币资金',active;排除F_CASH_SAFETY,由②专用逻辑按万元口径处理)──
|
||||||
|
cash_kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
or_(KPIDefinition.kpi_name.like("%现金%"), KPIDefinition.kpi_name.like("%货币资金%")),
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
KPIDefinition.kpi_code != "F_CASH_SAFETY",
|
||||||
|
).all()
|
||||||
|
for k in cash_kpis:
|
||||||
|
if "库存现金" in k.kpi_name:
|
||||||
|
val = cash_1001
|
||||||
|
elif "银行" in k.kpi_name:
|
||||||
|
val = cash_1002
|
||||||
|
else:
|
||||||
|
val = balance_yuan
|
||||||
|
remark = f"网银流水导入[{batch}]联动: 货币资金科目期末余额{balance_yuan}元(库存现金{cash_1001}/银行存款{cash_1002})"
|
||||||
|
existing = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id,
|
||||||
|
KPIValue.period == period,
|
||||||
|
KPIValue.source_type == "ledger",
|
||||||
|
).order_by(KPIValue.id.desc()).first()
|
||||||
|
if existing:
|
||||||
|
existing.actual_value = val
|
||||||
|
existing.source_batch = batch # type: ignore[assignment] # SQLAlchemy Column类型推断噪音
|
||||||
|
existing.remark = remark
|
||||||
|
existing.calculated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
db.add(KPIValue(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=k.id,
|
||||||
|
period=period,
|
||||||
|
actual_value=val,
|
||||||
|
source_type="ledger",
|
||||||
|
source_batch=batch,
|
||||||
|
data_status="verified",
|
||||||
|
remark=remark,
|
||||||
|
))
|
||||||
|
updates.append({"kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "period": period, "value": val})
|
||||||
|
|
||||||
|
# ── ② F_CASH_SAFETY 现金安全垫(万元)──
|
||||||
|
safety_kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
or_(KPIDefinition.kpi_code == "F_CASH_SAFETY", KPIDefinition.kpi_name.like("%安全垫%")),
|
||||||
|
).first()
|
||||||
|
if not safety_kpi:
|
||||||
|
safety_kpi = KPIDefinition(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_code="F_CASH_SAFETY",
|
||||||
|
kpi_name="现金安全垫",
|
||||||
|
dimension="finance",
|
||||||
|
category="cash_risk",
|
||||||
|
formula="货币资金余额-短期借款",
|
||||||
|
formula_desc="货币资金科目(1001/1002)期末余额 - 短期借款(EXT_139),单位万元",
|
||||||
|
unit="万元",
|
||||||
|
target_value=0, # kpi_definitions.target_value NOT NULL DEFAULT 0.00,ORM显式传None会绕过默认值导致IntegrityError
|
||||||
|
data_source_type="ledger",
|
||||||
|
data_source="网银流水导入联动",
|
||||||
|
data_owner="财务Bot",
|
||||||
|
frequency="monthly",
|
||||||
|
status="active",
|
||||||
|
kpi_level="operational",
|
||||||
|
important_flag=1,
|
||||||
|
data_level="core",
|
||||||
|
epic="Epic2",
|
||||||
|
)
|
||||||
|
db.add(safety_kpi)
|
||||||
|
db.flush()
|
||||||
|
logger.info(f"新增KPI F_CASH_SAFETY 现金安全垫 (entity_id={entity_id})")
|
||||||
|
|
||||||
|
short_debt_wan = None
|
||||||
|
debt_kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.kpi_code == "EXT_139",
|
||||||
|
).first()
|
||||||
|
if debt_kpi:
|
||||||
|
dv = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == debt_kpi.id,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.period.desc()).first()
|
||||||
|
if dv and dv.actual_value is not None:
|
||||||
|
short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元
|
||||||
|
|
||||||
|
if short_debt_wan is not None:
|
||||||
|
safety_value = round(cash_wan - short_debt_wan, 2)
|
||||||
|
remark = f"网银流水导入[{batch}]联动: 货币资金{cash_wan}万 - 短期借款{short_debt_wan}万 = 安全垫{safety_value}万"
|
||||||
|
else:
|
||||||
|
safety_value = cash_wan
|
||||||
|
remark = f"网银流水导入[{batch}]联动: 无短期借款(EXT_139)数据,现金安全垫=货币资金余额{cash_wan}万"
|
||||||
|
existing = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == safety_kpi.id,
|
||||||
|
KPIValue.period == period,
|
||||||
|
KPIValue.source_type == "ledger",
|
||||||
|
).order_by(KPIValue.id.desc()).first()
|
||||||
|
if existing:
|
||||||
|
existing.actual_value = safety_value
|
||||||
|
existing.remark = remark
|
||||||
|
existing.calculated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
db.add(KPIValue(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=safety_kpi.id,
|
||||||
|
period=period,
|
||||||
|
actual_value=safety_value,
|
||||||
|
source_type="ledger",
|
||||||
|
source_batch=batch,
|
||||||
|
data_status="verified",
|
||||||
|
remark=remark,
|
||||||
|
))
|
||||||
|
updates.append({"kpi_code": "F_CASH_SAFETY", "kpi_name": "现金安全垫", "period": period, "value": safety_value})
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/import/template")
|
||||||
|
def api_get_voucher_template():
|
||||||
|
"""下载网银流水导入模板xlsx"""
|
||||||
|
if not os.path.exists(_TEMPLATE_PATH):
|
||||||
|
raise HTTPException(404, "模板文件不存在,请联系管理员生成")
|
||||||
|
return FileResponse(
|
||||||
|
_TEMPLATE_PATH,
|
||||||
|
filename="网银流水导入模板.xlsx",
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/vouchers")
|
||||||
|
async def api_import_vouchers(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""网银流水标准导入 — 三校验(借贷平衡/期间合计/结转行) → voucher_details/import_logs → 现金流余额联动"""
|
||||||
|
from app.models import VoucherDetail, ImportLog
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
fname = (file.filename or "网银流水.xlsx").strip()
|
||||||
|
try:
|
||||||
|
df = pd.read_excel(io.BytesIO(content))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"无法读取Excel文件: {e}")
|
||||||
|
if df is None or len(df) == 0:
|
||||||
|
raise HTTPException(400, "Excel文件为空(无数据行)")
|
||||||
|
|
||||||
|
colmap = _resolve_voucher_cols(list(df.columns))
|
||||||
|
missing = [f for f in ("voucher_date", "voucher_no", "subject_code") if f not in colmap]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(400, f"缺少必要列: {', '.join(missing)}(模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要)")
|
||||||
|
|
||||||
|
# ── 逐行校验 ──
|
||||||
|
errors = []
|
||||||
|
valid_rows = []
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
excel_row = idx + 2 # 表头占第1行
|
||||||
|
raw_date = row.get(colmap["voucher_date"])
|
||||||
|
if raw_date is None or (isinstance(raw_date, float) and pd.isna(raw_date)):
|
||||||
|
errors.append({"row": excel_row, "field": "voucher_date", "reason": "凭证日期为空"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
voucher_date = pd.to_datetime(raw_date).to_pydatetime()
|
||||||
|
except Exception:
|
||||||
|
errors.append({"row": excel_row, "field": "voucher_date", "reason": f"日期无法解析: {raw_date}"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
voucher_no = _norm_str(row.get(colmap["voucher_no"]))
|
||||||
|
if not voucher_no:
|
||||||
|
errors.append({"row": excel_row, "field": "voucher_no", "reason": "凭证号为空"})
|
||||||
|
continue
|
||||||
|
subject_code = _norm_str(row.get(colmap["subject_code"]))
|
||||||
|
if not subject_code:
|
||||||
|
errors.append({"row": excel_row, "field": "subject_code", "reason": "科目编码为空"})
|
||||||
|
continue
|
||||||
|
subject_name = _norm_str(row.get(colmap["subject_name"]) if "subject_name" in colmap else "")
|
||||||
|
if not subject_name:
|
||||||
|
errors.append({"row": excel_row, "field": "subject_name", "reason": "科目名称为空"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _parse_amount(raw) -> float:
|
||||||
|
"""金额解析:空→0;数字→float;字符串去逗号→float;失败→None"""
|
||||||
|
if raw is None or (isinstance(raw, float) and pd.isna(raw)):
|
||||||
|
return 0.0
|
||||||
|
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
|
||||||
|
return float(raw)
|
||||||
|
s = str(raw).strip().replace(",", "")
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
debit = _parse_amount(row.get(colmap["debit_amount"]) if "debit_amount" in colmap else None)
|
||||||
|
credit = _parse_amount(row.get(colmap["credit_amount"]) if "credit_amount" in colmap else None)
|
||||||
|
if debit is None:
|
||||||
|
errors.append({"row": excel_row, "field": "debit_amount", "reason": f"借方金额不是有效数字: {row.get(colmap['debit_amount'])}"})
|
||||||
|
continue
|
||||||
|
if credit is None:
|
||||||
|
errors.append({"row": excel_row, "field": "credit_amount", "reason": f"贷方金额不是有效数字: {row.get(colmap['credit_amount'])}"})
|
||||||
|
continue
|
||||||
|
if debit < 0 or credit < 0:
|
||||||
|
errors.append({"row": excel_row, "field": "amount", "reason": "金额不能为负"})
|
||||||
|
continue
|
||||||
|
if debit == 0 and credit == 0:
|
||||||
|
errors.append({"row": excel_row, "field": "amount", "reason": "借贷金额不能同时为0"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
summary = _norm_str(row.get(colmap["summary"]) if "summary" in colmap else "")
|
||||||
|
carry_forward = 1 if _is_carry_forward(summary, subject_name) else 0
|
||||||
|
valid_rows.append({
|
||||||
|
"voucher_no": voucher_no,
|
||||||
|
"voucher_date": voucher_date,
|
||||||
|
"period": voucher_date.strftime("%Y-%m"),
|
||||||
|
"subject_code": subject_code,
|
||||||
|
"subject_name": subject_name,
|
||||||
|
"debit_amount": round(debit, 2),
|
||||||
|
"credit_amount": round(credit, 2),
|
||||||
|
"summary": summary,
|
||||||
|
"carry_forward": carry_forward,
|
||||||
|
})
|
||||||
|
|
||||||
|
total = len(df)
|
||||||
|
success = len(valid_rows)
|
||||||
|
failed = len(errors)
|
||||||
|
|
||||||
|
# ── 校验规则① 借贷平衡(Σ借 vs Σ贷,容差0.01)──
|
||||||
|
debit_total = round(sum(r["debit_amount"] for r in valid_rows), 2)
|
||||||
|
credit_total = round(sum(r["credit_amount"] for r in valid_rows), 2)
|
||||||
|
diff = round(debit_total - credit_total, 2)
|
||||||
|
balance_ok = abs(diff) <= 0.01
|
||||||
|
balance_check = {
|
||||||
|
"passed": balance_ok,
|
||||||
|
"debit_total": debit_total,
|
||||||
|
"credit_total": credit_total,
|
||||||
|
"diff": diff,
|
||||||
|
"tolerance": 0.01,
|
||||||
|
}
|
||||||
|
if not balance_ok:
|
||||||
|
errors.append({"row": 0, "field": "balance", "reason": f"借贷不平衡: 借方合计{debit_total} ≠ 贷方合计{credit_total},差额{diff}"})
|
||||||
|
|
||||||
|
# ── 校验规则② 期间合计(按period汇总,供对账)──
|
||||||
|
period_totals = {}
|
||||||
|
for r in valid_rows:
|
||||||
|
pt = period_totals.setdefault(r["period"], {"debit_total": 0.0, "credit_total": 0.0})
|
||||||
|
pt["debit_total"] = round(pt["debit_total"] + r["debit_amount"], 2)
|
||||||
|
pt["credit_total"] = round(pt["credit_total"] + r["credit_amount"], 2)
|
||||||
|
|
||||||
|
# ── 校验规则③ 结转行 ──
|
||||||
|
carry_forward_count = sum(1 for r in valid_rows if r["carry_forward"])
|
||||||
|
|
||||||
|
# ── 入库(部分成功模式:失败行不阻断整体)──
|
||||||
|
batch = f"{os.path.splitext(fname)[0]}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||||
|
for r in valid_rows:
|
||||||
|
db.add(VoucherDetail(
|
||||||
|
entity_id=entity_id,
|
||||||
|
voucher_no=r["voucher_no"],
|
||||||
|
voucher_date=r["voucher_date"],
|
||||||
|
subject_code=r["subject_code"],
|
||||||
|
subject_name=r["subject_name"],
|
||||||
|
debit_amount=r["debit_amount"],
|
||||||
|
credit_amount=r["credit_amount"],
|
||||||
|
summary=r["summary"],
|
||||||
|
carry_forward=r["carry_forward"],
|
||||||
|
period=r["period"],
|
||||||
|
batch=batch,
|
||||||
|
))
|
||||||
|
periods_in = sorted(set(r["period"] for r in valid_rows))
|
||||||
|
db.add(ImportLog(
|
||||||
|
entity_id=entity_id,
|
||||||
|
filename=fname,
|
||||||
|
batch=batch,
|
||||||
|
total_rows=total,
|
||||||
|
success_rows=success,
|
||||||
|
failed_rows=failed,
|
||||||
|
errors=errors or None,
|
||||||
|
period=periods_in[0] if periods_in else None,
|
||||||
|
import_type="vouchers",
|
||||||
|
created_by="finance-bot",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"网银流水导入[{batch}] entity={entity_id}: 总{total}/成功{success}/失败{failed}, 借贷平衡={'通过' if balance_ok else '失败'}")
|
||||||
|
|
||||||
|
# ── 现金流联动 ──
|
||||||
|
cash_balance = None
|
||||||
|
kpi_updates = []
|
||||||
|
try:
|
||||||
|
cash_balance = _sync_cash_balance_from_ledger(db, entity_id, batch)
|
||||||
|
kpi_updates = _sync_cash_kpis(db, entity_id, batch, periods_in)
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"网银流水导入[{batch}] 现金流联动失败: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"total": total,
|
||||||
|
"success_rows": success,
|
||||||
|
"failed_rows": failed,
|
||||||
|
"errors": errors,
|
||||||
|
"balance_check": balance_check,
|
||||||
|
"period_totals": period_totals,
|
||||||
|
"carry_forward_count": carry_forward_count,
|
||||||
|
"cash_balance": cash_balance,
|
||||||
|
"kpi_updates": kpi_updates,
|
||||||
|
"batch": batch,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""现金流分类规则 API — 管理会计OS (P2-⑥ 2026-08-28)
|
||||||
|
|
||||||
|
分类规则管理(cash_plan_classify_rules) + 待分类队列(cash_plan_unclassified) + 一键归类。
|
||||||
|
sync-cash-plans 未命中的KPI进入待分类队列,人工一键归类 → 自动补建规则+生成CashPlan。
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
|
from app.auth_middleware import require_auth, require_role
|
||||||
|
from app.models import (
|
||||||
|
CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
|
||||||
|
KPIDefinition, BudgetPlan,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/cma/budget",
|
||||||
|
tags=["现金流分类"],
|
||||||
|
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 分类规则 CRUD ──────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/cash-classify-rules")
|
||||||
|
def list_classify_rules(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""分类规则列表(按 entity_id 隔离)"""
|
||||||
|
rows = db.query(CashPlanClassifyRule).filter(
|
||||||
|
CashPlanClassifyRule.entity_id == entity_id
|
||||||
|
).order_by(CashPlanClassifyRule.priority.asc(), CashPlanClassifyRule.id.desc()).all()
|
||||||
|
|
||||||
|
kpi_ids = {r.kpi_id for r in rows if r.kpi_id}
|
||||||
|
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
kpi = kpis.get(r.kpi_id) if r.kpi_id else None
|
||||||
|
result.append({
|
||||||
|
"id": r.id,
|
||||||
|
"kpi_id": r.kpi_id,
|
||||||
|
"kpi_code": kpi.kpi_code if kpi else "",
|
||||||
|
"kpi_name": kpi.kpi_name if kpi else "",
|
||||||
|
"kpi_code_pattern": r.kpi_code_pattern,
|
||||||
|
"plan_type": r.plan_type,
|
||||||
|
"priority": r.priority,
|
||||||
|
"status": r.status,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
})
|
||||||
|
return {"data": result, "total": len(result)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cash-classify-rules")
|
||||||
|
def create_classify_rule(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""新建分类规则(kpi_id 精确 或 kpi_code_pattern 关键词 二选一)"""
|
||||||
|
plan_type = data.get("plan_type")
|
||||||
|
if plan_type not in ("receive", "pay"):
|
||||||
|
raise HTTPException(400, "plan_type 必须是 receive/pay")
|
||||||
|
kpi_id = data.get("kpi_id")
|
||||||
|
pattern = data.get("kpi_code_pattern")
|
||||||
|
if not kpi_id and not pattern:
|
||||||
|
raise HTTPException(400, "需要 kpi_id 或 kpi_code_pattern 至少一个")
|
||||||
|
|
||||||
|
row = CashPlanClassifyRule(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
kpi_code_pattern=pattern,
|
||||||
|
plan_type=plan_type,
|
||||||
|
priority=data.get("priority", 10),
|
||||||
|
status=data.get("status", "active"),
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return {"message": "分类规则已创建", "id": row.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/cash-classify-rules/{rule_id}")
|
||||||
|
def update_classify_rule(
|
||||||
|
rule_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""更新分类规则"""
|
||||||
|
row = db.query(CashPlanClassifyRule).filter(
|
||||||
|
CashPlanClassifyRule.id == rule_id,
|
||||||
|
CashPlanClassifyRule.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "规则不存在")
|
||||||
|
for field in ("kpi_id", "kpi_code_pattern", "plan_type", "priority", "status"):
|
||||||
|
if field in data:
|
||||||
|
setattr(row, field, data[field])
|
||||||
|
db.commit()
|
||||||
|
return {"message": "分类规则已更新", "id": row.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/cash-classify-rules/{rule_id}")
|
||||||
|
def delete_classify_rule(
|
||||||
|
rule_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""删除分类规则"""
|
||||||
|
row = db.query(CashPlanClassifyRule).filter(
|
||||||
|
CashPlanClassifyRule.id == rule_id,
|
||||||
|
CashPlanClassifyRule.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "规则不存在")
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "分类规则已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 待分类队列 ──────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/cash-unclassified")
|
||||||
|
def list_unclassified(
|
||||||
|
status: Optional[str] = Query(None, description="pending/classified/ignored"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""待分类KPI队列"""
|
||||||
|
query = db.query(CashPlanUnclassified).filter(CashPlanUnclassified.entity_id == entity_id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(CashPlanUnclassified.status == status)
|
||||||
|
rows = query.order_by(CashPlanUnclassified.created_at.desc()).all()
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
result.append({
|
||||||
|
"id": r.id,
|
||||||
|
"kpi_id": r.kpi_id,
|
||||||
|
"kpi_name": r.kpi_name,
|
||||||
|
"period": r.period,
|
||||||
|
"budget_value": r.budget_value,
|
||||||
|
"reason": r.reason,
|
||||||
|
"status": r.status,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
"resolved_at": r.resolved_at.isoformat() if r.resolved_at else None,
|
||||||
|
})
|
||||||
|
return {"data": result, "total": len(result)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cash-unclassified/{item_id}/classify")
|
||||||
|
def classify_unclassified(
|
||||||
|
item_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""一键归类: body {plan_type: receive/pay}
|
||||||
|
① 自动补建分类规则 ② 标记队列 classified ③ 联动生成对应 CashPlan
|
||||||
|
"""
|
||||||
|
plan_type = data.get("plan_type")
|
||||||
|
if plan_type not in ("receive", "pay"):
|
||||||
|
raise HTTPException(400, "plan_type 必须是 receive/pay")
|
||||||
|
|
||||||
|
item = db.query(CashPlanUnclassified).filter(
|
||||||
|
CashPlanUnclassified.id == item_id,
|
||||||
|
CashPlanUnclassified.entity_id == entity_id,
|
||||||
|
CashPlanUnclassified.status == "pending",
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "待分类记录不存在或已处理")
|
||||||
|
|
||||||
|
# ① 自动补建规则(无精确KPI规则时)
|
||||||
|
existing_rule = db.query(CashPlanClassifyRule).filter(
|
||||||
|
CashPlanClassifyRule.entity_id == entity_id,
|
||||||
|
CashPlanClassifyRule.kpi_id == item.kpi_id,
|
||||||
|
).first()
|
||||||
|
if not existing_rule:
|
||||||
|
db.add(CashPlanClassifyRule(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=item.kpi_id,
|
||||||
|
kpi_code_pattern=None,
|
||||||
|
plan_type=plan_type,
|
||||||
|
priority=10,
|
||||||
|
status="active",
|
||||||
|
))
|
||||||
|
|
||||||
|
# ② 标记队列
|
||||||
|
item.status = "classified"
|
||||||
|
item.resolved_at = datetime.now()
|
||||||
|
|
||||||
|
# ③ 联动生成 CashPlan(有期间和预算值时)
|
||||||
|
plan_created = False
|
||||||
|
if item.period and item.budget_value is not None:
|
||||||
|
try:
|
||||||
|
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
|
||||||
|
plan_date = datetime(year, month, 1)
|
||||||
|
except Exception:
|
||||||
|
plan_date = None
|
||||||
|
if plan_date:
|
||||||
|
existing_plan = db.query(CashPlan).filter(
|
||||||
|
CashPlan.entity_id == entity_id,
|
||||||
|
CashPlan.related_kpi_id == item.kpi_id,
|
||||||
|
CashPlan.plan_type == plan_type,
|
||||||
|
CashPlan.plan_date == plan_date,
|
||||||
|
).first()
|
||||||
|
if not existing_plan:
|
||||||
|
db.add(CashPlan(
|
||||||
|
entity_id=entity_id,
|
||||||
|
plan_type=plan_type,
|
||||||
|
related_kpi_id=item.kpi_id,
|
||||||
|
amount=item.budget_value,
|
||||||
|
plan_date=plan_date,
|
||||||
|
description=f"待分类队列归类: {item.kpi_name or ''}",
|
||||||
|
status="pending",
|
||||||
|
source="budget_sync",
|
||||||
|
))
|
||||||
|
plan_created = True
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"message": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""),
|
||||||
|
"plan_type": plan_type,
|
||||||
|
"rule_created": not existing_rule,
|
||||||
|
"plan_created": plan_created,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cash-unclassified/{item_id}/ignore")
|
||||||
|
def ignore_unclassified(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""忽略该KPI(不生成规则)"""
|
||||||
|
item = db.query(CashPlanUnclassified).filter(
|
||||||
|
CashPlanUnclassified.id == item_id,
|
||||||
|
CashPlanUnclassified.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "待分类记录不存在")
|
||||||
|
item.status = "ignored"
|
||||||
|
item.resolved_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已忽略"}
|
||||||
@@ -441,10 +441,11 @@ def my_dashboard(
|
|||||||
target = kpi_target_by_frequency(k)
|
target = kpi_target_by_frequency(k)
|
||||||
level = "gray"
|
level = "gray"
|
||||||
if actual is not None and target:
|
if actual is not None and target:
|
||||||
# 反向指标(越低越好):费用率/渠补率/应收天数/返利率/成本率
|
# 反向指标(越低越好):费用率/渠补率/应收天数/返利率/成本率/存货天数
|
||||||
REVERSE_INDICATORS = {
|
REVERSE_INDICATORS = {
|
||||||
"F_COST_RATIO", "C_REBATE_RATE", "F_AR_DAYS",
|
"F_COST_RATIO", "C_REBATE_RATE", "F_AR_DAYS",
|
||||||
"F_REBATE_RATE", "F_FACTORY_REBATE_RATE", "F_COST_CONTROL_RATE",
|
"F_REBATE_RATE", "F_FACTORY_REBATE_RATE", "F_COST_CONTROL_RATE",
|
||||||
|
"F_INV_DAYS", "F_DEBT_RATIO", "P_BUG_RATE", "P_REWORK_PCT",
|
||||||
}
|
}
|
||||||
if k.kpi_code in REVERSE_INDICATORS:
|
if k.kpi_code in REVERSE_INDICATORS:
|
||||||
# 反向:实际≤目标=绿;实际≤目标*1.1=黄;否则红
|
# 反向:实际≤目标=绿;实际≤目标*1.1=黄;否则红
|
||||||
@@ -471,13 +472,21 @@ def my_dashboard(
|
|||||||
"period": latest_v.period if latest_v else None,
|
"period": latest_v.period if latest_v else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2. 我的改善行动(assignee匹配)
|
# 2. 我的改善行动(assignee匹配;CEO/管理员看全部)
|
||||||
from app.models import ActionPlan
|
from app.models import ActionPlan
|
||||||
|
if current_user.role == "ceo":
|
||||||
|
# CEO/管理员查看全部行动方案(2026-08-26修复: 原逻辑只按assignee过滤导致工作台显示空)
|
||||||
|
# 2026-08-31 隔离补漏: CEO 看全部也须按 entity_id 过滤,防跨账套待办串数据
|
||||||
|
my_plans = db.query(ActionPlan).filter(
|
||||||
|
ActionPlan.entity_id == entity_id,
|
||||||
|
).order_by(ActionPlan.updated_at.desc()).all()
|
||||||
|
else:
|
||||||
my_plans = db.query(ActionPlan).filter(
|
my_plans = db.query(ActionPlan).filter(
|
||||||
or_(
|
or_(
|
||||||
ActionPlan.assignee == username,
|
ActionPlan.assignee == username,
|
||||||
ActionPlan.assignee == name,
|
ActionPlan.assignee == name,
|
||||||
)
|
),
|
||||||
|
ActionPlan.entity_id == entity_id,
|
||||||
).order_by(ActionPlan.updated_at.desc()).all()
|
).order_by(ActionPlan.updated_at.desc()).all()
|
||||||
|
|
||||||
plan_list = []
|
plan_list = []
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""数据分类分级 — 重要数据标记(2026-08-26 政策驱动)
|
||||||
|
|
||||||
|
背景:《网络数据安全风险评估办法》(2026-08-20 三部门施行)
|
||||||
|
- 重要数据处理者每年一次强制评估
|
||||||
|
- 评估前提 = 先分类分级("不知道重要数据在哪,评估无从谈起")
|
||||||
|
- 本模块 = "分类分级工具"第一版(标记+清单),不做评估算法
|
||||||
|
|
||||||
|
数据级别(参考 GB/T 43697 简化):
|
||||||
|
- core 核心数据(一旦遭篡改/破坏/泄露,直接危害国家安全/经济运行)
|
||||||
|
- important 重要数据(一旦遭篡改/破坏/泄露,危害公共利益/企业关键业务)
|
||||||
|
- general 一般数据(其余)
|
||||||
|
|
||||||
|
覆盖对象:
|
||||||
|
- subjects 会计科目(全局,无 entity_id,与现有科目管理一致)
|
||||||
|
- kpi_definitions KPI字典(按 entity_id 多租户隔离)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
|
from app.auth_middleware import require_role, require_auth
|
||||||
|
from app.models import Subject, KPIDefinition, Entity
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/cma/data-classification", tags=["数据分类分级"],
|
||||||
|
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 写操作只允许 ceo/finance/it(与 KPI 字典一致)
|
||||||
|
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||||
|
|
||||||
|
VALID_LEVELS = {"core", "important", "general"}
|
||||||
|
|
||||||
|
LEVEL_LABELS = {
|
||||||
|
"core": "核心数据",
|
||||||
|
"important": "重要数据",
|
||||||
|
"general": "一般数据",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 行业参考目录(内置提示,可按行业自定义扩展)
|
||||||
|
INDUSTRY_REFERENCE = [
|
||||||
|
{"industry": "通用/企业服务", "category": "客户数据", "suggest_level": "important",
|
||||||
|
"desc": "客户名单、合同、订单、应收账款等客户经营数据"},
|
||||||
|
{"industry": "通用/企业服务", "category": "财务数据", "suggest_level": "important",
|
||||||
|
"desc": "财务报表、银行账户、税务申报、薪酬数据"},
|
||||||
|
{"industry": "通用/企业服务", "category": "员工数据", "suggest_level": "important",
|
||||||
|
"desc": "员工身份、薪酬、社保、考勤、健康信息"},
|
||||||
|
{"industry": "金融", "category": "账户交易", "suggest_level": "core",
|
||||||
|
"desc": "客户账户、交易流水、信贷记录(金融行业核心数据)"},
|
||||||
|
{"industry": "医疗", "category": "健康医疗", "suggest_level": "core",
|
||||||
|
"desc": "病历、健康档案、基因数据(医疗行业核心数据)"},
|
||||||
|
{"industry": "工业", "category": "关键基础设施", "suggest_level": "important",
|
||||||
|
"desc": "生产控制、工艺参数、供应链关键环节数据"},
|
||||||
|
{"industry": "互联网", "category": "用户个人信息", "suggest_level": "important",
|
||||||
|
"desc": "个人信息、日志、位置数据(万人以上规模需重点评估)"},
|
||||||
|
{"industry": "能源", "category": "能源数据", "suggest_level": "important",
|
||||||
|
"desc": "能源生产、传输、消费监测数据"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_to_item(s: Subject) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "subject",
|
||||||
|
"id": s.id,
|
||||||
|
"code": s.subject_code,
|
||||||
|
"name": s.subject_name,
|
||||||
|
"dimension": None,
|
||||||
|
"category": s.new_standard_category or s.category or "",
|
||||||
|
"important_flag": 1 if s.important_flag else 0,
|
||||||
|
"data_level": s.data_level or "general",
|
||||||
|
"data_category": s.data_category or "",
|
||||||
|
"data_owner": None,
|
||||||
|
"storage": "会计科目台账",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi_to_item(k: KPIDefinition) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "kpi",
|
||||||
|
"id": k.id,
|
||||||
|
"code": k.kpi_code,
|
||||||
|
"name": k.kpi_name,
|
||||||
|
"dimension": k.dimension,
|
||||||
|
"category": k.category or "",
|
||||||
|
"important_flag": 1 if k.important_flag else 0,
|
||||||
|
"data_level": k.data_level or "general",
|
||||||
|
"data_category": k.data_category or "",
|
||||||
|
"data_owner": k.data_owner,
|
||||||
|
"storage": f"KPI台账(entity_id={k.entity_id})",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_items(
|
||||||
|
data_type: Optional[str],
|
||||||
|
data_level: Optional[str],
|
||||||
|
important: Optional[int],
|
||||||
|
keyword: Optional[str],
|
||||||
|
entity_id: int,
|
||||||
|
db: Session,
|
||||||
|
) -> list:
|
||||||
|
"""内部清单查询(供 inventory/export 复用,避免依赖注入问题)"""
|
||||||
|
items: list[dict] = []
|
||||||
|
|
||||||
|
if not data_type or data_type == "subject":
|
||||||
|
q = db.query(Subject).filter(Subject.is_active == 1)
|
||||||
|
if data_level:
|
||||||
|
q = q.filter(Subject.data_level == data_level)
|
||||||
|
if important == 1:
|
||||||
|
q = q.filter(Subject.important_flag == 1)
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
q = q.filter(
|
||||||
|
Subject.subject_name.like(like) | Subject.subject_code.like(like)
|
||||||
|
)
|
||||||
|
for s in q.order_by(Subject.subject_code).all():
|
||||||
|
items.append(_subject_to_item(s))
|
||||||
|
|
||||||
|
if not data_type or data_type == "kpi":
|
||||||
|
q = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
)
|
||||||
|
if data_level:
|
||||||
|
q = q.filter(KPIDefinition.data_level == data_level)
|
||||||
|
if important == 1:
|
||||||
|
q = q.filter(KPIDefinition.important_flag == 1)
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
q = q.filter(
|
||||||
|
KPIDefinition.kpi_name.like(like) | KPIDefinition.kpi_code.like(like)
|
||||||
|
)
|
||||||
|
for k in q.order_by(KPIDefinition.kpi_code).all():
|
||||||
|
items.append(_kpi_to_item(k))
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_of(items: list) -> dict:
|
||||||
|
return {
|
||||||
|
"total": len(items),
|
||||||
|
"marked": sum(1 for i in items if i["important_flag"] == 1),
|
||||||
|
"by_level": {
|
||||||
|
"core": sum(1 for i in items if i["data_level"] == "core"),
|
||||||
|
"important": sum(1 for i in items if i["data_level"] == "important"),
|
||||||
|
"general": sum(1 for i in items if i["data_level"] == "general"),
|
||||||
|
},
|
||||||
|
"by_type": {
|
||||||
|
"subject": sum(1 for i in items if i["type"] == "subject"),
|
||||||
|
"kpi": sum(1 for i in items if i["type"] == "kpi"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory")
|
||||||
|
def get_inventory(
|
||||||
|
data_type: Optional[str] = Query(None, description="subject/kpi,缺省返回全部"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||||
|
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||||
|
keyword: Optional[str] = Query(None, description="编码/名称搜索"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""数据资产清单(科目 + KPI 合并输出,含重要级别)"""
|
||||||
|
if data_level and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||||
|
|
||||||
|
items = _query_items(data_type, data_level, important, keyword, entity_id, db)
|
||||||
|
return {"total": len(items), "items": items, "stats": _stats_of(items)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_stats(
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""数据分类分级统计概览(评估前准备看板)"""
|
||||||
|
subject_total = db.query(Subject).filter(Subject.is_active == 1).count()
|
||||||
|
subject_marked = db.query(Subject).filter(
|
||||||
|
Subject.is_active == 1, Subject.important_flag == 1
|
||||||
|
).count()
|
||||||
|
kpi_total = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||||
|
).count()
|
||||||
|
kpi_marked = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.important_flag == 1,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"entity_name": ent.name if ent else "",
|
||||||
|
"subjects": {"total": subject_total, "marked": subject_marked},
|
||||||
|
"kpis": {"total": kpi_total, "marked": kpi_marked},
|
||||||
|
"total": subject_total + kpi_total,
|
||||||
|
"marked_total": subject_marked + kpi_marked,
|
||||||
|
"coverage_pct": round((subject_marked + kpi_marked) / (subject_total + kpi_total) * 100, 1)
|
||||||
|
if (subject_total + kpi_total) else 0,
|
||||||
|
"policy_note": "《网络数据安全风险评估办法》(2026-08-20施行):重要数据处理者每年一次强制评估,评估前提=先完成数据分类分级",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/subjects/{subject_id}")
|
||||||
|
def mark_subject(
|
||||||
|
subject_id: int,
|
||||||
|
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||||
|
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""标记单个科目:重要数据标记 + 数据分级 + 行业分类"""
|
||||||
|
subject = db.query(Subject).filter(Subject.id == subject_id).first()
|
||||||
|
if not subject:
|
||||||
|
raise HTTPException(404, "科目不存在")
|
||||||
|
|
||||||
|
if data_level is not None and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||||
|
|
||||||
|
if important_flag is not None:
|
||||||
|
subject.important_flag = 1 if important_flag else 0
|
||||||
|
if data_level is not None:
|
||||||
|
subject.data_level = data_level
|
||||||
|
if data_level in ("core", "important"):
|
||||||
|
subject.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||||
|
if data_category is not None:
|
||||||
|
subject.data_category = data_category or None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "更新成功",
|
||||||
|
"subject_id": subject_id,
|
||||||
|
"important_flag": subject.important_flag,
|
||||||
|
"data_level": subject.data_level,
|
||||||
|
"data_category": subject.data_category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/kpis/{kpi_id}")
|
||||||
|
def mark_kpi(
|
||||||
|
kpi_id: int,
|
||||||
|
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||||
|
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""标记单个KPI:重要数据标记 + 数据分级 + 行业分类(按账套隔离)"""
|
||||||
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.id == kpi_id,
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI不存在或不属于当前账套")
|
||||||
|
|
||||||
|
if data_level is not None and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||||
|
|
||||||
|
if important_flag is not None:
|
||||||
|
kpi.important_flag = 1 if important_flag else 0
|
||||||
|
if data_level is not None:
|
||||||
|
kpi.data_level = data_level
|
||||||
|
if data_level in ("core", "important"):
|
||||||
|
kpi.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||||
|
if data_category is not None:
|
||||||
|
kpi.data_category = data_category or None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "更新成功",
|
||||||
|
"kpi_id": kpi_id,
|
||||||
|
"important_flag": kpi.important_flag,
|
||||||
|
"data_level": kpi.data_level,
|
||||||
|
"data_category": kpi.data_category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/batch")
|
||||||
|
def batch_mark(
|
||||||
|
data_type: str = Query(..., description="subject/kpi"),
|
||||||
|
ids: List[int] = Query(..., description="ID列表"),
|
||||||
|
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||||
|
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""批量标记(科目全局 / KPI按账套隔离)"""
|
||||||
|
if data_type not in ("subject", "kpi"):
|
||||||
|
raise HTTPException(400, f"无效的数据类型: {data_type},可选 subject/kpi")
|
||||||
|
if data_level is not None and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||||
|
if not ids:
|
||||||
|
raise HTTPException(400, "ids 不能为空")
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
if data_type == "subject":
|
||||||
|
q = db.query(Subject).filter(Subject.id.in_(ids), Subject.is_active == 1)
|
||||||
|
targets = q.all()
|
||||||
|
for s in targets:
|
||||||
|
if important_flag is not None:
|
||||||
|
s.important_flag = 1 if important_flag else 0
|
||||||
|
if data_level is not None:
|
||||||
|
s.data_level = data_level
|
||||||
|
if data_level in ("core", "important"):
|
||||||
|
s.important_flag = 1
|
||||||
|
if data_category is not None:
|
||||||
|
s.data_category = data_category or None
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
q = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.id.in_(ids),
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
)
|
||||||
|
targets = q.all()
|
||||||
|
for k in targets:
|
||||||
|
if important_flag is not None:
|
||||||
|
k.important_flag = 1 if important_flag else 0
|
||||||
|
if data_level is not None:
|
||||||
|
k.data_level = data_level
|
||||||
|
if data_level in ("core", "important"):
|
||||||
|
k.important_flag = 1
|
||||||
|
if data_category is not None:
|
||||||
|
k.data_category = data_category or None
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"批量更新成功", "data_type": data_type, "updated_count": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export_csv(
|
||||||
|
data_type: Optional[str] = Query(None, description="subject/kpi,缺省全部"),
|
||||||
|
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||||
|
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""导出数据分类分级清单(评估前准备材料)CSV"""
|
||||||
|
if data_level and data_level not in VALID_LEVELS:
|
||||||
|
raise HTTPException(400, f"无效的数据级别: {data_level}")
|
||||||
|
|
||||||
|
items = _query_items(data_type, data_level, important, None, entity_id, db)
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(["类型", "编码", "名称", "维度", "分类", "重要标记", "数据级别", "行业参考分类", "数据责任人", "存储位置"])
|
||||||
|
for i in items:
|
||||||
|
writer.writerow([
|
||||||
|
"科目" if i["type"] == "subject" else "KPI",
|
||||||
|
i["code"],
|
||||||
|
i["name"],
|
||||||
|
i["dimension"] or "",
|
||||||
|
i["category"] or "",
|
||||||
|
"是" if i["important_flag"] else "否",
|
||||||
|
LEVEL_LABELS.get(i["data_level"], i["data_level"]),
|
||||||
|
i["data_category"] or "",
|
||||||
|
i["data_owner"] or "",
|
||||||
|
i["storage"] or "",
|
||||||
|
])
|
||||||
|
|
||||||
|
content = "\ufeff" + buf.getvalue() # BOM 兼容 Excel
|
||||||
|
filename = f"data-classification-inventory_{datetime.now().strftime('%Y%m%d')}.csv"
|
||||||
|
from starlette.responses import Response
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/industry-reference")
|
||||||
|
def industry_reference():
|
||||||
|
"""行业参考目录(内置提示,按行业给出重点数据分类建议)"""
|
||||||
|
return {"items": INDUSTRY_REFERENCE}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, and_
|
from sqlalchemy import func, and_, text
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import json
|
import json
|
||||||
@@ -322,3 +322,255 @@ def quality_stats(db: Session = Depends(get_db)):
|
|||||||
"threshold_days": 180,
|
"threshold_days": 180,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# DAMA 数据治理规则检查(财务七规则)— 2026-08-30 P1
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
RULES_META = {
|
||||||
|
"unit_check": {"name": "单位校验", "level": "error", "desc": "cash_plans.amount > 10000(万元口径可疑,疑似单位错乱)"},
|
||||||
|
"dup_alert": {"name": "重复预警", "level": "error", "desc": "同一plan_id存在多条pending应收预警(去重键错误)"},
|
||||||
|
"orphan_check": {"name": "孤儿预警", "level": "error", "desc": "预警suggestion.plan_id指向不存在的cash_plans记录"},
|
||||||
|
"virtual_pollution": {"name": "虚拟污染", "level": "error", "desc": "cash_plans.source含test/虚拟等测试标识混入真实数据"},
|
||||||
|
"entity_check": {"name": "实体归属", "level": "error", "desc": "kpi_values.entity_id与kpi_definitions.entity_id不一致"},
|
||||||
|
"kpi_completeness": {"name": "KPI完整性", "level": "warning", "desc": "active状态KPI无任何实际值的数量"},
|
||||||
|
"reconciliation": {"name": "勾稽验证", "level": "warning", "desc": "预算月度合计 vs 年度目标差异>20%"},
|
||||||
|
}
|
||||||
|
|
||||||
|
DETAIL_LIMIT = 10 # 每条规则detail最多列出的条数(避免响应过大)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/check-governance")
|
||||||
|
def check_governance(
|
||||||
|
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""DAMA数据治理规则检查(财务七规则)— 返回质量评分+异常清单。
|
||||||
|
|
||||||
|
评分规则: 满分100,error级规则每条扣10分,warning级规则每条扣5分,
|
||||||
|
每条规则最多扣一次分(按规则是否命中,不按count累扣),最低0分。
|
||||||
|
|
||||||
|
收敛说明(2026-08-31 安全修复 P1-2):规则执行统一复用
|
||||||
|
_run_governance_checks(Python 解析 suggestion JSON,SQLite 兼容),
|
||||||
|
删除原 _run_rule_checks(MySQL JSON_EXTRACT/JSON_VALID 版,SQLite 不兼容)。
|
||||||
|
本端点保留原评分口径与响应结构(governance-check 端点使用新的扣分口径)。
|
||||||
|
"""
|
||||||
|
gov = _run_governance_checks(db, entity_id or 0)
|
||||||
|
issues = gov["issues"]
|
||||||
|
|
||||||
|
# 计算评分(check-governance 原口径:error 扣10 / warning 扣5,每规则最多扣一次)
|
||||||
|
score = 100
|
||||||
|
for item in issues:
|
||||||
|
if item["count"] > 0:
|
||||||
|
score -= 10 if item["level"] == "error" else 5
|
||||||
|
score = max(0, score)
|
||||||
|
|
||||||
|
passed = [item["rule"] for item in issues if item["count"] == 0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"checked_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"entity_id": entity_id or 0,
|
||||||
|
"score": score,
|
||||||
|
"total_rules": len(issues),
|
||||||
|
"issues": issues,
|
||||||
|
"passed": passed,
|
||||||
|
"rules_meta": RULES_META,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 财务七规则检查(governance-check)— 2026-08-30 P1 最终方案
|
||||||
|
# 与 /check-governance 的区别:
|
||||||
|
# * 评分规则不同:error 扣 min(15, count*3),warning 扣 min(10, count*1)
|
||||||
|
# * 规则2/3 用 Python 解析 suggestion JSON(不依赖 MySQL JSON 函数)
|
||||||
|
# * 规则4 区分 error(test/sync/虚拟) 与 manual(待人工确认 warning)
|
||||||
|
# * 规则7 按 status='active' 口径(与 budget.py 一致,避免多版本叠加失真)
|
||||||
|
# 只读幂等:不写库、不创建 KpiDataQualityLog
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_plan_id(suggestion: str):
|
||||||
|
"""从 kpi_alerts.suggestion (Text 存 JSON) 解析 plan_id;解析失败返回 None"""
|
||||||
|
if not suggestion:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(suggestion)
|
||||||
|
return data.get("plan_id")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_governance_checks(db: Session, entity_id: int = 0):
|
||||||
|
"""执行财务七规则,返回 issues 列表(含 deducted 扣分)。entity_id=0 表示全部实体。"""
|
||||||
|
eid = entity_id or 0
|
||||||
|
ent = " AND cp.entity_id = :eid" if eid else ""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# ── 规则1 单位校验:amount > 10000(万元口径可疑)──
|
||||||
|
rows = db.execute(text(
|
||||||
|
"SELECT cp.id, cp.entity_id, cp.amount, cp.source, cp.description "
|
||||||
|
"FROM cash_plans cp WHERE cp.amount > 10000" + ent + " ORDER BY cp.amount DESC LIMIT 200"
|
||||||
|
), {"eid": eid}).fetchall()
|
||||||
|
issues.append({
|
||||||
|
"rule": "unit_check", "level": "error",
|
||||||
|
"count": len(rows),
|
||||||
|
"detail": [f"plan#{r.id} 金额{r.amount}(疑似元)" for r in rows[:DETAIL_LIMIT]]
|
||||||
|
+ (["…等%d条" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 规则2/3 共用数据:pending cash_plan 预警(实体筛选经 kpi_definitions)──
|
||||||
|
alert_sql = (
|
||||||
|
"SELECT a.id, a.kpi_id, a.suggestion, d.entity_id AS kpi_entity_id "
|
||||||
|
"FROM kpi_alerts a JOIN kpi_definitions d ON d.id = a.kpi_id "
|
||||||
|
"WHERE a.alert_type = 'cash_plan' AND a.status = 'pending'"
|
||||||
|
)
|
||||||
|
if eid:
|
||||||
|
alert_sql += " AND d.entity_id = :eid"
|
||||||
|
alert_rows = db.execute(text(alert_sql), {"eid": eid}).fetchall()
|
||||||
|
|
||||||
|
# Python 侧解析 suggestion → plan_id(不依赖 MySQL JSON 函数)
|
||||||
|
parsed = [] # [(alert_id, kpi_id, plan_id)]
|
||||||
|
for r in alert_rows:
|
||||||
|
pid = _extract_plan_id(r.suggestion)
|
||||||
|
if pid is not None:
|
||||||
|
parsed.append((r.id, r.kpi_id, pid))
|
||||||
|
|
||||||
|
# 已存在的 cash_plans id 集合(规则3 判断孤儿用;实体筛选时仅看该实体下 plan)
|
||||||
|
plan_ids_sql = "SELECT id FROM cash_plans" + (" WHERE entity_id = :eid" if eid else "")
|
||||||
|
plan_id_set = {row[0] for row in db.execute(text(plan_ids_sql), {"eid": eid}).fetchall()}
|
||||||
|
|
||||||
|
# ── 规则2 重复预警:同 plan_id 多条 pending 预警 ──
|
||||||
|
group_map = {}
|
||||||
|
for alert_id, kpi_id, pid in parsed:
|
||||||
|
group_map.setdefault(pid, []).append(alert_id)
|
||||||
|
dup_groups = [(pid, ids) for pid, ids in group_map.items() if len(ids) > 1]
|
||||||
|
dup_groups.sort(key=lambda x: -len(x[1]))
|
||||||
|
issues.append({
|
||||||
|
"rule": "dup_alert", "level": "error",
|
||||||
|
"count": len(dup_groups),
|
||||||
|
"detail": [f"plan#{pid} 重复预警×{len(ids)}" for pid, ids in dup_groups[:DETAIL_LIMIT]]
|
||||||
|
+ (["…等%d个plan" % len(dup_groups)] if len(dup_groups) > DETAIL_LIMIT else []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 规则3 孤儿预警:plan_id 指向不存在的 cash_plans ──
|
||||||
|
orphan_rows = [(aid, kid, pid) for aid, kid, pid in parsed if pid not in plan_id_set]
|
||||||
|
issues.append({
|
||||||
|
"rule": "orphan_check", "level": "error",
|
||||||
|
"count": len(orphan_rows),
|
||||||
|
"detail": [f"预警#{aid}(kpi#{kid}) → plan#{pid} 不存在" for aid, kid, pid in orphan_rows[:DETAIL_LIMIT]]
|
||||||
|
+ (["…等%d条" % len(orphan_rows)] if len(orphan_rows) > DETAIL_LIMIT else []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 规则4 虚拟污染:source 含 test/sync/虚拟 → error;source='manual' → 待人工确认 warning ──
|
||||||
|
rows = db.execute(text(
|
||||||
|
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
|
||||||
|
"WHERE (cp.source LIKE '%test%' OR cp.source LIKE '%sync%' OR cp.source LIKE '%虚拟%' OR cp.source LIKE '%demo%')"
|
||||||
|
+ ent + " ORDER BY cp.id LIMIT 200"
|
||||||
|
), {"eid": eid}).fetchall()
|
||||||
|
manual_rows = db.execute(text(
|
||||||
|
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
|
||||||
|
"WHERE cp.source = 'manual'" + ent + " ORDER BY cp.id LIMIT 200"
|
||||||
|
), {"eid": eid}).fetchall()
|
||||||
|
issues.append({
|
||||||
|
"rule": "virtual_pollution", "level": "error",
|
||||||
|
"count": len(rows),
|
||||||
|
"detail": [f"plan#{r.id} source={r.source} desc={r.description or ''}" for r in rows[:DETAIL_LIMIT]]
|
||||||
|
+ (["…等%d条" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
|
||||||
|
"manual_count": len(manual_rows),
|
||||||
|
"manual_detail": [f"plan#{r.id} source=manual(待人工确认)" for r in manual_rows[:DETAIL_LIMIT]]
|
||||||
|
+ (["…等%d条" % len(manual_rows)] if len(manual_rows) > DETAIL_LIMIT else []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 规则5 实体归属:kpi_values.entity_id IS NOT NULL 且 != kpi_definitions.entity_id ──
|
||||||
|
ent_sql = (
|
||||||
|
"SELECT v.id, v.kpi_id, d.kpi_code, v.entity_id AS v_eid, d.entity_id AS d_eid "
|
||||||
|
"FROM kpi_values v JOIN kpi_definitions d ON v.kpi_id = d.id "
|
||||||
|
"WHERE v.entity_id IS NOT NULL AND v.entity_id != d.entity_id"
|
||||||
|
)
|
||||||
|
if eid:
|
||||||
|
ent_sql += " AND v.entity_id = :eid"
|
||||||
|
ent_sql += " ORDER BY v.id LIMIT 200"
|
||||||
|
ent_rows = db.execute(text(ent_sql), {"eid": eid}).fetchall()
|
||||||
|
issues.append({
|
||||||
|
"rule": "entity_check", "level": "error",
|
||||||
|
"count": len(ent_rows),
|
||||||
|
"detail": [f"值#{r.id} {r.kpi_code} 实体{r.v_eid}≠定义实体{r.d_eid}" for r in ent_rows[:DETAIL_LIMIT]]
|
||||||
|
+ (["…等%d条" % len(ent_rows)] if len(ent_rows) > DETAIL_LIMIT else []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 规则6 KPI完整性:active KPI 无任何实际值 ──
|
||||||
|
comp_sql = (
|
||||||
|
"SELECT d.id, d.kpi_code, d.kpi_name FROM kpi_definitions d "
|
||||||
|
"WHERE d.status='active' AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id)"
|
||||||
|
)
|
||||||
|
if eid:
|
||||||
|
comp_sql += " AND d.entity_id = :eid"
|
||||||
|
comp_sql += " ORDER BY d.id LIMIT 300"
|
||||||
|
comp_rows = db.execute(text(comp_sql), {"eid": eid}).fetchall()
|
||||||
|
issues.append({
|
||||||
|
"rule": "kpi_completeness", "level": "warning",
|
||||||
|
"count": len(comp_rows),
|
||||||
|
"detail": [f"{r.kpi_code} {r.kpi_name}(无值)" for r in comp_rows[:DETAIL_LIMIT]]
|
||||||
|
+ (["…等%d个KPI" % len(comp_rows)] if len(comp_rows) > DETAIL_LIMIT else []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 规则7 勾稽验证:预算(active口径)年度合计 vs 年度目标 差异>20% ──
|
||||||
|
recon_sql = (
|
||||||
|
"SELECT d.kpi_code, d.kpi_name, d.target_yearly, "
|
||||||
|
"COALESCE(SUM(b.budget_value),0) AS monthly_sum, "
|
||||||
|
"ROUND((COALESCE(SUM(b.budget_value),0) - d.target_yearly) / d.target_yearly * 100, 1) AS diff_pct "
|
||||||
|
"FROM kpi_definitions d JOIN budget_plans b ON b.kpi_id = d.id "
|
||||||
|
"WHERE d.status='active' AND d.target_yearly > 0 AND b.status='active'"
|
||||||
|
)
|
||||||
|
if eid:
|
||||||
|
recon_sql += " AND d.entity_id = :eid"
|
||||||
|
recon_sql += " GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
|
||||||
|
recon_rows = db.execute(text(recon_sql), {"eid": eid}).fetchall()
|
||||||
|
issues.append({
|
||||||
|
"rule": "reconciliation", "level": "warning",
|
||||||
|
"count": len(recon_rows),
|
||||||
|
"detail": [f"{r.kpi_code} 预算合计{round(r.monthly_sum, 1)} vs 年度目标{r.target_yearly} 差异{r.diff_pct}%" for r in recon_rows[:DETAIL_LIMIT]]
|
||||||
|
+ (["…等%d个KPI" % len(recon_rows)] if len(recon_rows) > DETAIL_LIMIT else []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 评分:error 扣 min(15, count*3),warning 扣 min(10, count*1);规则4 manual 按 warning 附加扣 ──
|
||||||
|
total_deduct = 0
|
||||||
|
for item in issues:
|
||||||
|
ded = 0
|
||||||
|
if item["count"] > 0:
|
||||||
|
ded += min(15, item["count"] * 3) if item["level"] == "error" else min(10, item["count"] * 1)
|
||||||
|
# 规则4 附加:manual 待人工确认(warning 性质)
|
||||||
|
manual_cnt = item.get("manual_count") or 0
|
||||||
|
if item["rule"] == "virtual_pollution" and manual_cnt > 0:
|
||||||
|
ded += min(10, manual_cnt * 1)
|
||||||
|
item["deducted"] = ded
|
||||||
|
total_deduct += ded
|
||||||
|
score = max(0, 100 - total_deduct)
|
||||||
|
|
||||||
|
passed = [item["rule"] for item in issues if item["count"] == 0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"checked_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"entity_id": eid,
|
||||||
|
"score": score,
|
||||||
|
"total_deduct": total_deduct,
|
||||||
|
"total_rules": len(issues),
|
||||||
|
"issues": issues,
|
||||||
|
"passed": passed,
|
||||||
|
"rules_meta": RULES_META,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/governance-check")
|
||||||
|
def governance_check(
|
||||||
|
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""财务七规则检查(DAMA数据治理)— 只读幂等,不写库。
|
||||||
|
|
||||||
|
7条规则: unit_check/dup_alert/orphan_check/virtual_pollution/entity_check/kpi_completeness/reconciliation
|
||||||
|
评分: 满分100,error 扣 min(15, count*3),warning 扣 min(10, count*1),score=max(0, 100-总扣分)
|
||||||
|
实体筛选: entity_id 参数(0=全部)。
|
||||||
|
"""
|
||||||
|
return _run_governance_checks(db, entity_id or 0)
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
|
||||||
|
|
||||||
|
派生规则配置(budget_derivation_rules):apply-method 派生KPI时优先读规则,
|
||||||
|
percentage_of → base_kpi实际值×rate;incremental → 上月×(1+rate);无规则fallback默认比例。
|
||||||
|
"""
|
||||||
|
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_auth, require_role
|
||||||
|
from app.models import BudgetDerivationRule, KPIDefinition
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/cma/budget",
|
||||||
|
tags=["派生规则"],
|
||||||
|
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/derivation-rules")
|
||||||
|
def list_derivation_rules(
|
||||||
|
kpi_id: Optional[int] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""派生规则列表(按 entity_id 隔离)"""
|
||||||
|
query = db.query(BudgetDerivationRule).filter(BudgetDerivationRule.entity_id == entity_id)
|
||||||
|
if kpi_id:
|
||||||
|
query = query.filter(BudgetDerivationRule.kpi_id == kpi_id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(BudgetDerivationRule.status == status)
|
||||||
|
rows = query.order_by(BudgetDerivationRule.id.desc()).all()
|
||||||
|
|
||||||
|
all_kpi_ids = {r.kpi_id for r in rows} | {r.base_kpi_id for r in rows if r.base_kpi_id}
|
||||||
|
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(all_kpi_ids)).all()} if all_kpi_ids else {}
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
kpi = kpis.get(r.kpi_id)
|
||||||
|
base = kpis.get(r.base_kpi_id) if r.base_kpi_id else None
|
||||||
|
result.append({
|
||||||
|
"id": r.id,
|
||||||
|
"kpi_id": r.kpi_id,
|
||||||
|
"kpi_code": kpi.kpi_code if kpi else "",
|
||||||
|
"kpi_name": kpi.kpi_name if kpi else "",
|
||||||
|
"rule_type": r.rule_type,
|
||||||
|
"base_kpi_id": r.base_kpi_id,
|
||||||
|
"base_kpi_code": base.kpi_code if base else "",
|
||||||
|
"base_kpi_name": base.kpi_name if base else "",
|
||||||
|
"params": r.params,
|
||||||
|
"formula_text": r.formula_text,
|
||||||
|
"status": r.status,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
})
|
||||||
|
return {"data": result, "total": len(result)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/derivation-rules")
|
||||||
|
def create_derivation_rule(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""新建派生规则(同KPI同类型唯一)"""
|
||||||
|
kpi_id = data.get("kpi_id")
|
||||||
|
rule_type = data.get("rule_type")
|
||||||
|
if not kpi_id or rule_type not in ("incremental", "percentage_of", "formula"):
|
||||||
|
raise HTTPException(400, "需要 kpi_id 且 rule_type ∈ incremental/percentage_of/formula")
|
||||||
|
|
||||||
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.id == kpi_id,
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
|
||||||
|
existing = db.query(BudgetDerivationRule).filter(
|
||||||
|
BudgetDerivationRule.entity_id == entity_id,
|
||||||
|
BudgetDerivationRule.kpi_id == kpi_id,
|
||||||
|
BudgetDerivationRule.rule_type == rule_type,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(400, f"KPI({kpi_id})已存在 {rule_type} 规则")
|
||||||
|
|
||||||
|
row = BudgetDerivationRule(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
rule_type=rule_type,
|
||||||
|
base_kpi_id=data.get("base_kpi_id"),
|
||||||
|
params=data.get("params"),
|
||||||
|
formula_text=data.get("formula_text"),
|
||||||
|
status=data.get("status", "active"),
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return {"message": "派生规则已创建", "id": row.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/derivation-rules/{rule_id}")
|
||||||
|
def update_derivation_rule(
|
||||||
|
rule_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""更新派生规则"""
|
||||||
|
row = db.query(BudgetDerivationRule).filter(
|
||||||
|
BudgetDerivationRule.id == rule_id,
|
||||||
|
BudgetDerivationRule.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "规则不存在")
|
||||||
|
for field in ("rule_type", "base_kpi_id", "params", "formula_text", "status"):
|
||||||
|
if field in data:
|
||||||
|
setattr(row, field, data[field])
|
||||||
|
db.commit()
|
||||||
|
return {"message": "派生规则已更新", "id": row.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/derivation-rules/{rule_id}")
|
||||||
|
def delete_derivation_rule(
|
||||||
|
rule_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""删除派生规则"""
|
||||||
|
row = db.query(BudgetDerivationRule).filter(
|
||||||
|
BudgetDerivationRule.id == rule_id,
|
||||||
|
BudgetDerivationRule.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "规则不存在")
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "派生规则已删除"}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""KPI因果链建模 — 任务2
|
"""KPI因果链建模 — 任务2
|
||||||
KPI间因果关系网络 + 模拟推演
|
KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2)
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -11,6 +12,11 @@ from app.database import get_db
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
||||||
|
from app.services.causality_verification import (
|
||||||
|
ALL_STATUSES,
|
||||||
|
ALL_SOURCE_TYPES,
|
||||||
|
STATUS_PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("kpi-causality")
|
logger = logging.getLogger("kpi-causality")
|
||||||
|
|
||||||
@@ -30,8 +36,8 @@ def _to_dict(obj):
|
|||||||
|
|
||||||
@router.get("/full-network")
|
@router.get("/full-network")
|
||||||
def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b)"""
|
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)"""
|
||||||
edges = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.entity_id == entity_id).all()
|
edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
|
||||||
node_ids = set()
|
node_ids = set()
|
||||||
edge_list = []
|
edge_list = []
|
||||||
for e in edges:
|
for e in edges:
|
||||||
@@ -61,14 +67,19 @@ def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/kpi/{kpi_id}/network")
|
@router.get("/kpi/{kpi_id}/network")
|
||||||
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""获取KPI的因果网络(上游驱动 + 下游影响)"""
|
"""获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业"""
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
if kpi.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "KPI不存在") # 跨企业不暴露存在性
|
||||||
|
|
||||||
# 上游(指向当前KPI的因果)
|
# 上游(指向当前KPI的因果)
|
||||||
upstream = db.query(KPICausality).filter(KPICausality.target_kpi_id == kpi_id).all()
|
upstream = db.query(KPICausality).filter(
|
||||||
|
KPICausality.target_kpi_id == kpi_id,
|
||||||
|
KPICausality.entity_id == entity_id,
|
||||||
|
).all()
|
||||||
upstream_list = []
|
upstream_list = []
|
||||||
for c in upstream:
|
for c in upstream:
|
||||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||||
@@ -78,10 +89,16 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
|||||||
"kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
|
"kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
|
||||||
"strength": c.strength, "lag_months": c.lag_months,
|
"strength": c.strength, "lag_months": c.lag_months,
|
||||||
"direction": c.direction, "formula": c.formula,
|
"direction": c.direction, "formula": c.formula,
|
||||||
|
"source_type": c.source_type, "verify_status": c.verify_status,
|
||||||
|
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
|
||||||
|
"verified_by": c.verified_by,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 下游(当前KPI指向的因果)
|
# 下游(当前KPI指向的因果)
|
||||||
downstream = db.query(KPICausality).filter(KPICausality.source_kpi_id == kpi_id).all()
|
downstream = db.query(KPICausality).filter(
|
||||||
|
KPICausality.source_kpi_id == kpi_id,
|
||||||
|
KPICausality.entity_id == entity_id,
|
||||||
|
).all()
|
||||||
downstream_list = []
|
downstream_list = []
|
||||||
for c in downstream:
|
for c in downstream:
|
||||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||||
@@ -91,6 +108,9 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
|||||||
"kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
|
"kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
|
||||||
"strength": c.strength, "lag_months": c.lag_months,
|
"strength": c.strength, "lag_months": c.lag_months,
|
||||||
"direction": c.direction, "formula": c.formula,
|
"direction": c.direction, "formula": c.formula,
|
||||||
|
"source_type": c.source_type, "verify_status": c.verify_status,
|
||||||
|
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
|
||||||
|
"verified_by": c.verified_by,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,7 +121,7 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/simulate")
|
@router.post("/simulate")
|
||||||
def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""模拟推演: 修改一个KPI的值,预测对其他KPI的影响
|
"""模拟推演: 修改一个KPI的值,预测对其他KPI的影响
|
||||||
Body: { kpi_id: int, new_value: float, period: str }
|
Body: { kpi_id: int, new_value: float, period: str }
|
||||||
"""
|
"""
|
||||||
@@ -115,6 +135,8 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
|||||||
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
if not source_kpi:
|
if not source_kpi:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
if source_kpi.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
|
||||||
# 获取当前值
|
# 获取当前值
|
||||||
current_value = None
|
current_value = None
|
||||||
@@ -142,9 +164,10 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
|||||||
continue
|
continue
|
||||||
visited.add(current_kpi_id)
|
visited.add(current_kpi_id)
|
||||||
|
|
||||||
# 查找从current_kpi_id出发的下游因果链
|
# 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
|
||||||
downstream = db.query(KPICausality).filter(
|
downstream = db.query(KPICausality).filter(
|
||||||
KPICausality.source_kpi_id == current_kpi_id
|
KPICausality.source_kpi_id == current_kpi_id,
|
||||||
|
KPICausality.entity_id == entity_id,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
for edge in downstream:
|
for edge in downstream:
|
||||||
@@ -212,15 +235,18 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
|||||||
def list_causalities(
|
def list_causalities(
|
||||||
source_kpi_id: Optional[int] = None,
|
source_kpi_id: Optional[int] = None,
|
||||||
target_kpi_id: Optional[int] = None,
|
target_kpi_id: Optional[int] = None,
|
||||||
|
verify_status: Optional[str] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b)"""
|
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选)"""
|
||||||
query = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.entity_id == entity_id)
|
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
|
||||||
if source_kpi_id:
|
if source_kpi_id:
|
||||||
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
|
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
|
||||||
if target_kpi_id:
|
if target_kpi_id:
|
||||||
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
|
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
|
||||||
|
if verify_status:
|
||||||
|
query = query.filter(KPICausality.verify_status == verify_status)
|
||||||
items = query.order_by(KPICausality.id).all()
|
items = query.order_by(KPICausality.id).all()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
@@ -236,11 +262,51 @@ def list_causalities(
|
|||||||
return {"data": result, "total": len(result)}
|
return {"data": result, "total": len(result)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify-status")
|
||||||
|
def get_verify_status(
|
||||||
|
verify_status: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""验证状态总览 — 按状态统计 + 链列表(2026-08-27 三层验证机制)
|
||||||
|
|
||||||
|
可选 ?verify_status=pending/data_verified/human_verified/disputed 筛选
|
||||||
|
"""
|
||||||
|
if verify_status and verify_status not in ALL_STATUSES:
|
||||||
|
raise HTTPException(400, f"verify_status 必须为 {'/'.join(ALL_STATUSES)}")
|
||||||
|
|
||||||
|
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
|
||||||
|
if verify_status:
|
||||||
|
query = query.filter(KPICausality.verify_status == verify_status)
|
||||||
|
items = query.order_by(KPICausality.id).all()
|
||||||
|
|
||||||
|
by_status = {s: 0 for s in ALL_STATUSES}
|
||||||
|
data = []
|
||||||
|
for c in items:
|
||||||
|
by_status[c.verify_status] = by_status.get(c.verify_status, 0) + 1
|
||||||
|
d = _to_dict(c)
|
||||||
|
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||||
|
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||||
|
d["source_kpi_code"] = src.kpi_code if src else None
|
||||||
|
d["source_kpi_name"] = src.kpi_name if src else None
|
||||||
|
d["target_kpi_code"] = tgt.kpi_code if tgt else None
|
||||||
|
d["target_kpi_name"] = tgt.kpi_name if tgt else None
|
||||||
|
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
|
||||||
|
data.append(d)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {"total": len(items), "by_status": by_status},
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{causality_id}")
|
@router.get("/{causality_id}")
|
||||||
def get_causality(causality_id: int, db: Session = Depends(get_db)):
|
def get_causality(causality_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||||
if not c:
|
if not c:
|
||||||
raise HTTPException(404, "因果链不存在")
|
raise HTTPException(404, "因果链不存在")
|
||||||
|
if c.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||||
d = _to_dict(c)
|
d = _to_dict(c)
|
||||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||||
@@ -251,7 +317,7 @@ def get_causality(causality_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||||
"""创建因果链"""
|
"""创建因果链(source_type标记来源, 2026-08-27)"""
|
||||||
source_id = data.get("source_kpi_id")
|
source_id = data.get("source_kpi_id")
|
||||||
target_id = data.get("target_kpi_id")
|
target_id = data.get("target_kpi_id")
|
||||||
if not source_id or not target_id:
|
if not source_id or not target_id:
|
||||||
@@ -262,6 +328,8 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
|
|||||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
|
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
|
||||||
if not src or not tgt:
|
if not src or not tgt:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
if src.entity_id != tgt.entity_id:
|
||||||
|
raise HTTPException(400, "源KPI和目标KPI必须属于同一企业")
|
||||||
|
|
||||||
existing = db.query(KPICausality).filter(
|
existing = db.query(KPICausality).filter(
|
||||||
KPICausality.source_kpi_id == source_id,
|
KPICausality.source_kpi_id == source_id,
|
||||||
@@ -270,40 +338,91 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(400, f"因果链已存在: {src.kpi_code}→{tgt.kpi_code}")
|
raise HTTPException(400, f"因果链已存在: {src.kpi_code}→{tgt.kpi_code}")
|
||||||
|
|
||||||
|
source_type = data.get("source_type", "manual")
|
||||||
|
if source_type not in ALL_SOURCE_TYPES:
|
||||||
|
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
|
||||||
|
|
||||||
c = KPICausality(
|
c = KPICausality(
|
||||||
|
entity_id=src.entity_id,
|
||||||
source_kpi_id=source_id,
|
source_kpi_id=source_id,
|
||||||
target_kpi_id=target_id,
|
target_kpi_id=target_id,
|
||||||
strength=data.get("strength", 0.5),
|
strength=data.get("strength", 0.5),
|
||||||
lag_months=data.get("lag_months", 1),
|
lag_months=data.get("lag_months", 1),
|
||||||
formula=data.get("formula"),
|
formula=data.get("formula"),
|
||||||
direction=data.get("direction", "positive"),
|
direction=data.get("direction", "positive"),
|
||||||
|
source_type=source_type,
|
||||||
|
verify_status=STATUS_PENDING,
|
||||||
)
|
)
|
||||||
db.add(c)
|
db.add(c)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(c)
|
db.refresh(c)
|
||||||
db.add(OperationLog(action="create", target_type="kpi_causality",
|
db.add(OperationLog(action="create", target_type="kpi_causality",
|
||||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code}"))
|
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})"))
|
||||||
db.commit()
|
db.commit()
|
||||||
return _to_dict(c)
|
return _to_dict(c)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{causality_id}")
|
@router.put("/{causality_id}")
|
||||||
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||||
if not c:
|
if not c:
|
||||||
raise HTTPException(404, "因果链不存在")
|
raise HTTPException(404, "因果链不存在")
|
||||||
for field in ("strength", "lag_months", "formula", "direction"):
|
if c.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||||
|
for field in ("strength", "lag_months", "formula", "direction", "source_type"):
|
||||||
if field in data:
|
if field in data:
|
||||||
|
if field == "source_type" and data[field] not in ALL_SOURCE_TYPES:
|
||||||
|
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
|
||||||
setattr(c, field, data[field])
|
setattr(c, field, data[field])
|
||||||
|
# 修改链定义后,验证状态回到待检(定义变了旧结论失效)
|
||||||
|
if any(f in data for f in ("strength", "lag_months", "formula", "direction")):
|
||||||
|
c.verify_status = STATUS_PENDING
|
||||||
|
c.verified_at = None
|
||||||
|
c.verified_by = None
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(c)
|
db.refresh(c)
|
||||||
return _to_dict(c)
|
return _to_dict(c)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{causality_id}/verify")
|
||||||
|
def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
|
"""人工确认(战略回顾会核对打标)— 2026-08-27 三层验证机制
|
||||||
|
|
||||||
|
Body: { verify_status: "human_verified"|"disputed", verified_by?: "任富海" }
|
||||||
|
默认打标 human_verified(人工最终确认)。
|
||||||
|
"""
|
||||||
|
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(404, "因果链不存在")
|
||||||
|
if c.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||||
|
|
||||||
|
target_status = data.get("verify_status", "human_verified")
|
||||||
|
if target_status not in ("human_verified", "disputed"):
|
||||||
|
raise HTTPException(400, "verify_status 必须为 human_verified 或 disputed")
|
||||||
|
verified_by = data.get("verified_by") or user.name or user.username
|
||||||
|
|
||||||
|
c.verify_status = target_status
|
||||||
|
c.verified_at = datetime.now()
|
||||||
|
c.verified_by = str(verified_by)[:50]
|
||||||
|
db.add(OperationLog(action="verify", target_type="kpi_causality",
|
||||||
|
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(c)
|
||||||
|
d = _to_dict(c)
|
||||||
|
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{causality_id}")
|
@router.delete("/{causality_id}")
|
||||||
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||||
if c:
|
if c:
|
||||||
|
if c.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||||
db.delete(c)
|
db.delete(c)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "已删除"}
|
return {"message": "已删除"}
|
||||||
|
|||||||
+147
-3
@@ -9,7 +9,7 @@ import json
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
||||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy
|
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
|
||||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
|
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
|
||||||
@@ -107,7 +107,8 @@ def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depen
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO',
|
REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO',
|
||||||
'P_QUALITY_RATE', 'F_COST_RATIO', 'F_INTEREST_COVER', 'F_QUICK_RATIO']
|
'F_COST_RATIO', 'F_AR_DAYS', 'F_REBATE_RATE',
|
||||||
|
'F_FACTORY_REBATE_RATE', 'F_COST_CONTROL_RATE', 'F_INV_DAYS']
|
||||||
|
|
||||||
|
|
||||||
def _calc_five_tier_score(current_value, target_value, is_reverse=False):
|
def _calc_five_tier_score(current_value, target_value, is_reverse=False):
|
||||||
@@ -474,6 +475,83 @@ def get_kpi_causality_chain(
|
|||||||
# 动态路由(必须在静态路由之后)
|
# 动态路由(必须在静态路由之后)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
@router.post("/{kpi_id}/values")
|
||||||
|
def create_kpi_value(
|
||||||
|
kpi_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""录入KPI单值(人工数据录入,用于客户/流程/学习层台账数据)
|
||||||
|
Body: {period: '2026-08', actual_value: 85}
|
||||||
|
"""
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
if kpi.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
|
||||||
|
period = data.get("period")
|
||||||
|
actual_value = data.get("actual_value")
|
||||||
|
if not period or actual_value is None:
|
||||||
|
raise HTTPException(400, "缺少必要参数: period, actual_value")
|
||||||
|
|
||||||
|
# 同一期间重复录入 → 更新
|
||||||
|
existing = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi_id,
|
||||||
|
KPIValue.period == period,
|
||||||
|
KPIValue.source_type == "manual",
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.actual_value = float(actual_value)
|
||||||
|
existing.data_status = "verified"
|
||||||
|
existing.remark = f"人工录入(更新) by {current_user.username}"
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已更新", "id": existing.id}
|
||||||
|
|
||||||
|
new_val = KPIValue(
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
period=period,
|
||||||
|
actual_value=float(actual_value),
|
||||||
|
source_type="manual",
|
||||||
|
source_batch=f"manual-{current_user.username}-{datetime.now().strftime('%Y%m%d')}",
|
||||||
|
data_status="verified",
|
||||||
|
remark=f"人工录入 by {current_user.username}",
|
||||||
|
)
|
||||||
|
db.add(new_val)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已录入", "id": new_val.id, "period": period, "actual_value": float(actual_value)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{kpi_id}/values")
|
||||||
|
def list_kpi_values(
|
||||||
|
kpi_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""查询KPI实际值列表(含source_type标记,供归集标签页展示)"""
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
if kpi.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all()
|
||||||
|
return {
|
||||||
|
"data": [{
|
||||||
|
"id": v.id,
|
||||||
|
"period": v.period,
|
||||||
|
"actual_value": v.actual_value,
|
||||||
|
"source_type": v.source_type or "manual",
|
||||||
|
"source_batch": v.source_batch or "",
|
||||||
|
"data_status": v.data_status,
|
||||||
|
"remark": v.remark or "",
|
||||||
|
} for v in vals]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{kpi_id}")
|
@router.get("/{kpi_id}")
|
||||||
def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
@@ -482,7 +560,56 @@ def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends
|
|||||||
# 账套隔离: 禁止跨企业读取 (2026-08-23 P1a)
|
# 账套隔离: 禁止跨企业读取 (2026-08-23 P1a)
|
||||||
if kpi.entity_id != entity_id:
|
if kpi.entity_id != entity_id:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
return kpi_to_dict(kpi)
|
result = kpi_to_dict(kpi)
|
||||||
|
# 附带历史数据(前端KPI详情"历史数据"Tab依赖)
|
||||||
|
# 2026-08-26修复: 原实现只返回定义信息,values缺失导致历史数据Tab空白
|
||||||
|
# 2026-08-26增强: values对齐KPI元数据(目标值/偏差/红黄绿判定/单位)
|
||||||
|
vals = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi_id,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.period.asc()).all()
|
||||||
|
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
|
||||||
|
target = kpi.target_value
|
||||||
|
result["values"] = []
|
||||||
|
for v in vals:
|
||||||
|
# 判定红黄绿(对齐工作台语义:正向≥0.9绿/≥0.7黄/否则红;反向≤目标绿/≤1.1倍黄/否则红)
|
||||||
|
level = "info"
|
||||||
|
score = None
|
||||||
|
if target and v.actual_value is not None:
|
||||||
|
if is_reverse:
|
||||||
|
if v.actual_value <= target:
|
||||||
|
level = "green"
|
||||||
|
elif v.actual_value <= target * 1.1:
|
||||||
|
level = "yellow"
|
||||||
|
else:
|
||||||
|
level = "red"
|
||||||
|
else:
|
||||||
|
ratio = v.actual_value / target
|
||||||
|
if ratio >= 0.9:
|
||||||
|
level = "green"
|
||||||
|
elif ratio >= 0.7:
|
||||||
|
level = "yellow"
|
||||||
|
else:
|
||||||
|
level = "red"
|
||||||
|
score, _ = _calc_five_tier_score(v.actual_value, target, is_reverse=is_reverse)
|
||||||
|
# 偏差率(相对目标)
|
||||||
|
deviation = None
|
||||||
|
if target and target != 0 and v.actual_value is not None:
|
||||||
|
deviation = round((v.actual_value - target) / target * 100, 1)
|
||||||
|
result["values"].append({
|
||||||
|
"id": v.id,
|
||||||
|
"period": v.period,
|
||||||
|
"actual_value": v.actual_value,
|
||||||
|
"target_value": target,
|
||||||
|
"unit": kpi.unit or "",
|
||||||
|
"deviation_pct": deviation,
|
||||||
|
"level": level,
|
||||||
|
"score": score,
|
||||||
|
"source_type": v.source_type,
|
||||||
|
"data_status": v.data_status,
|
||||||
|
"source_batch": v.source_batch,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] = None, is_update: bool = False):
|
def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] = None, is_update: bool = False):
|
||||||
@@ -608,6 +735,19 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
|
|||||||
return kpi_to_dict(kpi)
|
return kpi_to_dict(kpi)
|
||||||
|
|
||||||
|
|
||||||
|
# 可更新字段白名单(安全修复 2026-08-31):update_kpi 只允许更新业务属性。
|
||||||
|
# status / important_flag 等敏感字段及主键/归属字段(id/kpi_code/entity_id/map_id/created_*)一律忽略,
|
||||||
|
# 防越权修改(如借 update 篡改重要标记/上下架状态)。
|
||||||
|
UPDATE_KPI_WHITELIST = {
|
||||||
|
"kpi_name", "dimension", "objective", "formula", "formula_desc",
|
||||||
|
"data_source_type", "data_source_config", "data_source", "data_owner",
|
||||||
|
"frequency", "unit", "target_value", "target_monthly", "target_quarterly",
|
||||||
|
"target_yearly", "target_calc_type", "threshold_green", "threshold_yellow",
|
||||||
|
"threshold_red", "category", "data_level", "data_category",
|
||||||
|
"responsible_dept", "responsible_user", "kpi_level", "bot_source", "epic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{kpi_id}")
|
@router.put("/{kpi_id}")
|
||||||
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
@@ -622,6 +762,10 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
|
|||||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||||
data.pop("entity_id", None) # 禁止通过update改企业归属
|
data.pop("entity_id", None) # 禁止通过update改企业归属
|
||||||
data = apply_calc_type_inference(data, infer_missing=False)
|
data = apply_calc_type_inference(data, infer_missing=False)
|
||||||
|
# 白名单过滤(安全修复 2026-08-31):白名单外字段(status/important_flag/主键等)忽略不修改
|
||||||
|
for k in list(data.keys()):
|
||||||
|
if k not in UPDATE_KPI_WHITELIST:
|
||||||
|
data.pop(k, None)
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if hasattr(kpi, k) and v is not None:
|
if hasattr(kpi, k) and v is not None:
|
||||||
setattr(kpi, k, v)
|
setattr(kpi, k, v)
|
||||||
|
|||||||
+380
-7
@@ -2,10 +2,11 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import StrategicMap, OperationLog, MapObjective
|
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
||||||
import json
|
import json
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
||||||
@@ -115,13 +116,20 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id
|
|||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
old_status = m.status
|
old_status = m.status
|
||||||
|
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
|
||||||
|
# status 允许但仅限合法值域(draft/published), 防止任意发布
|
||||||
|
ALLOWED_MAP_FIELDS = {"title", "version", "dimensions", "canvas_data", "description", "status"}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if hasattr(m, k) and v is not None:
|
if k in ALLOWED_MAP_FIELDS and hasattr(m, k) and v is not None:
|
||||||
|
if k == "status" and v not in ("draft", "published"):
|
||||||
|
continue
|
||||||
setattr(m, k, v)
|
setattr(m, k, v)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# 同步目标到map_objectives表
|
# 同步目标到map_objectives表
|
||||||
_sync_map_objectives(m, db)
|
_sync_map_objectives(m, db)
|
||||||
|
# KR完整修复(2026-08-27): 同步 objectives+krs 表
|
||||||
|
_sync_objectives_krs(m, db)
|
||||||
|
|
||||||
# ├─ 版本管理: draft → published 时自动创建快照
|
# ├─ 版本管理: draft → published 时自动创建快照
|
||||||
if old_status == "draft" and m.status == "published":
|
if old_status == "draft" and m.status == "published":
|
||||||
@@ -307,7 +315,7 @@ def _sync_map_objectives(m, db):
|
|||||||
for i, obj in enumerate(dim.get("objectives", [])):
|
for i, obj in enumerate(dim.get("objectives", [])):
|
||||||
mo = MapObjective(
|
mo = MapObjective(
|
||||||
map_id=m.id,
|
map_id=m.id,
|
||||||
entity_id=getattr(m, "entity_id", 1) or 1, # 账套隔离: 继承地图企业
|
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
|
||||||
dimension_key=dim.get("key", ""),
|
dimension_key=dim.get("key", ""),
|
||||||
name=obj.get("name", ""),
|
name=obj.get("name", ""),
|
||||||
description=obj.get("description", ""),
|
description=obj.get("description", ""),
|
||||||
@@ -320,7 +328,11 @@ def _sync_map_objectives(m, db):
|
|||||||
|
|
||||||
|
|
||||||
def _merge_map_objectives(m, db):
|
def _merge_map_objectives(m, db):
|
||||||
"""读取时:将map_objectives表的数据合并进dimensions JSON"""
|
"""读取时:将map_objectives表的数据合并进dimensions JSON
|
||||||
|
|
||||||
|
注意(安全审查 2026-08-31):本函数是【只读合并】——只读 map_objectives 并合并到
|
||||||
|
dimensions JSON,不写库、无事务提交需求,缺 commit 不影响。勿误判为写操作。
|
||||||
|
"""
|
||||||
objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all()
|
objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all()
|
||||||
if not objs:
|
if not objs:
|
||||||
return
|
return
|
||||||
@@ -351,9 +363,245 @@ def _merge_map_objectives(m, db):
|
|||||||
m.dimensions = dims
|
m.dimensions = dims
|
||||||
|
|
||||||
|
|
||||||
|
# ── KR完整修复(2026-08-27): 地图节点 → objectives + krs 表同步 ──
|
||||||
|
|
||||||
|
KR_OPERATORS = (">=", "<=", ">", "<", "=")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_kr_operator_target(raw_target, operator=None):
|
||||||
|
"""解析KR目标值字符串:
|
||||||
|
旧格式 '≥20%'/'<=18' → ('>=', 20.0)
|
||||||
|
新格式 operator='>=' + target=20 → ('>=', 20.0)
|
||||||
|
"""
|
||||||
|
if operator:
|
||||||
|
op = str(operator).strip()
|
||||||
|
if op == "≥":
|
||||||
|
op = ">="
|
||||||
|
elif op == "≤":
|
||||||
|
op = "<="
|
||||||
|
if op not in KR_OPERATORS:
|
||||||
|
op = ">="
|
||||||
|
else:
|
||||||
|
op = ">="
|
||||||
|
if raw_target is None:
|
||||||
|
return op, None
|
||||||
|
if isinstance(raw_target, (int, float)):
|
||||||
|
return op, float(raw_target)
|
||||||
|
s = str(raw_target).strip()
|
||||||
|
if s == "":
|
||||||
|
return op, None
|
||||||
|
# 前缀符号
|
||||||
|
for cand in KR_OPERATORS:
|
||||||
|
if s.startswith(cand):
|
||||||
|
op = cand
|
||||||
|
s = s[len(cand):]
|
||||||
|
break
|
||||||
|
if s.startswith("≥"):
|
||||||
|
op = ">="
|
||||||
|
s = s[1:]
|
||||||
|
elif s.startswith("≤"):
|
||||||
|
op = "<="
|
||||||
|
s = s[1:]
|
||||||
|
# 去掉单位(%、天、小时等非数字后缀)
|
||||||
|
num = s
|
||||||
|
while num and not (num[0].isdigit() or num[0] in ".+-"):
|
||||||
|
num = num[1:]
|
||||||
|
import re
|
||||||
|
m = re.match(r"[-+]?\d*\.?\d+", num)
|
||||||
|
if not m:
|
||||||
|
return op, None
|
||||||
|
return op, float(m.group(0))
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_objectives_krs(m, db):
|
||||||
|
"""保存地图时:将dimensions JSON中带krs的目标节点同步到objectives表+krs表。
|
||||||
|
- 每个带krs的目标节点 → 确保objectives表有记录(按entity_id+name匹配,无则创建)
|
||||||
|
- krs数组 → krs表 upsert(按title匹配;删除本目标下JSON中不存在的krs)
|
||||||
|
- 回写 objective_id / kr_id 到节点JSON,方便前端下次直接使用
|
||||||
|
"""
|
||||||
|
dims = m.dimensions
|
||||||
|
if isinstance(dims, str):
|
||||||
|
try:
|
||||||
|
dims = json.loads(dims)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
dims = dims or []
|
||||||
|
entity_id = m.entity_id if m.entity_id is not None else 1
|
||||||
|
for dim in dims:
|
||||||
|
dim_key = dim.get("key", "")
|
||||||
|
for obj in dim.get("objectives", []):
|
||||||
|
krs = obj.get("krs") or []
|
||||||
|
if not krs:
|
||||||
|
continue
|
||||||
|
name = (obj.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
# 1. 确保 Objective 记录存在
|
||||||
|
objective = db.query(Objective).filter(
|
||||||
|
Objective.entity_id == entity_id,
|
||||||
|
Objective.title == name,
|
||||||
|
).first()
|
||||||
|
if not objective:
|
||||||
|
objective = Objective(
|
||||||
|
entity_id=entity_id,
|
||||||
|
title=name,
|
||||||
|
description=obj.get("description") or "",
|
||||||
|
dimension=dim_key,
|
||||||
|
quarter=_map_quarter(m),
|
||||||
|
owner=obj.get("owner") or "",
|
||||||
|
status="active",
|
||||||
|
progress=0,
|
||||||
|
)
|
||||||
|
db.add(objective)
|
||||||
|
db.flush() # 拿到 id
|
||||||
|
obj["_objective_id"] = objective.id
|
||||||
|
# 2. upsert krs 表
|
||||||
|
existing_krs = {kr.title: kr for kr in db.query(KR).filter(KR.objective_id == objective.id).all()}
|
||||||
|
keep_ids = set()
|
||||||
|
for i, kr_item in enumerate(krs):
|
||||||
|
if not isinstance(kr_item, dict):
|
||||||
|
continue
|
||||||
|
title = (kr_item.get("title") or kr_item.get("name") or "").strip()
|
||||||
|
if not title:
|
||||||
|
# 跳过空KR(前端允许空白行,不入库)
|
||||||
|
continue
|
||||||
|
op, tgt = _parse_kr_operator_target(
|
||||||
|
kr_item.get("target_value"), kr_item.get("operator"))
|
||||||
|
kpi_id = kr_item.get("metric_kpi_id")
|
||||||
|
if not kpi_id and kr_item.get("kpi_code"):
|
||||||
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.kpi_code == kr_item["kpi_code"],
|
||||||
|
).first()
|
||||||
|
kpi_id = kpi.id if kpi else None
|
||||||
|
# 关联KPI时自动继承方向(KPI阈值如 '>=800')
|
||||||
|
if kpi_id and not kr_item.get("operator"):
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
|
if kpi and kpi.threshold_green:
|
||||||
|
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
|
||||||
|
op, tgt = op2, (tgt2 if tgt is None else tgt)
|
||||||
|
kr = existing_krs.get(title)
|
||||||
|
if kr is None:
|
||||||
|
kr = KR(
|
||||||
|
entity_id=entity_id,
|
||||||
|
objective_id=objective.id,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
db.add(kr)
|
||||||
|
kr.metric_kpi_id = kpi_id
|
||||||
|
kr.operator = op
|
||||||
|
if kr_item.get("tolerance") is not None:
|
||||||
|
kr.tolerance = kr_item.get("tolerance")
|
||||||
|
if kr_item.get("weight") is not None:
|
||||||
|
kr.weight = kr_item.get("weight")
|
||||||
|
if tgt is not None:
|
||||||
|
kr.target_value = tgt
|
||||||
|
if kr_item.get("current_value") is not None:
|
||||||
|
kr.current_value = kr_item.get("current_value")
|
||||||
|
if kr_item.get("due_date"):
|
||||||
|
kr.due_date = _parse_kr_date(kr_item.get("due_date"))
|
||||||
|
kr.sort_order = i + 1
|
||||||
|
keep_ids.add(kr.id)
|
||||||
|
kr_item["_kr_id"] = kr.id
|
||||||
|
# 3. 删除JSON中不存在的krs(空标题的除外——它从未入库)
|
||||||
|
for title, kr in existing_krs.items():
|
||||||
|
if kr.id not in keep_ids:
|
||||||
|
db.delete(kr)
|
||||||
|
db.commit()
|
||||||
|
# 回写dimensions(含_objective_id/_kr_id)
|
||||||
|
m.dimensions = dims
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _map_quarter(m) -> str:
|
||||||
|
"""从地图标题提取季度标识(如 2026Q3),无则空字符串"""
|
||||||
|
title = m.title or ""
|
||||||
|
import re
|
||||||
|
m_q = re.search(r"(20\d{2})\s*[Qq](\d)", title)
|
||||||
|
if m_q:
|
||||||
|
return f"{m_q.group(1)}Q{m_q.group(2)}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_kr_date(v):
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return v.date()
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── 战略回顾会 聚合接口 ──────────────────────
|
# ── 战略回顾会 聚合接口 ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{map_id}/krs")
|
||||||
|
def get_map_krs(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
|
"""战略回顾会 KR进度: 从krs表读取地图各目标的KR (KR修复 2026-08-27)
|
||||||
|
|
||||||
|
地图dimensions JSON中每个带krs的目标节点已同步到objectives+krs表,
|
||||||
|
此处按 节点._objective_id(或 entity+name 回查)取KR。
|
||||||
|
"""
|
||||||
|
from app.models import KPIDefinition, KR, Objective
|
||||||
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
|
dims = m.dimensions
|
||||||
|
if isinstance(dims, str):
|
||||||
|
try:
|
||||||
|
dims = json.loads(dims)
|
||||||
|
except Exception:
|
||||||
|
dims = []
|
||||||
|
dims = dims or []
|
||||||
|
|
||||||
|
obj_ids = []
|
||||||
|
for dim in dims:
|
||||||
|
for obj in dim.get("objectives", []):
|
||||||
|
oid = obj.get("_objective_id")
|
||||||
|
if oid:
|
||||||
|
obj_ids.append(int(oid))
|
||||||
|
continue
|
||||||
|
# 兼容无回写字段的旧节点: 按 entity+name 回查
|
||||||
|
name = (obj.get("name") or "").strip()
|
||||||
|
if name:
|
||||||
|
o = db.query(Objective).filter(
|
||||||
|
Objective.entity_id == entity_id,
|
||||||
|
Objective.title == name,
|
||||||
|
).first()
|
||||||
|
if o:
|
||||||
|
obj_ids.append(o.id)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
if obj_ids:
|
||||||
|
krs = db.query(KR).filter(
|
||||||
|
KR.entity_id == entity_id,
|
||||||
|
KR.objective_id.in_(obj_ids),
|
||||||
|
).order_by(KR.objective_id, KR.sort_order, KR.id).all()
|
||||||
|
for kr in krs:
|
||||||
|
mkpi = None
|
||||||
|
if kr.metric_kpi_id:
|
||||||
|
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
|
||||||
|
result.append({
|
||||||
|
"id": kr.id,
|
||||||
|
"objective_id": kr.objective_id,
|
||||||
|
"title": kr.title,
|
||||||
|
"metric_kpi_id": kr.metric_kpi_id,
|
||||||
|
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||||
|
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||||
|
"operator": _parse_kr_operator_target(kr.operator)[0] if hasattr(kr, "operator") and kr.operator else ">=",
|
||||||
|
"weight": float(kr.weight) if kr.weight is not None else None,
|
||||||
|
"target_value": float(kr.target_value) if kr.target_value is not None else None,
|
||||||
|
"current_value": float(kr.current_value) if kr.current_value is not None else None,
|
||||||
|
"progress": kr.progress or 0,
|
||||||
|
"status": kr.status,
|
||||||
|
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||||||
|
})
|
||||||
|
return {"data": result}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{map_id}/review")
|
@router.get("/{map_id}/review")
|
||||||
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""战略回顾会:返回目标状态、KPI值、改善行动
|
"""战略回顾会:返回目标状态、KPI值、改善行动
|
||||||
@@ -378,9 +626,10 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
for code in obj.get("kpis", []):
|
for code in obj.get("kpis", []):
|
||||||
all_kpi_codes.add(code)
|
all_kpi_codes.add(code)
|
||||||
|
|
||||||
# 查询KPI定义
|
# 查询KPI定义(账套隔离: 同编码跨企业会混入, OpenCode审查#10)
|
||||||
kpi_defs = db.query(KPIDefinition).filter(
|
kpi_defs = db.query(KPIDefinition).filter(
|
||||||
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False
|
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False,
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
).all() if all_kpi_codes else []
|
).all() if all_kpi_codes else []
|
||||||
kpi_map = {k.kpi_code: k for k in kpi_defs}
|
kpi_map = {k.kpi_code: k for k in kpi_defs}
|
||||||
|
|
||||||
@@ -446,11 +695,21 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
continue
|
continue
|
||||||
lv = latest_values.get(kpi_def.id, {})
|
lv = latest_values.get(kpi_def.id, {})
|
||||||
actual = lv.get("actual_value")
|
actual = lv.get("actual_value")
|
||||||
target = kpi_def.target_value
|
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
|
||||||
|
target = kpi_def.target_monthly or kpi_def.target_value
|
||||||
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
||||||
|
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
|
||||||
level = "gray"
|
level = "gray"
|
||||||
if actual is not None and target:
|
if actual is not None and target:
|
||||||
|
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
|
||||||
|
lower_better = any(k in name for k in
|
||||||
|
("费用率", "成本率", "损耗", "逾期", "投诉", "流失", "负债率", "返修", "缺勤"))
|
||||||
|
if lower_better:
|
||||||
|
ratio = target / actual if actual else 0 # 实际越低越好: 目标/实际
|
||||||
|
else:
|
||||||
ratio = actual / target
|
ratio = actual / target
|
||||||
|
if ratio < 0:
|
||||||
|
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
|
||||||
if ratio >= 0.9:
|
if ratio >= 0.9:
|
||||||
level = "green"
|
level = "green"
|
||||||
elif ratio >= 0.6:
|
elif ratio >= 0.6:
|
||||||
@@ -481,6 +740,13 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
"kpis": obj_kpis,
|
"kpis": obj_kpis,
|
||||||
"level": worst_level,
|
"level": worst_level,
|
||||||
"has_data": len(obj_kpis) > 0,
|
"has_data": len(obj_kpis) > 0,
|
||||||
|
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
|
||||||
|
"action_plan_summary": {
|
||||||
|
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
|
||||||
|
"pending": len([p for p in action_plans_data
|
||||||
|
if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]
|
||||||
|
and p["status"] not in ("done", "completed")]),
|
||||||
|
} if obj_kpis else {"total": 0, "pending": 0},
|
||||||
}
|
}
|
||||||
objectives.append(obj_item)
|
objectives.append(obj_item)
|
||||||
|
|
||||||
@@ -523,3 +789,110 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
"action_plans": action_plans_data,
|
"action_plans": action_plans_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 复盘记录持久化(大PDCA Act闭环 2026-08-27)────────────────
|
||||||
|
def review_to_dict(r: ReviewRecord) -> dict:
|
||||||
|
return {
|
||||||
|
"id": r.id,
|
||||||
|
"entity_id": r.entity_id,
|
||||||
|
"map_id": r.map_id,
|
||||||
|
"review_type": r.review_type,
|
||||||
|
"review_date": r.review_date.isoformat() if r.review_date else None,
|
||||||
|
"summary": r.summary,
|
||||||
|
"adjustments": r.adjustments,
|
||||||
|
"next_priorities": r.next_priorities,
|
||||||
|
"created_by": r.created_by,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_review_date(raw) -> Optional[date]:
|
||||||
|
"""兼容 'YYYY-MM-DD' / datetime / date 输入"""
|
||||||
|
if raw is None:
|
||||||
|
return datetime.now().date()
|
||||||
|
if isinstance(raw, datetime):
|
||||||
|
return raw.date()
|
||||||
|
if isinstance(raw, date):
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(raw)[:10], "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return datetime.now().date()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
|
||||||
|
m = db.query(StrategicMap).filter(
|
||||||
|
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id
|
||||||
|
).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{map_id}/review-records")
|
||||||
|
def create_review_record(
|
||||||
|
map_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""保存复盘结论(战略回顾会/运营复盘会结束时落库)"""
|
||||||
|
_get_map_or_404(db, map_id, entity_id)
|
||||||
|
summary = (data.get("summary") or "").strip()
|
||||||
|
if not summary:
|
||||||
|
raise HTTPException(400, "复盘结论(summary)不能为空")
|
||||||
|
|
||||||
|
r = ReviewRecord(
|
||||||
|
entity_id=entity_id, # 多租户隔离: 强制token企业
|
||||||
|
map_id=map_id,
|
||||||
|
review_type=data.get("review_type", "quarterly"),
|
||||||
|
review_date=_parse_review_date(data.get("review_date")),
|
||||||
|
summary=summary,
|
||||||
|
adjustments=data.get("adjustments") or [],
|
||||||
|
next_priorities=data.get("next_priorities") or [],
|
||||||
|
created_by=current_user.id if current_user else None,
|
||||||
|
)
|
||||||
|
db.add(r)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(r)
|
||||||
|
return review_to_dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{map_id}/review-records")
|
||||||
|
def list_review_records(
|
||||||
|
map_id: int,
|
||||||
|
review_type: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""历史复盘记录列表(按复盘日期倒序)"""
|
||||||
|
_get_map_or_404(db, map_id, entity_id)
|
||||||
|
q = db.query(ReviewRecord).filter(
|
||||||
|
ReviewRecord.map_id == map_id,
|
||||||
|
ReviewRecord.entity_id == entity_id,
|
||||||
|
)
|
||||||
|
if review_type:
|
||||||
|
q = q.filter(ReviewRecord.review_type == review_type)
|
||||||
|
records = q.order_by(ReviewRecord.review_date.desc(), ReviewRecord.id.desc()).all()
|
||||||
|
return {"data": [review_to_dict(r) for r in records]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{map_id}/review-records/{record_id}")
|
||||||
|
def get_review_record(
|
||||||
|
map_id: int,
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""单条复盘记录详情"""
|
||||||
|
_get_map_or_404(db, map_id, entity_id)
|
||||||
|
r = db.query(ReviewRecord).filter(
|
||||||
|
ReviewRecord.id == record_id,
|
||||||
|
ReviewRecord.map_id == map_id,
|
||||||
|
ReviewRecord.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "复盘记录不存在")
|
||||||
|
return review_to_dict(r)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"],
|
|||||||
|
|
||||||
@router.get("/{map_id}/objectives")
|
@router.get("/{map_id}/objectives")
|
||||||
def list_objectives(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def list_objectives(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""获取某地图下的所有目标"""
|
"""获取某地图下的所有目标(账套隔离: 跨企业返回空, OpenCode执行层发现)"""
|
||||||
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
|
if not m:
|
||||||
|
return {"data": []}
|
||||||
objs = db.query(MapObjective).filter(
|
objs = db.query(MapObjective).filter(
|
||||||
MapObjective.map_id == map_id
|
MapObjective.map_id == map_id
|
||||||
).order_by(MapObjective.sort_order).all()
|
).order_by(MapObjective.sort_order).all()
|
||||||
@@ -23,7 +26,7 @@ def list_objectives(map_id: int, db: Session = Depends(get_db), entity_id: int =
|
|||||||
@router.post("/{map_id}/objectives")
|
@router.post("/{map_id}/objectives")
|
||||||
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""新增目标"""
|
"""新增目标"""
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id), StrategicMap.entity_id == entity_id.first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
obj = MapObjective(
|
obj = MapObjective(
|
||||||
@@ -42,14 +45,18 @@ def create_objective(map_id: int, data: dict, db: Session = Depends(get_db), ent
|
|||||||
|
|
||||||
@router.put("/{map_id}/objectives/{obj_id}")
|
@router.put("/{map_id}/objectives/{obj_id}")
|
||||||
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""修改目标"""
|
"""修改目标(账套隔离 + 字段白名单, OpenCode审查#8)"""
|
||||||
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "战略地图不存在")
|
||||||
obj = db.query(MapObjective).filter(
|
obj = db.query(MapObjective).filter(
|
||||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||||
).first()
|
).first()
|
||||||
if not obj:
|
if not obj:
|
||||||
raise HTTPException(404, "目标不存在")
|
raise HTTPException(404, "目标不存在")
|
||||||
|
ALLOWED_OBJ_FIELDS = {"name", "description", "icon", "sort_order", "kpis", "dimension_key"}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if hasattr(obj, k) and v is not None:
|
if k in ALLOWED_OBJ_FIELDS and hasattr(obj, k) and v is not None:
|
||||||
setattr(obj, k, v)
|
setattr(obj, k, v)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _obj_to_dict(obj)
|
return _obj_to_dict(obj)
|
||||||
@@ -57,7 +64,10 @@ def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends
|
|||||||
|
|
||||||
@router.delete("/{map_id}/objectives/{obj_id}")
|
@router.delete("/{map_id}/objectives/{obj_id}")
|
||||||
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""删除目标"""
|
"""删除目标(账套隔离)"""
|
||||||
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "战略地图不存在")
|
||||||
obj = db.query(MapObjective).filter(
|
obj = db.query(MapObjective).filter(
|
||||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||||
).first()
|
).first()
|
||||||
@@ -70,7 +80,10 @@ def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), en
|
|||||||
|
|
||||||
@router.put("/{map_id}/objectives/sort")
|
@router.put("/{map_id}/objectives/sort")
|
||||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""批量排序: {"ids": [3, 1, 2]}"""
|
"""批量排序: {"ids": [3, 1, 2]}(账套隔离)"""
|
||||||
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "战略地图不存在")
|
||||||
ids = data.get("ids", [])
|
ids = data.get("ids", [])
|
||||||
for idx, obj_id in enumerate(ids):
|
for idx, obj_id in enumerate(ids):
|
||||||
db.query(MapObjective).filter(
|
db.query(MapObjective).filter(
|
||||||
|
|||||||
+379
-60
@@ -1,10 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
|
OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
|
||||||
|
KR完整修复(2026-08-27): KR读取从ActionPlan改为krs表 + 方向符号operator + 权重
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, date as date_type
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
@@ -16,6 +17,159 @@ router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── KR 工具函数 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
VALID_OPERATORS = (">=", "<=", ">", "<", "=")
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v):
|
||||||
|
"""Decimal → float 便于 JSON 序列化"""
|
||||||
|
return float(v) if v is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_operator(raw):
|
||||||
|
"""解析方向符号: 支持 >=/<=/>/</=,非法默认 >="""
|
||||||
|
if not raw:
|
||||||
|
return ">="
|
||||||
|
op = str(raw).strip()
|
||||||
|
if op in VALID_OPERATORS:
|
||||||
|
return op
|
||||||
|
# 兼容中文符号 ≥ ≤
|
||||||
|
if op in ("≥",):
|
||||||
|
return ">="
|
||||||
|
if op in ("≤",):
|
||||||
|
return "<="
|
||||||
|
return ">="
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_kr(kr: KR) -> bool:
|
||||||
|
"""达成判断: 实际值满足 operator → True"""
|
||||||
|
if kr.current_value is None or kr.target_value is None:
|
||||||
|
return False
|
||||||
|
cur = float(kr.current_value)
|
||||||
|
tgt = float(kr.target_value)
|
||||||
|
op = _parse_operator(kr.operator)
|
||||||
|
if op == ">=":
|
||||||
|
ok = cur >= tgt
|
||||||
|
elif op == "<=":
|
||||||
|
ok = cur <= tgt
|
||||||
|
elif op == ">":
|
||||||
|
ok = cur > tgt
|
||||||
|
elif op == "<":
|
||||||
|
ok = cur < tgt
|
||||||
|
elif op == "=":
|
||||||
|
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
|
||||||
|
ok = abs(cur - tgt) <= tol
|
||||||
|
else:
|
||||||
|
ok = False
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_progress(kr: KR) -> int:
|
||||||
|
"""方向感知进度计算:
|
||||||
|
>=/>: current/target*100
|
||||||
|
<=/<: target/current*100
|
||||||
|
= : 容差内100
|
||||||
|
"""
|
||||||
|
if kr.current_value is None or kr.target_value is None:
|
||||||
|
return kr.progress or 0
|
||||||
|
cur = float(kr.current_value)
|
||||||
|
tgt = float(kr.target_value)
|
||||||
|
if tgt == 0:
|
||||||
|
return 100 if cur == 0 else (100 if cur > 0 else 0)
|
||||||
|
op = _parse_operator(kr.operator)
|
||||||
|
if op in (">=", ">"):
|
||||||
|
pct = cur / tgt * 100
|
||||||
|
elif op in ("<=", "<"):
|
||||||
|
# 值越小进度越高(如费用率<=18%)
|
||||||
|
if cur <= tgt:
|
||||||
|
pct = 100.0
|
||||||
|
else:
|
||||||
|
pct = max(0.0, tgt / cur * 100)
|
||||||
|
elif op == "=":
|
||||||
|
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
|
||||||
|
if abs(cur - tgt) <= tol:
|
||||||
|
pct = 100.0
|
||||||
|
else:
|
||||||
|
pct = max(0.0, 100.0 - abs(cur - tgt) / (abs(tgt) or 1) * 100)
|
||||||
|
else:
|
||||||
|
pct = cur / tgt * 100
|
||||||
|
return max(0, min(100, round(pct)))
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_kr_state(kr: KR):
|
||||||
|
"""按当前值刷新 progress + status(方向感知)"""
|
||||||
|
if kr.current_value is not None and kr.target_value is not None:
|
||||||
|
kr.progress = _calc_progress(kr)
|
||||||
|
if _eval_kr(kr):
|
||||||
|
kr.status = "achieved"
|
||||||
|
elif kr.progress > 0:
|
||||||
|
kr.status = "in_progress"
|
||||||
|
return kr
|
||||||
|
|
||||||
|
|
||||||
|
def _inherit_kpi_direction(db: Session, kpi_id, operator, target_value):
|
||||||
|
"""关联KPI时自动继承方向: 从KPI的threshold_green解析(如 '>=800' / '<=18')"""
|
||||||
|
if not kpi_id:
|
||||||
|
return operator, target_value
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
|
if not kpi:
|
||||||
|
return operator, target_value
|
||||||
|
threshold = (kpi.threshold_green or "").strip()
|
||||||
|
if not threshold:
|
||||||
|
return operator, target_value
|
||||||
|
op = operator
|
||||||
|
tgt = target_value
|
||||||
|
for cand in VALID_OPERATORS:
|
||||||
|
if threshold.startswith(cand):
|
||||||
|
op = cand
|
||||||
|
try:
|
||||||
|
tgt = float(threshold[len(cand):].strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# 无符号时尝试解析纯数字
|
||||||
|
try:
|
||||||
|
tgt = float(threshold)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return op, tgt
|
||||||
|
|
||||||
|
|
||||||
|
def _kr_serialize(kr: KR, db: Session = None) -> dict:
|
||||||
|
"""KR → dict(含operator/weight/tolerance)"""
|
||||||
|
mkpi = None
|
||||||
|
if db and kr.metric_kpi_id:
|
||||||
|
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
|
||||||
|
return {
|
||||||
|
"id": kr.id,
|
||||||
|
"objective_id": kr.objective_id,
|
||||||
|
"title": kr.title,
|
||||||
|
"metric_kpi_id": kr.metric_kpi_id,
|
||||||
|
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||||
|
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||||
|
"operator": _parse_operator(kr.operator),
|
||||||
|
"tolerance": _num(kr.tolerance),
|
||||||
|
"weight": _num(kr.weight),
|
||||||
|
"target_value": _num(kr.target_value),
|
||||||
|
"current_value": _num(kr.current_value),
|
||||||
|
"progress": kr.progress,
|
||||||
|
"status": kr.status,
|
||||||
|
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||||||
|
"monthly_milestones": kr.monthly_milestones or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_objective_or_404(db: Session, obj_id: int, entity_id: int) -> Objective:
|
||||||
|
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(404, "目标不存在")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
# ── 目标列表/详情 ─────────────────────────────────────
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_objectives(
|
def list_objectives(
|
||||||
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
||||||
@@ -24,7 +178,7 @@ def list_objectives(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""列出OKR目标(账套隔离: 按token企业, 2026-08-23 P2)"""
|
"""列出OKR目标(账套隔离: 按token企业) — KR读取自krs表(2026-08-27)"""
|
||||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||||
if quarter:
|
if quarter:
|
||||||
q = q.filter(Objective.quarter == quarter)
|
q = q.filter(Objective.quarter == quarter)
|
||||||
@@ -35,9 +189,11 @@ def list_objectives(
|
|||||||
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
||||||
results = []
|
results = []
|
||||||
for o in objs:
|
for o in objs:
|
||||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all()
|
krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all()
|
||||||
kr_summary = [
|
kr_summary = [
|
||||||
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress}
|
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress,
|
||||||
|
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight),
|
||||||
|
"metric_kpi_id": kr.metric_kpi_id}
|
||||||
for kr in krs
|
for kr in krs
|
||||||
]
|
]
|
||||||
results.append({
|
results.append({
|
||||||
@@ -47,7 +203,7 @@ def list_objectives(
|
|||||||
"confidence": o.confidence,
|
"confidence": o.confidence,
|
||||||
"key_results": kr_summary,
|
"key_results": kr_summary,
|
||||||
"kr_count": len(krs),
|
"kr_count": len(krs),
|
||||||
"kr_completed": sum(1 for kr in krs if kr.status == "completed"),
|
"kr_completed": sum(1 for kr in krs if kr.status in ("completed", "achieved")),
|
||||||
"created_at": o.created_at.isoformat() if o.created_at else None,
|
"created_at": o.created_at.isoformat() if o.created_at else None,
|
||||||
})
|
})
|
||||||
return {"total": len(results), "items": results}
|
return {"total": len(results), "items": results}
|
||||||
@@ -60,7 +216,6 @@ def create_objective(
|
|||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
|
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
|
||||||
# 兼容旧版Query参数
|
|
||||||
title = data.get("title") or ""
|
title = data.get("title") or ""
|
||||||
quarter = data.get("quarter") or ""
|
quarter = data.get("quarter") or ""
|
||||||
description = data.get("description")
|
description = data.get("description")
|
||||||
@@ -78,11 +233,9 @@ def create_objective(
|
|||||||
|
|
||||||
@router.get("/{obj_id}")
|
@router.get("/{obj_id}")
|
||||||
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""获取单个OKR详情(账套隔离: 跨企业404)"""
|
"""获取单个OKR详情(账套隔离: 跨企业404) — KR读取自krs表(2026-08-27)"""
|
||||||
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
|
obj = _get_objective_or_404(db, obj_id, entity_id)
|
||||||
if not obj:
|
krs = db.query(KR).filter(KR.objective_id == obj_id).order_by(KR.id).all()
|
||||||
raise HTTPException(404, "目标不存在")
|
|
||||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
|
|
||||||
return {
|
return {
|
||||||
"objective": {
|
"objective": {
|
||||||
"id": obj.id, "title": obj.title, "description": obj.description,
|
"id": obj.id, "title": obj.title, "description": obj.description,
|
||||||
@@ -90,31 +243,195 @@ def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = D
|
|||||||
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
|
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
|
||||||
"confidence": obj.confidence,
|
"confidence": obj.confidence,
|
||||||
},
|
},
|
||||||
"key_results": [
|
"key_results": [_kr_serialize(kr, db) for kr in krs],
|
||||||
{"id": kr.id, "title": kr.title, "kpi_id": kr.kpi_id,
|
|
||||||
"status": kr.status, "progress": kr.progress,
|
|
||||||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
|
||||||
"assignee": kr.assignee}
|
|
||||||
for kr in krs
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{obj_id}")
|
||||||
|
def update_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
|
"""更新OKR进度(通过krs表KR自动计算加权进度)"""
|
||||||
|
obj = _get_objective_or_404(db, obj_id, entity_id)
|
||||||
|
krs = db.query(KR).filter(KR.objective_id == obj_id).all()
|
||||||
|
if krs:
|
||||||
|
weights = [float(kr.weight) if kr.weight else 1.0 for kr in krs]
|
||||||
|
total_w = sum(weights) or len(krs)
|
||||||
|
obj.progress = round(sum(kr.progress * w for kr, w in zip(krs, weights)) / total_w)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "id": obj_id, "progress": obj.progress}
|
||||||
|
|
||||||
|
|
||||||
|
# ── KR CRUD ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{objective_id}/krs")
|
||||||
|
def create_kr(objective_id: int, data: dict, db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
|
"""新增KR(落krs表)— title/metric_kpi_id/operator/target_value/weight/progress/due_date
|
||||||
|
关联KPI时自动继承方向(threshold_green)
|
||||||
|
"""
|
||||||
|
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||||
|
title = data.get("title") or ""
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(422, "缺少必填字段: title")
|
||||||
|
operator, target_value = _inherit_kpi_direction(
|
||||||
|
db, data.get("metric_kpi_id"), data.get("operator"), data.get("target_value"))
|
||||||
|
if target_value is None and operator != "=":
|
||||||
|
# 没有目标值也能建(后续补),但明确提示
|
||||||
|
pass
|
||||||
|
kr = KR(
|
||||||
|
entity_id=entity_id,
|
||||||
|
objective_id=objective_id,
|
||||||
|
title=title,
|
||||||
|
metric_kpi_id=data.get("metric_kpi_id") or None,
|
||||||
|
operator=_parse_operator(operator),
|
||||||
|
tolerance=data.get("tolerance"),
|
||||||
|
weight=data.get("weight"),
|
||||||
|
target_value=target_value,
|
||||||
|
current_value=data.get("current_value"),
|
||||||
|
progress=data.get("progress", 0),
|
||||||
|
status=data.get("status", "pending"),
|
||||||
|
due_date=_parse_date(data.get("due_date")),
|
||||||
|
monthly_milestones=data.get("monthly_milestones") or [],
|
||||||
|
)
|
||||||
|
_refresh_kr_state(kr)
|
||||||
|
db.add(kr)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kr)
|
||||||
|
update_objective(objective_id, db, entity_id)
|
||||||
|
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{objective_id}/krs/sync")
|
||||||
|
def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
|
"""批量同步KR数组(前端保存整个KR列表时用):
|
||||||
|
body: {"krs": [{id?, title, operator, target_value, weight, kpi_code?, ...}]}
|
||||||
|
已存在的按id更新;无id且同名则更新;否则新增;不在列表中的删除
|
||||||
|
注意: 必须声明在 /krs/{kr_id} 之前,避免 'sync' 被解析为 kr_id
|
||||||
|
"""
|
||||||
|
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||||
|
incoming = data.get("krs", [])
|
||||||
|
if not isinstance(incoming, list):
|
||||||
|
raise HTTPException(400, "krs 必须是数组")
|
||||||
|
existing = {kr.id: kr for kr in db.query(KR).filter(KR.objective_id == objective_id).all()}
|
||||||
|
keep_ids = set()
|
||||||
|
now_name = {}
|
||||||
|
|
||||||
|
for i, item in enumerate(incoming):
|
||||||
|
title = (item.get("title") or item.get("name") or "").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
kpi_id = item.get("metric_kpi_id") or item.get("kpi_id")
|
||||||
|
# kpi_code → kpi_id 转换
|
||||||
|
if not kpi_id and item.get("kpi_code"):
|
||||||
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.kpi_code == item["kpi_code"],
|
||||||
|
KPIDefinition.entity_id == entity_id).first()
|
||||||
|
kpi_id = kpi.id if kpi else None
|
||||||
|
operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value"))
|
||||||
|
|
||||||
|
kr = None
|
||||||
|
if item.get("id") and int(item["id"]) in existing:
|
||||||
|
kr = existing[int(item["id"])]
|
||||||
|
elif title in now_name:
|
||||||
|
kr = now_name[title]
|
||||||
|
else:
|
||||||
|
# 按同名找已有
|
||||||
|
for k in existing.values():
|
||||||
|
if k.title == title:
|
||||||
|
kr = k
|
||||||
|
break
|
||||||
|
if kr is None:
|
||||||
|
kr = KR(entity_id=entity_id, objective_id=objective_id, title=title)
|
||||||
|
db.add(kr)
|
||||||
|
kr.title = title
|
||||||
|
kr.metric_kpi_id = kpi_id
|
||||||
|
kr.operator = _parse_operator(operator)
|
||||||
|
if item.get("tolerance") is not None:
|
||||||
|
kr.tolerance = item.get("tolerance")
|
||||||
|
if item.get("weight") is not None:
|
||||||
|
kr.weight = item.get("weight")
|
||||||
|
if item.get("target_value") is not None:
|
||||||
|
kr.target_value = item.get("target_value")
|
||||||
|
elif target_value is not None:
|
||||||
|
kr.target_value = target_value
|
||||||
|
if item.get("current_value") is not None:
|
||||||
|
kr.current_value = item.get("current_value")
|
||||||
|
if item.get("due_date"):
|
||||||
|
kr.due_date = _parse_date(item.get("due_date"))
|
||||||
|
if item.get("monthly_milestones") is not None:
|
||||||
|
kr.monthly_milestones = item.get("monthly_milestones")
|
||||||
|
_refresh_kr_state(kr)
|
||||||
|
keep_ids.add(kr.id)
|
||||||
|
now_name[title] = kr
|
||||||
|
# 删除不在列表中的
|
||||||
|
for kid, kr in existing.items():
|
||||||
|
if kid not in keep_ids:
|
||||||
|
db.delete(kr)
|
||||||
|
db.commit()
|
||||||
|
update_objective(objective_id, db, entity_id)
|
||||||
|
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
|
||||||
|
return {"ok": True, "objective_id": objective_id, "krs": [_kr_serialize(kr, db) for kr in krs]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{objective_id}/krs/{kr_id}")
|
||||||
|
def update_kr(objective_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
|
"""更新KR(含方向/权重/当前值,自动重算progress+status)"""
|
||||||
|
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||||
|
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
|
||||||
|
KR.entity_id == entity_id).first()
|
||||||
|
if not kr:
|
||||||
|
raise HTTPException(404, "KR不存在或不属于该目标")
|
||||||
|
updatable = {"title", "metric_kpi_id", "operator", "tolerance", "weight",
|
||||||
|
"target_value", "current_value", "progress", "status", "due_date",
|
||||||
|
"monthly_milestones"}
|
||||||
|
for k, v in data.items():
|
||||||
|
if k in updatable and v is not None:
|
||||||
|
setattr(kr, k, v)
|
||||||
|
# 关联KPI变化 → 自动继承方向
|
||||||
|
if "metric_kpi_id" in data or "operator" not in data or "target_value" not in data:
|
||||||
|
if data.get("metric_kpi_id") and ("operator" not in data or "target_value" not in data):
|
||||||
|
kr.operator, kr.target_value = _inherit_kpi_direction(
|
||||||
|
db, data.get("metric_kpi_id", kr.metric_kpi_id),
|
||||||
|
data.get("operator", kr.operator), data.get("target_value", kr.target_value))
|
||||||
|
if data.get("due_date"):
|
||||||
|
kr.due_date = _parse_date(data["due_date"])
|
||||||
|
_refresh_kr_state(kr)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kr)
|
||||||
|
update_objective(objective_id, db, entity_id)
|
||||||
|
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{objective_id}/krs/{kr_id}")
|
||||||
|
def delete_kr(objective_id: int, kr_id: int, db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
|
"""删除KR"""
|
||||||
|
_get_objective_or_404(db, objective_id, entity_id)
|
||||||
|
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
|
||||||
|
KR.entity_id == entity_id).first()
|
||||||
|
if not kr:
|
||||||
|
raise HTTPException(404, "KR不存在或不属于该目标")
|
||||||
|
db.delete(kr)
|
||||||
|
db.commit()
|
||||||
|
update_objective(objective_id, db, entity_id)
|
||||||
|
return {"ok": True, "deleted": kr_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ── KR里程碑(时间分解) ─────────────────────────────
|
||||||
|
|
||||||
@router.put("/{okr_id}/decomposition/milestones/{kr_id}")
|
@router.put("/{okr_id}/decomposition/milestones/{kr_id}")
|
||||||
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db)):
|
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
|
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
|
||||||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
_get_objective_or_404(db, okr_id, entity_id)
|
||||||
if not obj:
|
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
|
||||||
raise HTTPException(404, "OKR不存在")
|
KR.entity_id == entity_id).first()
|
||||||
kr = db.query(ActionPlan).filter(
|
|
||||||
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
|
|
||||||
).first()
|
|
||||||
if not kr:
|
if not kr:
|
||||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||||
milestones = data.get("milestones")
|
milestones = data.get("milestones")
|
||||||
if not isinstance(milestones, list):
|
if not isinstance(milestones, list):
|
||||||
raise HTTPException(400, "milestones 必须是数组")
|
raise HTTPException(400, "milestones 必须是数组")
|
||||||
# 规范化:只保留 month/label/status/target 字段
|
|
||||||
normalized = []
|
normalized = []
|
||||||
for ms in milestones:
|
for ms in milestones:
|
||||||
normalized.append({
|
normalized.append({
|
||||||
@@ -124,44 +441,45 @@ def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depe
|
|||||||
"target": ms.get("target"),
|
"target": ms.get("target"),
|
||||||
})
|
})
|
||||||
kr.monthly_milestones = normalized
|
kr.monthly_milestones = normalized
|
||||||
# 里程碑完成 → KR进度同步更新(完成数/总数)
|
|
||||||
if normalized:
|
if normalized:
|
||||||
done = sum(1 for ms in normalized if ms.get("status") == "completed")
|
done = sum(1 for ms in normalized if ms.get("status") == "completed")
|
||||||
kr.progress = round(done / len(normalized) * 100)
|
kr.progress = round(done / len(normalized) * 100)
|
||||||
if kr.progress >= 100:
|
if kr.progress >= 100:
|
||||||
kr.status = "completed"
|
kr.status = "achieved"
|
||||||
elif kr.status == "completed" and kr.progress < 100:
|
elif kr.status == "achieved" and kr.progress < 100:
|
||||||
kr.status = "in_progress"
|
kr.status = "in_progress"
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
|
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{okr_id}/decomposition/milestones/generate")
|
@router.post("/{okr_id}/decomposition/milestones/generate")
|
||||||
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db)):
|
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id)):
|
||||||
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
|
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
|
||||||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
_get_objective_or_404(db, okr_id, entity_id)
|
||||||
if not obj:
|
|
||||||
raise HTTPException(404, "OKR不存在")
|
|
||||||
kr_id = data.get("kr_id")
|
kr_id = data.get("kr_id")
|
||||||
if not kr_id:
|
if not kr_id:
|
||||||
raise HTTPException(400, "缺少 kr_id")
|
raise HTTPException(400, "缺少 kr_id")
|
||||||
kr = db.query(ActionPlan).filter(
|
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
|
||||||
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
|
KR.entity_id == entity_id).first()
|
||||||
).first()
|
|
||||||
if not kr:
|
if not kr:
|
||||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||||
# 重新生成:覆盖已有里程碑(幂等)
|
kr.monthly_milestones = _build_auto_milestones(kr)
|
||||||
milestones = _build_auto_milestones(kr)
|
|
||||||
kr.monthly_milestones = milestones
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
|
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
|
||||||
|
|
||||||
|
|
||||||
def _build_auto_milestones(kr: ActionPlan) -> list:
|
def _build_auto_milestones(kr: KR) -> list:
|
||||||
"""按截止日期向前均分3个月度里程碑"""
|
"""按截止日期向前均分3个月度里程碑"""
|
||||||
base = kr.due_date or datetime.now() + timedelta(days=90)
|
base = kr.due_date or datetime.now() + timedelta(days=90)
|
||||||
|
if isinstance(base, str):
|
||||||
|
try:
|
||||||
|
base = datetime.strptime(base[:10], "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
base = datetime.now().date() + timedelta(days=90)
|
||||||
|
if isinstance(base, datetime):
|
||||||
|
base = base.date()
|
||||||
base = base.replace(day=1)
|
base = base.replace(day=1)
|
||||||
# 计算前3个月份(从最早的月份开始)
|
|
||||||
month_keys = []
|
month_keys = []
|
||||||
for i in range(3, 0, -1):
|
for i in range(3, 0, -1):
|
||||||
y, m = base.year, base.month - i
|
y, m = base.year, base.month - i
|
||||||
@@ -180,34 +498,35 @@ def _build_auto_milestones(kr: ActionPlan) -> list:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{obj_id}")
|
def _parse_date(v):
|
||||||
def update_objective(obj_id: int, db: Session = Depends(get_db)):
|
"""兼容 str/date/datetime → date"""
|
||||||
"""更新OKR进度(通过查询ActionPlan自动计算)"""
|
if v is None or v == "":
|
||||||
obj = db.query(Objective).filter(Objective.id == obj_id).first()
|
return None
|
||||||
if not obj:
|
if isinstance(v, date_type):
|
||||||
raise HTTPException(404, "目标不存在")
|
return v
|
||||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
|
if isinstance(v, datetime):
|
||||||
if krs:
|
return v.date()
|
||||||
obj.progress = sum(kr.progress for kr in krs) // len(krs)
|
try:
|
||||||
db.commit()
|
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
|
||||||
return {"ok": True, "id": obj_id, "progress": obj.progress}
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{okr_id}/decomposition")
|
@router.get("/{okr_id}/decomposition")
|
||||||
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db)):
|
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
|
||||||
"""获取OKR的时间分解视图数据"""
|
entity_id: int = Depends(get_entity_id)):
|
||||||
okr = db.query(Objective).filter(Objective.id == okr_id).first()
|
"""获取OKR的时间分解视图数据 — KR读取自krs表(2026-08-27)"""
|
||||||
if not okr:
|
okr = _get_objective_or_404(db, okr_id, entity_id)
|
||||||
raise HTTPException(404, "OKR不存在")
|
|
||||||
|
|
||||||
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
|
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
|
||||||
bsc_o = db.query(Objective).filter(
|
bsc_o = db.query(Objective).filter(
|
||||||
Objective.dimension == okr.dimension,
|
Objective.dimension == okr.dimension,
|
||||||
Objective.quarter.is_(None)
|
Objective.quarter.is_(None),
|
||||||
|
Objective.entity_id == entity_id,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# 2. 本OKR的所有KR(关联到该Objective的ActionPlan)
|
# 2. 本OKR的所有KR(krs表)
|
||||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == okr_id).all()
|
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
|
||||||
|
|
||||||
# 3. 当前周的ActionPlan(本周行动计划)
|
# 3. 当前周的ActionPlan(本周行动计划)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ def trace_ontology(
|
|||||||
"metric_kpi_id": kr.metric_kpi_id,
|
"metric_kpi_id": kr.metric_kpi_id,
|
||||||
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||||
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||||
|
"operator": kr.operator or ">=",
|
||||||
|
"weight": _num(kr.weight),
|
||||||
"target_value": _num(kr.target_value),
|
"target_value": _num(kr.target_value),
|
||||||
"current_value": _num(kr.current_value),
|
"current_value": _num(kr.current_value),
|
||||||
"progress": kr.progress,
|
"progress": kr.progress,
|
||||||
|
|||||||
@@ -161,6 +161,97 @@ def api_cvp_detailed(data: dict):
|
|||||||
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
|
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 相关成本决策(CMA P2商业决策分析25%权重核心) ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/relevant-decision")
|
||||||
|
def api_relevant_decision(data: dict):
|
||||||
|
"""相关成本决策分析(CMA P2 商业决策分析核心内容)
|
||||||
|
场景: make-or-buy自制外购 / special-order特殊订单 / product-mix产品组合
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
decision_type = data.get("type", "make_or_buy")
|
||||||
|
|
||||||
|
if decision_type == "make_or_buy":
|
||||||
|
# 自制vs外购决策
|
||||||
|
# 相关成本 = 增量成本(只有随决策变化的成本才是相关的)
|
||||||
|
make_var_cost = float(data.get("make_variable_cost", 0)) # 自制单位变动成本
|
||||||
|
make_fixed = float(data.get("make_fixed_cost", 0)) # 自制新增固定成本
|
||||||
|
buy_price = float(data.get("buy_price", 0)) # 外购单价
|
||||||
|
demand = float(data.get("demand", 0)) # 需求量
|
||||||
|
existing_fixed = float(data.get("existing_fixed_cost", 0)) # 现有固定成本(无关成本,自制不增加则忽略)
|
||||||
|
|
||||||
|
make_total = make_var_cost * demand + make_fixed
|
||||||
|
buy_total = buy_price * demand
|
||||||
|
diff = buy_total - make_total # >0自制省钱
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "自制vs外购",
|
||||||
|
"make_total_cost": round(make_total, 2),
|
||||||
|
"buy_total_cost": round(buy_total, 2),
|
||||||
|
"difference": round(diff, 2),
|
||||||
|
"recommendation": "自制" if diff > 0 else "外购",
|
||||||
|
"reason": f"自制总成本{make_total:.2f} vs 外购总成本{buy_total:.2f},{'自制节省' + str(round(diff,2)) if diff > 0 else '外购节省' + str(round(-diff,2))}",
|
||||||
|
"unit_make_cost": round(make_var_cost + (make_fixed / demand if demand else 0), 2),
|
||||||
|
"unit_buy_price": buy_price,
|
||||||
|
"indifferent_point": round(make_fixed / (buy_price - make_var_cost), 2) if buy_price > make_var_cost else None,
|
||||||
|
"notes": "仅考虑相关成本(增量成本);现有固定成本若不受决策影响则无关",
|
||||||
|
}
|
||||||
|
|
||||||
|
elif decision_type == "special_order":
|
||||||
|
# 特殊订单决策(有剩余产能时,只要价格>单位变动成本即接受)
|
||||||
|
normal_price = float(data.get("normal_price", 0))
|
||||||
|
special_price = float(data.get("special_price", 0))
|
||||||
|
var_cost = float(data.get("variable_cost", 0))
|
||||||
|
order_qty = float(data.get("order_qty", 0))
|
||||||
|
capacity_used = float(data.get("capacity_used", 0)) # 特殊订单占用产能%
|
||||||
|
extra_fixed = float(data.get("extra_fixed_cost", 0)) # 一次性额外固定成本
|
||||||
|
|
||||||
|
contribution_per_unit = special_price - var_cost
|
||||||
|
total_contribution = contribution_per_unit * order_qty - extra_fixed
|
||||||
|
accept = total_contribution > 0 and capacity_used <= 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "特殊订单",
|
||||||
|
"unit_contribution": round(contribution_per_unit, 2),
|
||||||
|
"total_contribution": round(total_contribution, 2),
|
||||||
|
"extra_fixed_cost": extra_fixed,
|
||||||
|
"capacity_used_pct": capacity_used,
|
||||||
|
"recommendation": "接受" if accept else "拒绝",
|
||||||
|
"reason": f"单价{special_price} - 变动成本{var_cost} = 单位贡献{contribution_per_unit:.2f}" +
|
||||||
|
(f",共{total_contribution:.2f} > 0 且产能{capacity_used}%够用 → 接受(增量利润)" if accept else
|
||||||
|
f",总贡献{total_contribution:.2f} ≤ 0 或产能不足 → 拒绝"),
|
||||||
|
"notes": "有剩余产能时,只要价格>变动成本且不冲击正常市场即可接受;固定成本无关",
|
||||||
|
}
|
||||||
|
|
||||||
|
elif decision_type == "product_mix":
|
||||||
|
# 产品组合决策(约束理论:单位约束资源的边际贡献最大者优先)
|
||||||
|
products = data.get("products", []) # [{name, price, var_cost, constraint_usage, demand}]
|
||||||
|
results = []
|
||||||
|
for p in products:
|
||||||
|
cm_per_unit = float(p.get("price", 0)) - float(p.get("var_cost", 0))
|
||||||
|
cm_per_constraint = cm_per_unit / float(p.get("constraint_usage", 1))
|
||||||
|
results.append({
|
||||||
|
"name": p.get("name", ""),
|
||||||
|
"unit_contribution": round(cm_per_unit, 2),
|
||||||
|
"constraint_usage": float(p.get("constraint_usage", 1)),
|
||||||
|
"contribution_per_constraint": round(cm_per_constraint, 2),
|
||||||
|
"demand": float(p.get("demand", 0)),
|
||||||
|
})
|
||||||
|
# 按单位约束资源贡献排序(约束理论优先)
|
||||||
|
results.sort(key=lambda x: x["contribution_per_constraint"], reverse=True)
|
||||||
|
return {
|
||||||
|
"type": "产品组合(约束理论)",
|
||||||
|
"ranking": results,
|
||||||
|
"recommendation": f"优先生产「{results[0]['name']}」(单位约束贡献{results[0]['contribution_per_constraint']}最高)",
|
||||||
|
"notes": "瓶颈资源下,按单位约束资源的边际贡献排序,而非单位边际贡献",
|
||||||
|
}
|
||||||
|
|
||||||
|
raise HTTPException(400, "未知决策类型: " + str(decision_type))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"相关成本决策失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# ── 现金流预测(AI事前预警) ────────────────────────────────────
|
# ── 现金流预测(AI事前预警) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+506
-105
@@ -12,13 +12,14 @@ from fastapi import APIRouter, Depends, Query, HTTPException
|
|||||||
import json
|
import json
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, or_
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_role, require_auth
|
from app.auth_middleware import require_role, require_auth
|
||||||
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
|
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
|
||||||
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff
|
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi
|
||||||
|
from app.deps import get_entity_id
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger("cma.reports")
|
logger = logging.getLogger("cma.reports")
|
||||||
@@ -36,6 +37,7 @@ router = APIRouter(prefix="/api/cma/reports", tags=["管理报表"],
|
|||||||
def get_profit_summary(
|
def get_profit_summary(
|
||||||
period: str = Query(None, description="格式 YYYY-MM"),
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润"""
|
"""管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润"""
|
||||||
if period is None:
|
if period is None:
|
||||||
@@ -43,7 +45,8 @@ def get_profit_summary(
|
|||||||
|
|
||||||
# 从KPI数据中获取各利润要素
|
# 从KPI数据中获取各利润要素
|
||||||
def get_val(code: str):
|
def get_val(code: str):
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
return None
|
return None
|
||||||
v = db.query(KPIValue).filter(
|
v = db.query(KPIValue).filter(
|
||||||
@@ -78,7 +81,8 @@ def get_profit_summary(
|
|||||||
prev_period = f"{py}-{pm:02d}"
|
prev_period = f"{py}-{pm:02d}"
|
||||||
|
|
||||||
def get_prev_val(code: str):
|
def get_prev_val(code: str):
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||||
if not kpi: return None
|
if not kpi: return None
|
||||||
v = db.query(KPIValue).filter(
|
v = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id, KPIValue.period == prev_period
|
KPIValue.kpi_id == kpi.id, KPIValue.period == prev_period
|
||||||
@@ -158,12 +162,13 @@ def get_budget_execution(
|
|||||||
dimension: Optional[str] = Query(None),
|
dimension: Optional[str] = Query(None),
|
||||||
alert_level: Optional[str] = Query(None),
|
alert_level: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""预算执行报告 — 各KPI预算vs实际vs差异率"""
|
"""预算执行报告 — 各KPI预算vs实际vs差异率(账套隔离 2026-08-31)"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
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)
|
||||||
if dimension:
|
if dimension:
|
||||||
query = query.filter(KPIDefinition.dimension == dimension)
|
query = query.filter(KPIDefinition.dimension == dimension)
|
||||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).all()
|
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).all()
|
||||||
@@ -222,9 +227,10 @@ def get_kpi_trends(
|
|||||||
dimension: Optional[str] = Query(None),
|
dimension: Optional[str] = Query(None),
|
||||||
months: int = Query(12, ge=3, le=36),
|
months: int = Query(12, ge=3, le=36),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""KPI趋势报告 — 选定KPI的历史趋势线"""
|
"""KPI趋势报告 — 选定KPI的历史趋势线(账套隔离 2026-08-31)"""
|
||||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id)
|
||||||
if kpi_id:
|
if kpi_id:
|
||||||
query = query.filter(KPIDefinition.id == kpi_id)
|
query = query.filter(KPIDefinition.id == kpi_id)
|
||||||
if dimension:
|
if dimension:
|
||||||
@@ -288,20 +294,21 @@ def get_bsc_scorecard(
|
|||||||
map_id: Optional[int] = Query(None),
|
map_id: Optional[int] = Query(None),
|
||||||
period: Optional[str] = Query(None),
|
period: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""四维度绩效评分卡 — BSC健康度"""
|
"""四维度绩效评分卡 — BSC健康度(账套隔离 2026-08-31)"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
# 取最新的已发布地图
|
# 取最新的已发布地图(当前企业)
|
||||||
map_query = db.query(StrategicMap).filter(StrategicMap.status == "published")
|
map_query = db.query(StrategicMap).filter(StrategicMap.status == "published", StrategicMap.entity_id == entity_id)
|
||||||
if map_id:
|
if map_id:
|
||||||
map_query = map_query.filter(StrategicMap.id == map_id)
|
map_query = map_query.filter(StrategicMap.id == map_id)
|
||||||
sm = map_query.order_by(StrategicMap.updated_at.desc()).first()
|
sm = map_query.order_by(StrategicMap.updated_at.desc()).first()
|
||||||
|
|
||||||
if not sm:
|
if not sm:
|
||||||
# 没有已发布地图,按维度聚合KPI
|
# 没有已发布地图,按维度聚合KPI
|
||||||
return _build_scorecard_from_kpis(db, period)
|
return _build_scorecard_from_kpis(db, period, entity_id)
|
||||||
|
|
||||||
# 从战略地图维度数据构建评分卡
|
# 从战略地图维度数据构建评分卡
|
||||||
dims = sm.dimensions
|
dims = sm.dimensions
|
||||||
@@ -368,9 +375,10 @@ def get_bsc_scorecard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_scorecard_from_kpis(db: Session, period: str) -> dict:
|
def _build_scorecard_from_kpis(db: Session, period: str, entity_id: int = 1) -> dict:
|
||||||
"""没有战略地图时,直接按维度聚合KPI算分"""
|
"""没有战略地图时,直接按维度聚合KPI算分(账套隔离 2026-08-31)"""
|
||||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||||
dims: dict = {}
|
dims: dict = {}
|
||||||
|
|
||||||
for kpi in kpis:
|
for kpi in kpis:
|
||||||
@@ -519,8 +527,8 @@ BLOCK_INFO = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]:
|
def _get_subject_amount(db: Session, code: str, period: str, entity_id: int = 1) -> Optional[float]:
|
||||||
"""从 subjects + kpi_values 获取科目金额数据"""
|
"""从 subjects + kpi_values 获取科目金额数据(账套隔离 2026-08-31)"""
|
||||||
# 尝试从KPI数据获取(KPI编码与科目编码映射)
|
# 尝试从KPI数据获取(KPI编码与科目编码映射)
|
||||||
kpi_code_map = {
|
kpi_code_map = {
|
||||||
"6001": "F_REVENUE",
|
"6001": "F_REVENUE",
|
||||||
@@ -544,7 +552,8 @@ def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]:
|
|||||||
# 1. 优先从 kpi_values 取
|
# 1. 优先从 kpi_values 取
|
||||||
if code in kpi_code_map:
|
if code in kpi_code_map:
|
||||||
kpi_code = kpi_code_map[code]
|
kpi_code = kpi_code_map[code]
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.kpi_code == kpi_code, KPIDefinition.entity_id == entity_id).first()
|
||||||
if kpi:
|
if kpi:
|
||||||
v = db.query(KPIValue).filter(
|
v = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
@@ -575,19 +584,20 @@ def get_profit_statement(
|
|||||||
period: str = Query(None, description="格式 YYYY-MM"),
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
format: str = Query("old", description="old/new/dual"),
|
format: str = Query("old", description="old/new/dual"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""利润表 — 支持旧格式、新30号准则五板块格式、双列对比"""
|
"""利润表 — 支持旧格式、新30号准则五板块格式、双列对比(账套隔离 2026-08-31)"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
if format == "old":
|
if format == "old":
|
||||||
# 旧30号准则格式(保留兼容)
|
# 旧30号准则格式(保留兼容)
|
||||||
return get_profit_summary(period=period, db=db)
|
return get_profit_summary(period=period, db=db, entity_id=entity_id)
|
||||||
|
|
||||||
if format == "dual":
|
if format == "dual":
|
||||||
# 双列对比:旧准则 vs 新准则
|
# 双列对比:旧准则 vs 新准则
|
||||||
old_data = get_profit_summary(period=period, db=db)
|
old_data = get_profit_summary(period=period, db=db, entity_id=entity_id)
|
||||||
new_data = _build_new_format_profit(db, period)
|
new_data = _build_new_format_profit(db, period, entity_id)
|
||||||
return {
|
return {
|
||||||
"period": period,
|
"period": period,
|
||||||
"format": "dual",
|
"format": "dual",
|
||||||
@@ -597,11 +607,11 @@ def get_profit_statement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# === 新30号准则:五板块结构 ===
|
# === 新30号准则:五板块结构 ===
|
||||||
return _build_new_format_profit(db, period)
|
return _build_new_format_profit(db, period, entity_id)
|
||||||
|
|
||||||
|
|
||||||
def _build_new_format_profit(db: Session, period: str) -> dict:
|
def _build_new_format_profit(db: Session, period: str, entity_id: int = 1) -> dict:
|
||||||
"""构建新30号准则五板块利润表(含附注明细)"""
|
"""构建新30号准则五板块利润表(含附注明细)(账套隔离 2026-08-31)"""
|
||||||
blocks = []
|
blocks = []
|
||||||
total_net_profit = 0
|
total_net_profit = 0
|
||||||
all_items_have_data = True
|
all_items_have_data = True
|
||||||
@@ -613,7 +623,7 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
|
|||||||
block_has_data = False
|
block_has_data = False
|
||||||
|
|
||||||
for item_cfg in block_cfg["items"]:
|
for item_cfg in block_cfg["items"]:
|
||||||
amount = _get_subject_amount(db, item_cfg["code"], period)
|
amount = _get_subject_amount(db, item_cfg["code"], period, entity_id)
|
||||||
if amount is not None:
|
if amount is not None:
|
||||||
effective = amount * item_cfg["sign"]
|
effective = amount * item_cfg["sign"]
|
||||||
block_subtotal += effective
|
block_subtotal += effective
|
||||||
@@ -645,7 +655,7 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
|
|||||||
total_net_profit += block_subtotal
|
total_net_profit += block_subtotal
|
||||||
|
|
||||||
# 附注明细(对外法定报表披露要求)
|
# 附注明细(对外法定报表披露要求)
|
||||||
notes = _build_profit_notes(db, period, blocks, total_net_profit)
|
notes = _build_profit_notes(db, period, blocks, total_net_profit, entity_id)
|
||||||
|
|
||||||
# 合计行:净利润 = 一二三+四+五
|
# 合计行:净利润 = 一二三+四+五
|
||||||
return {
|
return {
|
||||||
@@ -661,10 +671,10 @@ def _build_new_format_profit(db: Session, period: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_profit_notes(db: Session, period: str, blocks: list, net_profit: float) -> dict:
|
def _build_profit_notes(db: Session, period: str, blocks: list, net_profit: float, entity_id: int = 1) -> dict:
|
||||||
"""利润表附注明细 — 收入/费用/财务费用拆解 + 板块勾稽 + 关键比率"""
|
"""利润表附注明细 — 收入/费用/财务费用拆解 + 板块勾稽 + 关键比率(账套隔离 2026-08-31)"""
|
||||||
def amt(code):
|
def amt(code):
|
||||||
return _get_subject_amount(db, code, period)
|
return _get_subject_amount(db, code, period, entity_id)
|
||||||
|
|
||||||
revenue_main = amt("6001")
|
revenue_main = amt("6001")
|
||||||
revenue_other = amt("6051")
|
revenue_other = amt("6051")
|
||||||
@@ -818,8 +828,9 @@ class MpmCalculateRequest(BaseModel):
|
|||||||
def mpm_calculate(
|
def mpm_calculate(
|
||||||
req: MpmCalculateRequest,
|
req: MpmCalculateRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""MPM管理层指标计算器 — 生成合规调节表"""
|
"""MPM管理层指标计算器 — 生成合规调节表(账套隔离 2026-08-31)"""
|
||||||
if req.period is None:
|
if req.period is None:
|
||||||
req.period = datetime.now().strftime("%Y-%m")
|
req.period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
@@ -828,12 +839,12 @@ def mpm_calculate(
|
|||||||
raise HTTPException(status_code=400, detail=f"不支持的指标类型: {req.indicator_type}")
|
raise HTTPException(status_code=400, detail=f"不支持的指标类型: {req.indicator_type}")
|
||||||
|
|
||||||
# 获取基准值:净利润
|
# 获取基准值:净利润
|
||||||
net_profit = _calc_new_net_profit(db, req.period)
|
net_profit = _calc_new_net_profit(db, req.period, entity_id)
|
||||||
if net_profit is None:
|
if net_profit is None:
|
||||||
net_profit = 0
|
net_profit = 0
|
||||||
|
|
||||||
# 经营现金流(自由现金流的基准)
|
# 经营现金流(自由现金流的基准)
|
||||||
operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period)
|
operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period, entity_id)
|
||||||
|
|
||||||
# 确定基准值
|
# 确定基准值
|
||||||
if req.indicator_type == "free_cash_flow":
|
if req.indicator_type == "free_cash_flow":
|
||||||
@@ -871,7 +882,7 @@ def mpm_calculate(
|
|||||||
|
|
||||||
# 尝试自动取值
|
# 尝试自动取值
|
||||||
if amount is None and checked:
|
if amount is None and checked:
|
||||||
amount = _get_adjustment_value(db, code, req.period)
|
amount = _get_adjustment_value(db, code, req.period, entity_id)
|
||||||
|
|
||||||
effective = round(amount * sign, 2) if amount is not None else None
|
effective = round(amount * sign, 2) if amount is not None else None
|
||||||
|
|
||||||
@@ -912,9 +923,10 @@ def mpm_calculate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_kpi_val(db: Session, code: str, period: str) -> Optional[float]:
|
def _get_kpi_val(db: Session, code: str, period: str, entity_id: int = 1) -> Optional[float]:
|
||||||
"""从KPI定义+值获取数值"""
|
"""从KPI定义+值获取数值(账套隔离 2026-08-31)"""
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
return None
|
return None
|
||||||
v = db.query(KPIValue).filter(
|
v = db.query(KPIValue).filter(
|
||||||
@@ -923,14 +935,14 @@ def _get_kpi_val(db: Session, code: str, period: str) -> Optional[float]:
|
|||||||
return float(v.actual_value) if v and v.actual_value is not None else None
|
return float(v.actual_value) if v and v.actual_value is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _calc_new_net_profit(db: Session, period: str) -> Optional[float]:
|
def _calc_new_net_profit(db: Session, period: str, entity_id: int = 1) -> Optional[float]:
|
||||||
"""计算新30号准则下的净利润"""
|
"""计算新30号准则下的净利润(账套隔离 2026-08-31)"""
|
||||||
total = 0
|
total = 0
|
||||||
has_data = False
|
has_data = False
|
||||||
for block_key in ["operating", "investing", "financing", "tax", "discontinued"]:
|
for block_key in ["operating", "investing", "financing", "tax", "discontinued"]:
|
||||||
block_cfg = BLOCK_INFO[block_key]
|
block_cfg = BLOCK_INFO[block_key]
|
||||||
for item_cfg in block_cfg["items"]:
|
for item_cfg in block_cfg["items"]:
|
||||||
amount = _get_subject_amount(db, item_cfg["code"], period)
|
amount = _get_subject_amount(db, item_cfg["code"], period, entity_id)
|
||||||
if amount is not None:
|
if amount is not None:
|
||||||
total += amount * item_cfg["sign"]
|
total += amount * item_cfg["sign"]
|
||||||
has_data = True
|
has_data = True
|
||||||
@@ -939,14 +951,14 @@ def _calc_new_net_profit(db: Session, period: str) -> Optional[float]:
|
|||||||
return round(total, 2)
|
return round(total, 2)
|
||||||
|
|
||||||
|
|
||||||
def _get_adjustment_value(db: Session, adj_code: str, period: str) -> Optional[float]:
|
def _get_adjustment_value(db: Session, adj_code: str, period: str, entity_id: int = 1) -> Optional[float]:
|
||||||
"""获取调整项的自动取值"""
|
"""获取调整项的自动取值(账套隔离 2026-08-31)"""
|
||||||
mapping = ADJUSTMENT_VALUE_MAP.get(adj_code)
|
mapping = ADJUSTMENT_VALUE_MAP.get(adj_code)
|
||||||
if mapping is None:
|
if mapping is None:
|
||||||
return None # 需要用户输入
|
return None # 需要用户输入
|
||||||
|
|
||||||
code = mapping["code"]
|
code = mapping["code"]
|
||||||
amount = _get_subject_amount(db, code, period)
|
amount = _get_subject_amount(db, code, period, entity_id)
|
||||||
if amount is None:
|
if amount is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -976,14 +988,16 @@ def _get_demo_block_total(block_key: str) -> float:
|
|||||||
def get_restatement(
|
def get_restatement(
|
||||||
period: str = Query(None, description="格式 YYYY-MM"),
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""2026年数据按新准则重述 — 旧口径vs新口径双列对比,自动标记调整项"""
|
"""2026年数据按新准则重述 — 旧口径vs新口径双列对比,自动标记调整项(账套隔离 2026-08-31)"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
# 旧口径数据 (传统利润表项目)
|
# 旧口径数据 (传统利润表项目)
|
||||||
def _old_kpi_val(code: str):
|
def _old_kpi_val(code: str):
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
return None
|
return None
|
||||||
v = db.query(KPIValue).filter(
|
v = db.query(KPIValue).filter(
|
||||||
@@ -1001,22 +1015,22 @@ def get_restatement(
|
|||||||
old_rd_exp = _old_kpi_val("F_RD_EXP")
|
old_rd_exp = _old_kpi_val("F_RD_EXP")
|
||||||
|
|
||||||
# 新口径数据 (从科目映射或kpi_values获取)
|
# 新口径数据 (从科目映射或kpi_values获取)
|
||||||
new_revenue = _get_subject_amount(db, "6001", period)
|
new_revenue = _get_subject_amount(db, "6001", period, entity_id)
|
||||||
new_revenue_other = _get_subject_amount(db, "6051", period)
|
new_revenue_other = _get_subject_amount(db, "6051", period, entity_id)
|
||||||
new_cost = _get_subject_amount(db, "6401", period)
|
new_cost = _get_subject_amount(db, "6401", period, entity_id)
|
||||||
new_cost_other = _get_subject_amount(db, "6402", period)
|
new_cost_other = _get_subject_amount(db, "6402", period, entity_id)
|
||||||
new_selling = _get_subject_amount(db, "6601", period)
|
new_selling = _get_subject_amount(db, "6601", period, entity_id)
|
||||||
new_admin = _get_subject_amount(db, "6602", period)
|
new_admin = _get_subject_amount(db, "6602", period, entity_id)
|
||||||
new_rd = _get_subject_amount(db, "660204", period)
|
new_rd = _get_subject_amount(db, "660204", period, entity_id)
|
||||||
new_interest_income = _get_subject_amount(db, "6011", period)
|
new_interest_income = _get_subject_amount(db, "6011", period, entity_id)
|
||||||
new_interest_exp = _get_subject_amount(db, "660301", period)
|
new_interest_exp = _get_subject_amount(db, "660301", period, entity_id)
|
||||||
new_fx = _get_subject_amount(db, "6603", period)
|
new_fx = _get_subject_amount(db, "6603", period, entity_id)
|
||||||
new_fx_financing = _get_subject_amount(db, "660302", period)
|
new_fx_financing = _get_subject_amount(db, "660302", period, entity_id)
|
||||||
new_invest_income = _get_subject_amount(db, "6111", period)
|
new_invest_income = _get_subject_amount(db, "6111", period, entity_id)
|
||||||
new_impairment = _get_subject_amount(db, "6701", period)
|
new_impairment = _get_subject_amount(db, "6701", period, entity_id)
|
||||||
new_invest_impairment = _get_subject_amount(db, "670101", period)
|
new_invest_impairment = _get_subject_amount(db, "670101", period, entity_id)
|
||||||
new_tax = _get_subject_amount(db, "6801", period)
|
new_tax = _get_subject_amount(db, "6801", period, entity_id)
|
||||||
new_discontinued = _get_subject_amount(db, "6901", period)
|
new_discontinued = _get_subject_amount(db, "6901", period, entity_id)
|
||||||
|
|
||||||
# 旧口径汇总计算
|
# 旧口径汇总计算
|
||||||
old_operating_items = [
|
old_operating_items = [
|
||||||
@@ -1251,7 +1265,7 @@ def get_restatement(
|
|||||||
total = 0
|
total = 0
|
||||||
for codes in [op_items, inv_items, fin_items, tax_items, dis_items]:
|
for codes in [op_items, inv_items, fin_items, tax_items, dis_items]:
|
||||||
for code in codes:
|
for code in codes:
|
||||||
amt = _get_subject_amount(db, code, period)
|
amt = _get_subject_amount(db, code, period, entity_id)
|
||||||
if amt is not None:
|
if amt is not None:
|
||||||
# 根据BLOCK_INFO中的sign处理
|
# 根据BLOCK_INFO中的sign处理
|
||||||
for bk in BLOCK_INFO.values():
|
for bk in BLOCK_INFO.values():
|
||||||
@@ -1284,7 +1298,9 @@ def get_restatement(
|
|||||||
def get_category_map(
|
def get_category_map(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""返回科目→新30号准则板块映射"""
|
"""返回科目→新30号准则板块映射
|
||||||
|
豁免多租户隔离(2026-08-31):Subject 为全局会计科目字典(无 entity_id 列),
|
||||||
|
返回的是科目分类映射常量,非企业业务数据,故不做 entity 过滤"""
|
||||||
subjects_data = db.query(Subject).filter(Subject.is_active == 1).order_by(Subject.subject_code).all()
|
subjects_data = db.query(Subject).filter(Subject.is_active == 1).order_by(Subject.subject_code).all()
|
||||||
|
|
||||||
map_list = []
|
map_list = []
|
||||||
@@ -1442,8 +1458,8 @@ def _prev_period_str(period: str) -> str:
|
|||||||
return period
|
return period
|
||||||
|
|
||||||
|
|
||||||
def _get_bs_amount(db: Session, codes: list, period: str) -> Optional[float]:
|
def _get_bs_amount(db: Session, codes: list, period: str, entity_id: int = 1) -> Optional[float]:
|
||||||
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None"""
|
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None(账套隔离 2026-08-31)"""
|
||||||
total = 0.0
|
total = 0.0
|
||||||
has_data = False
|
has_data = False
|
||||||
try:
|
try:
|
||||||
@@ -1463,9 +1479,9 @@ def _get_bs_amount(db: Session, codes: list, period: str) -> Optional[float]:
|
|||||||
return round(total, 2) if has_data else None
|
return round(total, 2) if has_data else None
|
||||||
|
|
||||||
|
|
||||||
def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end") -> dict:
|
def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end", entity_id: int = 1) -> dict:
|
||||||
"""单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)"""
|
"""单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)(账套隔离 2026-08-31)"""
|
||||||
real = _get_bs_amount(db, line["codes"], period)
|
real = _get_bs_amount(db, line["codes"], period, entity_id)
|
||||||
if real is not None:
|
if real is not None:
|
||||||
return {"value": real, "is_demo": False}
|
return {"value": real, "is_demo": False}
|
||||||
key = "|".join(c for c, _ in line["codes"])
|
key = "|".join(c for c, _ in line["codes"])
|
||||||
@@ -1479,8 +1495,9 @@ def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end") -
|
|||||||
def get_balance_sheet(
|
def get_balance_sheet(
|
||||||
period: str = Query(None, description="格式 YYYY-MM"),
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初"""
|
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初(账套隔离 2026-08-31)"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
period = datetime.now().strftime("%Y-%m")
|
||||||
prev_period = _prev_period_str(period)
|
prev_period = _prev_period_str(period)
|
||||||
@@ -1496,8 +1513,8 @@ def get_balance_sheet(
|
|||||||
sec_end = sec_begin = 0.0
|
sec_end = sec_begin = 0.0
|
||||||
sec_real = False
|
sec_real = False
|
||||||
for line in sec["lines"]:
|
for line in sec["lines"]:
|
||||||
end = _bs_line_amount(db, line, period, column="end")
|
end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id)
|
||||||
begin = _bs_line_amount(db, line, prev_period, column="begin")
|
begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id)
|
||||||
if end["is_demo"] or begin["is_demo"]:
|
if end["is_demo"] or begin["is_demo"]:
|
||||||
all_real = False
|
all_real = False
|
||||||
if end["value"] is not None:
|
if end["value"] is not None:
|
||||||
@@ -1587,14 +1604,14 @@ CF_DEMO_FX = 0 # 汇率变动对现金的影响
|
|||||||
CF_DEMO_BEGIN = 1200 # 期初现金及现金等价物余额
|
CF_DEMO_BEGIN = 1200 # 期初现金及现金等价物余额
|
||||||
|
|
||||||
|
|
||||||
def _get_cf_amount(db: Session, line: dict, period: str) -> dict:
|
def _get_cf_amount(db: Session, line: dict, period: str, entity_id: int = 1) -> dict:
|
||||||
"""现金流量表行项目 — 优先KPI/凭证,否则示例数据"""
|
"""现金流量表行项目 — 优先KPI/凭证,否则示例数据(账套隔离 2026-08-31)"""
|
||||||
# 经营净额行特殊处理:优先取 F_OP_CFLOW
|
# 经营净额行特殊处理:优先取 F_OP_CFLOW
|
||||||
if line.get("kpi_code"):
|
if line.get("kpi_code"):
|
||||||
kpi_val = _get_kpi_val(db, line["kpi_code"], period)
|
kpi_val = _get_kpi_val(db, line["kpi_code"], period, entity_id)
|
||||||
if kpi_val is not None:
|
if kpi_val is not None:
|
||||||
return {"value": round(kpi_val, 2), "is_demo": False}
|
return {"value": round(kpi_val, 2), "is_demo": False}
|
||||||
real = _get_bs_amount(db, [(line["code"], line["sign"])], period)
|
real = _get_bs_amount(db, [(line["code"], line["sign"])], period, entity_id)
|
||||||
if real is not None:
|
if real is not None:
|
||||||
return {"value": real, "is_demo": False}
|
return {"value": real, "is_demo": False}
|
||||||
demo = CF_DEMO.get(line["code"])
|
demo = CF_DEMO.get(line["code"])
|
||||||
@@ -1607,8 +1624,9 @@ def _get_cf_amount(db: Session, line: dict, period: str) -> dict:
|
|||||||
def get_cash_flow_statement(
|
def get_cash_flow_statement(
|
||||||
period: str = Query(None, description="格式 YYYY-MM"),
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""现金流量表 — 经营/投资/筹资三活动(新30号准则直接法)"""
|
"""现金流量表 — 经营/投资/筹资三活动(新30号准则直接法)(账套隔离 2026-08-31)"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
@@ -1629,7 +1647,7 @@ def get_cash_flow_statement(
|
|||||||
for line in CASH_FLOW_LINES:
|
for line in CASH_FLOW_LINES:
|
||||||
if line["section"] != sc["key"]:
|
if line["section"] != sc["key"]:
|
||||||
continue
|
continue
|
||||||
v = _get_cf_amount(db, line, period)
|
v = _get_cf_amount(db, line, period, entity_id)
|
||||||
if v["is_demo"]:
|
if v["is_demo"]:
|
||||||
all_real = False
|
all_real = False
|
||||||
if v["value"] is not None:
|
if v["value"] is not None:
|
||||||
@@ -1652,12 +1670,12 @@ def get_cash_flow_statement(
|
|||||||
})
|
})
|
||||||
|
|
||||||
# 经营净额行优先取 KPI F_OP_CFLOW(真实数据优先)
|
# 经营净额行优先取 KPI F_OP_CFLOW(真实数据优先)
|
||||||
op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period)
|
op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period, entity_id)
|
||||||
if op_kpi is not None:
|
if op_kpi is not None:
|
||||||
sections[0]["net"] = round(op_kpi, 2)
|
sections[0]["net"] = round(op_kpi, 2)
|
||||||
net_by_section["operating"] = round(op_kpi, 2)
|
net_by_section["operating"] = round(op_kpi, 2)
|
||||||
|
|
||||||
fx = _get_kpi_val(db, "F_FX_LOSS", period)
|
fx = _get_kpi_val(db, "F_FX_LOSS", period, entity_id)
|
||||||
if fx is None:
|
if fx is None:
|
||||||
fx = CF_DEMO_FX
|
fx = CF_DEMO_FX
|
||||||
fx_demo = True
|
fx_demo = True
|
||||||
@@ -1689,6 +1707,7 @@ def get_cash_flow_statement(
|
|||||||
def get_statutory_reports(
|
def get_statutory_reports(
|
||||||
period: str = Query(None, description="格式 YYYY-MM"),
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""对外法定报表(新30号准则)— 利润表+资产负债表+现金流量表 组合视图"""
|
"""对外法定报表(新30号准则)— 利润表+资产负债表+现金流量表 组合视图"""
|
||||||
if period is None:
|
if period is None:
|
||||||
@@ -1696,9 +1715,9 @@ def get_statutory_reports(
|
|||||||
return {
|
return {
|
||||||
"period": period,
|
"period": period,
|
||||||
"title": f"对外法定报表 — 新30号准则({period})",
|
"title": f"对外法定报表 — 新30号准则({period})",
|
||||||
"profit": _build_new_format_profit(db, period),
|
"profit": _build_new_format_profit(db, period, entity_id),
|
||||||
"balance_sheet": get_balance_sheet(period=period, db=db),
|
"balance_sheet": get_balance_sheet(period=period, db=db, entity_id=entity_id),
|
||||||
"cash_flow": get_cash_flow_statement(period=period, db=db),
|
"cash_flow": get_cash_flow_statement(period=period, db=db, entity_id=entity_id),
|
||||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,11 +1726,12 @@ def get_statutory_reports(
|
|||||||
def export_statutory_reports(
|
def export_statutory_reports(
|
||||||
period: str = Query(None, description="格式 YYYY-MM"),
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""导出对外法定报表(新30号准则)— Excel 三表合一"""
|
"""导出对外法定报表(新30号准则)— Excel 三表合一"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
period = datetime.now().strftime("%Y-%m")
|
||||||
data = get_statutory_reports(period=period, db=db)
|
data = get_statutory_reports(period=period, db=db, entity_id=entity_id)
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
@@ -1868,7 +1888,11 @@ def get_dupont_analysis(
|
|||||||
entity: str = Query("bohai"),
|
entity: str = Query("bohai"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""杜邦分析 — ROE三级拆解 (CMA P2)"""
|
"""杜邦分析 — ROE三级拆解 (CMA P2)
|
||||||
|
豁免多租户隔离(2026-08-31):跨实体对比分析端点,entity 参数显式指定
|
||||||
|
分析对象(bohai→entity 2 / hanke→entity 1),非默认全库查询,故不叠加
|
||||||
|
Depends(get_entity_id)(叠加会导致 token 绑定的 entity 与显式 entity 参数
|
||||||
|
不一致时被 403 拦截,破坏跨企业对比功能)"""
|
||||||
if entity == "bohai":
|
if entity == "bohai":
|
||||||
# 博海标准KPI(F_REVENUE/F_NET_PROFIT)无verified值 → 优先DB读,读不到回退文档确认常量
|
# 博海标准KPI(F_REVENUE/F_NET_PROFIT)无verified值 → 优先DB读,读不到回退文档确认常量
|
||||||
net_profit = _get_dupont_kpi(db, 2, "F_NET_PROFIT")
|
net_profit = _get_dupont_kpi(db, 2, "F_NET_PROFIT")
|
||||||
@@ -2059,9 +2083,10 @@ def _get_month_period_prefix(period: str) -> str:
|
|||||||
return f"{y}-{m:02d}"
|
return f"{y}-{m:02d}"
|
||||||
|
|
||||||
|
|
||||||
def _fetch_kpi_data(db: Session) -> list:
|
def _fetch_kpi_data(db: Session, entity_id: int = 1) -> list:
|
||||||
"""获取所有活跃KPI的当前值、目标值、维度、预警"""
|
"""获取当前企业所有活跃KPI的当前值、目标值、维度、预警(账套隔离 2026-08-31)"""
|
||||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||||
result = []
|
result = []
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
@@ -2070,6 +2095,7 @@ def _fetch_kpi_data(db: Session) -> list:
|
|||||||
).order_by(KPIValue.period.desc()).first()
|
).order_by(KPIValue.period.desc()).first()
|
||||||
|
|
||||||
alerts = db.query(KPIAlert).filter(
|
alerts = db.query(KPIAlert).filter(
|
||||||
|
KPIAlert.entity_id == entity_id,
|
||||||
KPIAlert.kpi_id == k.id,
|
KPIAlert.kpi_id == k.id,
|
||||||
KPIAlert.status == "pending",
|
KPIAlert.status == "pending",
|
||||||
).order_by(KPIAlert.created_at.desc()).all()
|
).order_by(KPIAlert.created_at.desc()).all()
|
||||||
@@ -2093,9 +2119,9 @@ def _fetch_kpi_data(db: Session) -> list:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _build_weekly_report(db: Session, period: str) -> dict:
|
def _build_weekly_report(db: Session, period: str, entity_id: int = 1) -> dict:
|
||||||
"""生成周报"""
|
"""生成周报(账套隔离 2026-08-31)"""
|
||||||
kpis = _fetch_kpi_data(db)
|
kpis = _fetch_kpi_data(db, entity_id)
|
||||||
monday, sunday = _calc_week_range(period)
|
monday, sunday = _calc_week_range(period)
|
||||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
@@ -2103,6 +2129,7 @@ def _build_weekly_report(db: Session, period: str) -> dict:
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
seven_days_ago = datetime.now() - timedelta(days=7)
|
seven_days_ago = datetime.now() - timedelta(days=7)
|
||||||
recent_alerts = db.query(KPIAlert).filter(
|
recent_alerts = db.query(KPIAlert).filter(
|
||||||
|
KPIAlert.entity_id == entity_id,
|
||||||
KPIAlert.created_at >= seven_days_ago,
|
KPIAlert.created_at >= seven_days_ago,
|
||||||
KPIAlert.status == "pending",
|
KPIAlert.status == "pending",
|
||||||
).order_by(KPIAlert.created_at.desc()).all()
|
).order_by(KPIAlert.created_at.desc()).all()
|
||||||
@@ -2188,6 +2215,7 @@ def _build_weekly_report(db: Session, period: str) -> dict:
|
|||||||
"## 四、改进行动",
|
"## 四、改进行动",
|
||||||
])
|
])
|
||||||
actions = db.query(ActionPlan).filter(
|
actions = db.query(ActionPlan).filter(
|
||||||
|
ActionPlan.entity_id == entity_id,
|
||||||
ActionPlan.status.in_(["pending", "in_progress"]),
|
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||||
).order_by(ActionPlan.created_at.desc()).limit(5).all()
|
).order_by(ActionPlan.created_at.desc()).limit(5).all()
|
||||||
if actions:
|
if actions:
|
||||||
@@ -2247,9 +2275,9 @@ def _build_weekly_report(db: Session, period: str) -> dict:
|
|||||||
return {"markdown": markdown, "json": json_data, "title": f"经营分析周报 {monday}~{sunday}"}
|
return {"markdown": markdown, "json": json_data, "title": f"经营分析周报 {monday}~{sunday}"}
|
||||||
|
|
||||||
|
|
||||||
def _build_monthly_report(db: Session, period: str) -> dict:
|
def _build_monthly_report(db: Session, period: str, entity_id: int = 1) -> dict:
|
||||||
"""生成月报"""
|
"""生成月报(账套隔离 2026-08-31)"""
|
||||||
kpis = _fetch_kpi_data(db)
|
kpis = _fetch_kpi_data(db, entity_id)
|
||||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
prev_period = _get_month_period_prefix(period)
|
prev_period = _get_month_period_prefix(period)
|
||||||
|
|
||||||
@@ -2286,6 +2314,7 @@ def _build_monthly_report(db: Session, period: str) -> dict:
|
|||||||
|
|
||||||
# 预警汇总
|
# 预警汇总
|
||||||
pending_alerts = db.query(KPIAlert).filter(
|
pending_alerts = db.query(KPIAlert).filter(
|
||||||
|
KPIAlert.entity_id == entity_id,
|
||||||
KPIAlert.status == "pending",
|
KPIAlert.status == "pending",
|
||||||
).all()
|
).all()
|
||||||
red_count = sum(1 for a in pending_alerts if a.alert_level == "red")
|
red_count = sum(1 for a in pending_alerts if a.alert_level == "red")
|
||||||
@@ -2308,7 +2337,7 @@ def _build_monthly_report(db: Session, period: str) -> dict:
|
|||||||
dim_summary[d]["failed"] += 1
|
dim_summary[d]["failed"] += 1
|
||||||
|
|
||||||
# 改善行动
|
# 改善行动
|
||||||
actions = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(5).all()
|
actions = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).order_by(ActionPlan.created_at.desc()).limit(5).all()
|
||||||
|
|
||||||
# ── 生成 Markdown ──
|
# ── 生成 Markdown ──
|
||||||
md_lines = [
|
md_lines = [
|
||||||
@@ -2419,9 +2448,9 @@ def _build_monthly_report(db: Session, period: str) -> dict:
|
|||||||
return {"markdown": markdown, "json": json_data, "title": f"经营分析月报 {period}"}
|
return {"markdown": markdown, "json": json_data, "title": f"经营分析月报 {period}"}
|
||||||
|
|
||||||
|
|
||||||
def _build_special_report(db: Session, period: str, alert_ref: str = None) -> dict:
|
def _build_special_report(db: Session, period: str, alert_ref: str = None, entity_id: int = 1) -> dict:
|
||||||
"""生成专项分析报告 — 聚焦KPI异常"""
|
"""生成专项分析报告 — 聚焦KPI异常(账套隔离 2026-08-31)"""
|
||||||
kpis = _fetch_kpi_data(db)
|
kpis = _fetch_kpi_data(db, entity_id)
|
||||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
# 按偏差率排序(当前值/目标值)
|
# 按偏差率排序(当前值/目标值)
|
||||||
@@ -2596,6 +2625,7 @@ def generate_report(
|
|||||||
req: GenerateReportRequest,
|
req: GenerateReportRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""生成经营分析报告(周报/月报/专项),返回markdown+JSON
|
"""生成经营分析报告(周报/月报/专项),返回markdown+JSON
|
||||||
|
|
||||||
@@ -2613,9 +2643,9 @@ def generate_report(
|
|||||||
|
|
||||||
# 生成报告
|
# 生成报告
|
||||||
builders = {
|
builders = {
|
||||||
"weekly": lambda db, period: _build_weekly_report(db, period),
|
"weekly": lambda db, period: _build_weekly_report(db, period, entity_id),
|
||||||
"monthly": lambda db, period: _build_monthly_report(db, period),
|
"monthly": lambda db, period: _build_monthly_report(db, period, entity_id),
|
||||||
"special": lambda db, period: _build_special_report(db, period, alert_ref=req.alert_ref),
|
"special": lambda db, period: _build_special_report(db, period, alert_ref=req.alert_ref, entity_id=entity_id),
|
||||||
}
|
}
|
||||||
builder = builders[req.report_type]
|
builder = builders[req.report_type]
|
||||||
|
|
||||||
@@ -2625,8 +2655,9 @@ def generate_report(
|
|||||||
logger.error(f"报告生成异常: {e}", exc_info=True)
|
logger.error(f"报告生成异常: {e}", exc_info=True)
|
||||||
raise HTTPException(500, f"报告生成失败: {str(e)}")
|
raise HTTPException(500, f"报告生成失败: {str(e)}")
|
||||||
|
|
||||||
# 保存到数据库
|
# 保存到数据库(账套隔离 2026-08-31)
|
||||||
record = ReportHistory(
|
record = ReportHistory(
|
||||||
|
entity_id=entity_id,
|
||||||
report_type=req.report_type,
|
report_type=req.report_type,
|
||||||
period=period,
|
period=period,
|
||||||
title=report_data["title"],
|
title=report_data["title"],
|
||||||
@@ -2671,9 +2702,10 @@ def list_report_history(
|
|||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""查看报告生成历史"""
|
"""查看报告生成历史(账套隔离 2026-08-31)"""
|
||||||
query = db.query(ReportHistory).order_by(ReportHistory.created_at.desc())
|
query = db.query(ReportHistory).filter(ReportHistory.entity_id == entity_id).order_by(ReportHistory.created_at.desc())
|
||||||
if report_type:
|
if report_type:
|
||||||
query = query.filter(ReportHistory.report_type == report_type)
|
query = query.filter(ReportHistory.report_type == report_type)
|
||||||
records = query.limit(limit).all()
|
records = query.limit(limit).all()
|
||||||
@@ -2700,9 +2732,10 @@ def get_report_detail(
|
|||||||
report_id: int,
|
report_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""获取单条报告详情(含完整markdown内容)"""
|
"""获取单条报告详情(含完整markdown内容,账套隔离 2026-08-31)"""
|
||||||
r = db.query(ReportHistory).filter(ReportHistory.id == report_id).first()
|
r = db.query(ReportHistory).filter(ReportHistory.id == report_id, ReportHistory.entity_id == entity_id).first()
|
||||||
if not r:
|
if not r:
|
||||||
raise HTTPException(404, "报告不存在")
|
raise HTTPException(404, "报告不存在")
|
||||||
|
|
||||||
@@ -2718,3 +2751,371 @@ def get_report_detail(
|
|||||||
"json": r.json_content,
|
"json": r.json_content,
|
||||||
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
|
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 预编报表(预算版三张报表)— P2 2026-08-30
|
||||||
|
# 用 budget_plans 预算数据 + 现有报表模板,生成预算版
|
||||||
|
# 利润表 / 资产负债表 / 现金流量表,供高层拍板预算方案。
|
||||||
|
# 差异口径与 budget-execution 一致(calc_deviation: 实际-预算)。
|
||||||
|
# 无预算映射的行 has_budget=false 显式标注,不静默丢弃、不塞 demo 数据。
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 利润表科目编码 → KPI 编码(与 _get_subject_amount 内 kpi_code_map 一致)
|
||||||
|
PROFIT_SUBJECT_KPI_MAP = {
|
||||||
|
"6001": "F_REVENUE",
|
||||||
|
"6051": "F_REVENUE_OTHER",
|
||||||
|
"6401": "F_COST",
|
||||||
|
"6402": "F_COST_OTHER",
|
||||||
|
"6601": "F_SELLING_EXP",
|
||||||
|
"6602": "F_ADMIN_EXP",
|
||||||
|
"660204": "F_RD_EXP",
|
||||||
|
"6603": "F_FINANCE_EXP",
|
||||||
|
"6701": "F_IMPAIRMENT_LOSS",
|
||||||
|
"6011": "F_INTEREST_INCOME",
|
||||||
|
"6111": "F_INVEST_INCOME",
|
||||||
|
"611101": "F_INVEST_INCOME",
|
||||||
|
"660301": "F_INTEREST_EXP",
|
||||||
|
"660302": "F_FX_LOSS",
|
||||||
|
"6801": "F_TAX_EXP",
|
||||||
|
"6901": "F_DISCONTINUED",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 资产负债表行项目(科目组合 key 以 "|" 连接,与 _bs_line_amount 一致)→ KPI 映射
|
||||||
|
# ratio_kpi=true 表示该KPI为比率/天数型,预算值与金额不可直接比较,需单独展示
|
||||||
|
BALANCE_SHEET_PROFORMA_MAP = {
|
||||||
|
"1001|1002|1012": {
|
||||||
|
"kpi_code": "F_OP_CFLOW", "ratio_kpi": False,
|
||||||
|
"note": "货币资金以经营性现金流预算近似(无直接科目预算)",
|
||||||
|
},
|
||||||
|
"1122": {
|
||||||
|
"kpi_code": "F_AR_DAYS", "ratio_kpi": True,
|
||||||
|
"note": "比率型KPI(应收账款周转天数),预算为天数指标,与金额不可直接比较,需单独展示",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 现金流量表行项目 → KPI 映射(CF行无直接预算,用金额KPI近似;经营净额走 F_OP_CFLOW)
|
||||||
|
CASH_FLOW_PROFORMA_MAP = {
|
||||||
|
"CF01": {"kpi_code": "F_REVENUE", "note": "销售商品收到的现金以营业收入预算近似"},
|
||||||
|
"CF04": {"kpi_code": "F_COST", "note": "购买商品支付的现金以营业成本预算近似"},
|
||||||
|
"CF05": {"kpi_code": "F_ADMIN_EXP", "note": "支付给职工的现金以管理费用预算近似"},
|
||||||
|
"CF06": {"kpi_code": "F_TAX_EXP", "note": "支付的各项税费以所得税费用预算近似"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_kpi_by_code(db: Session, kpi_code: Optional[str], entity_id: int):
|
||||||
|
"""按 entity + kpi_code 查 KPI(proforma 专用,带租户隔离,兼容历史 NULL entity 行)"""
|
||||||
|
if not kpi_code:
|
||||||
|
return None
|
||||||
|
return db.query(KPIDefinition).filter(
|
||||||
|
or_(KPIDefinition.entity_id == entity_id, KPIDefinition.entity_id.is_(None)),
|
||||||
|
KPIDefinition.kpi_code == kpi_code,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _proforma_budget(db: Session, kpi_id: int, period: str, version: Optional[str] = None, entity_id: int = 1):
|
||||||
|
"""预编报表预算取数:budget_plan → target_split → none
|
||||||
|
|
||||||
|
与 calc_period_deviation 口径一致(无预算时用 KPI 目标值按月分摊)。
|
||||||
|
返回 (budget_value, budget_source, budget_version)
|
||||||
|
"""
|
||||||
|
budget = get_budget_for_kpi(db, kpi_id, period, version)
|
||||||
|
if budget is not None:
|
||||||
|
query = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.kpi_id == kpi_id,
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
)
|
||||||
|
if version:
|
||||||
|
query = query.filter(BudgetPlan.version == version)
|
||||||
|
plan = query.order_by(BudgetPlan.updated_at.desc()).first()
|
||||||
|
return round(float(budget), 2), "budget_plan", (plan.version if plan else version)
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
|
if kpi:
|
||||||
|
try:
|
||||||
|
month = int(period.split("-")[1])
|
||||||
|
except Exception:
|
||||||
|
month = 1
|
||||||
|
target = kpi.target_value
|
||||||
|
if target and target > 0 and kpi.frequency == "monthly":
|
||||||
|
return round(float(target) / 12, 2), "target_split", None
|
||||||
|
return None, "none", None
|
||||||
|
|
||||||
|
|
||||||
|
def _proforma_deviation(actual: Optional[float], budget: Optional[float], ratio_kpi: bool = False) -> dict:
|
||||||
|
"""差异三列 — 口径与 calc_period_deviation 一致(实际-预算,实际为空不计算);
|
||||||
|
比率型KPI不计算金额差异"""
|
||||||
|
if ratio_kpi or actual is None:
|
||||||
|
return {"deviation_amount": None, "deviation_rate": None, "is_over_budget": None}
|
||||||
|
return calc_deviation(actual, budget)
|
||||||
|
|
||||||
|
|
||||||
|
def _proforma_cf_actual(db: Session, line: dict, period: str, entity_id: int = 1) -> Optional[float]:
|
||||||
|
"""现金流量表行项目实际值 — 真实数据优先(KPI → 凭证),不塞 demo 数据(账套隔离 2026-08-31)"""
|
||||||
|
if line.get("kpi_code"):
|
||||||
|
v = _get_kpi_val(db, line["kpi_code"], period)
|
||||||
|
if v is not None:
|
||||||
|
return float(v)
|
||||||
|
v = _get_bs_amount(db, [(line["code"], line["sign"])], period)
|
||||||
|
if v is not None:
|
||||||
|
return float(v)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _proforma_versions(found_versions: set):
|
||||||
|
"""budget_version 输出:单一版本→字符串,多版本→列表,无→None"""
|
||||||
|
if not found_versions:
|
||||||
|
return None
|
||||||
|
vs = sorted(found_versions)
|
||||||
|
return vs[0] if len(vs) == 1 else vs
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/proforma/profit-statement")
|
||||||
|
def get_proforma_profit_statement(
|
||||||
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
|
version: Optional[str] = Query(None, description="预算版本,默认取最新active"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""预算版利润表 — 新30号准则五板块结构,每行叠加 预算值/实际值/差异"""
|
||||||
|
if period is None:
|
||||||
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
net_actual = net_budget = 0.0
|
||||||
|
found_versions = set()
|
||||||
|
|
||||||
|
for block_key in ["operating", "investing", "financing", "tax", "discontinued"]:
|
||||||
|
block_cfg = BLOCK_INFO[block_key]
|
||||||
|
items = []
|
||||||
|
block_actual = block_budget = 0.0
|
||||||
|
block_has_budget = False
|
||||||
|
for item_cfg in block_cfg["items"]:
|
||||||
|
code = item_cfg["code"]
|
||||||
|
kpi_code = PROFIT_SUBJECT_KPI_MAP.get(code)
|
||||||
|
actual = _get_subject_amount(db, code, period, entity_id)
|
||||||
|
budget, source, ver = None, "none", None
|
||||||
|
kpi = _find_kpi_by_code(db, kpi_code, entity_id) if kpi_code else None
|
||||||
|
if kpi:
|
||||||
|
budget, source, ver = _proforma_budget(db, kpi.id, period, version)
|
||||||
|
has_budget = budget is not None
|
||||||
|
if has_budget:
|
||||||
|
block_has_budget = True
|
||||||
|
if ver:
|
||||||
|
found_versions.add(ver)
|
||||||
|
if actual is not None:
|
||||||
|
block_actual += actual * item_cfg["sign"]
|
||||||
|
if budget is not None:
|
||||||
|
block_budget += budget * item_cfg["sign"]
|
||||||
|
dev = _proforma_deviation(actual, budget)
|
||||||
|
items.append({
|
||||||
|
"code": code,
|
||||||
|
"name": item_cfg["name"],
|
||||||
|
"sign": item_cfg["sign"],
|
||||||
|
"actual_value": round(actual, 2) if actual is not None else None,
|
||||||
|
"budget_value": budget,
|
||||||
|
"deviation_amount": dev.get("deviation_amount"),
|
||||||
|
"deviation_rate": dev.get("deviation_rate"),
|
||||||
|
"has_budget": has_budget,
|
||||||
|
"mapped_kpi_code": kpi_code,
|
||||||
|
"budget_source": source,
|
||||||
|
"ratio_kpi": False,
|
||||||
|
"note": None,
|
||||||
|
})
|
||||||
|
blocks.append({
|
||||||
|
"key": block_key,
|
||||||
|
"name": block_cfg["name"],
|
||||||
|
"short_name": block_cfg["short_name"],
|
||||||
|
"subtotal_actual": round(block_actual, 2),
|
||||||
|
"subtotal_budget": round(block_budget, 2),
|
||||||
|
"subtotal_name": block_cfg["result_name"],
|
||||||
|
"has_budget": block_has_budget,
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
net_actual += block_actual
|
||||||
|
net_budget += block_budget
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"budget_version": _proforma_versions(found_versions),
|
||||||
|
"requested_version": version,
|
||||||
|
"title": f"预算版利润表 — 新30号准则({period})",
|
||||||
|
"blocks": blocks,
|
||||||
|
"net_profit_actual": round(net_actual, 2),
|
||||||
|
"net_profit_budget": round(net_budget, 2),
|
||||||
|
"budget_source_hint": "budget_plan=预算方案 / target_split=KPI目标值按月分摊 / none=无预算",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/proforma/balance-sheet")
|
||||||
|
def get_proforma_balance_sheet(
|
||||||
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
|
version: Optional[str] = Query(None, description="预算版本,默认取最新active"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""预算版资产负债表 — 复用 BALANCE_SHEET_SECTIONS,每行叠加 预算值/实际值/差异"""
|
||||||
|
if period is None:
|
||||||
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
found_versions = set()
|
||||||
|
|
||||||
|
for sec in BALANCE_SHEET_SECTIONS:
|
||||||
|
lines = []
|
||||||
|
sec_actual = sec_budget = 0.0
|
||||||
|
sec_has_budget = False
|
||||||
|
for line in sec["lines"]:
|
||||||
|
key = "|".join(c for c, _ in line["codes"])
|
||||||
|
map_cfg = BALANCE_SHEET_PROFORMA_MAP.get(key) or {}
|
||||||
|
kpi_code = map_cfg.get("kpi_code")
|
||||||
|
ratio_kpi = map_cfg.get("ratio_kpi", False)
|
||||||
|
note = map_cfg.get("note")
|
||||||
|
# 实际值:真实凭证数据(预编报表不塞 demo 示例数据)
|
||||||
|
actual = _get_bs_amount(db, line["codes"], period)
|
||||||
|
budget, source, ver = None, "none", None
|
||||||
|
kpi = _find_kpi_by_code(db, kpi_code, entity_id) if kpi_code else None
|
||||||
|
if kpi:
|
||||||
|
budget, source, ver = _proforma_budget(db, kpi.id, period, version)
|
||||||
|
has_budget = budget is not None
|
||||||
|
if has_budget:
|
||||||
|
sec_has_budget = True
|
||||||
|
if ver:
|
||||||
|
found_versions.add(ver)
|
||||||
|
if actual is not None:
|
||||||
|
sec_actual += actual
|
||||||
|
if budget is not None and not ratio_kpi:
|
||||||
|
sec_budget += budget
|
||||||
|
dev = _proforma_deviation(actual, budget, ratio_kpi)
|
||||||
|
lines.append({
|
||||||
|
"name": line["name"],
|
||||||
|
"ns_category": line["ns_category"],
|
||||||
|
"ns_category_label": BS_CATEGORY_CN.get(line["ns_category"], line["ns_category"]),
|
||||||
|
"actual_value": round(actual, 2) if actual is not None else None,
|
||||||
|
"budget_value": budget,
|
||||||
|
"deviation_amount": dev.get("deviation_amount"),
|
||||||
|
"deviation_rate": dev.get("deviation_rate"),
|
||||||
|
"has_budget": has_budget,
|
||||||
|
"mapped_kpi_code": kpi_code,
|
||||||
|
"ratio_kpi": ratio_kpi,
|
||||||
|
"budget_source": source,
|
||||||
|
"note": note,
|
||||||
|
})
|
||||||
|
sections.append({
|
||||||
|
"key": sec["key"],
|
||||||
|
"name": sec["name"],
|
||||||
|
"category_label": sec["category_label"],
|
||||||
|
"subtotal_actual": round(sec_actual, 2),
|
||||||
|
"subtotal_budget": round(sec_budget, 2),
|
||||||
|
"has_budget": sec_has_budget,
|
||||||
|
"lines": lines,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"budget_version": _proforma_versions(found_versions),
|
||||||
|
"requested_version": version,
|
||||||
|
"title": f"预算版资产负债表({period})",
|
||||||
|
"sections": sections,
|
||||||
|
"budget_source_hint": "budget_plan=预算方案 / target_split=KPI目标值按月分摊 / none=无预算",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/proforma/cash-flow")
|
||||||
|
def get_proforma_cash_flow(
|
||||||
|
period: str = Query(None, description="格式 YYYY-MM"),
|
||||||
|
version: Optional[str] = Query(None, description="预算版本,默认取最新active"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""预算版现金流量表 — 复用 CASH_FLOW_LINES,每行叠加 预算值/实际值/差异"""
|
||||||
|
if period is None:
|
||||||
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
section_cfg = [
|
||||||
|
{"key": "operating", "name": "一、经营活动产生的现金流量", "short": "经营活动"},
|
||||||
|
{"key": "investing", "name": "二、投资活动产生的现金流量", "short": "投资活动"},
|
||||||
|
{"key": "financing", "name": "三、筹资活动产生的现金流量", "short": "筹资活动"},
|
||||||
|
]
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
found_versions = set()
|
||||||
|
|
||||||
|
for sc in section_cfg:
|
||||||
|
lines = []
|
||||||
|
subtotal_actual = subtotal_budget = 0.0
|
||||||
|
sec_has_budget = False
|
||||||
|
for line in CASH_FLOW_LINES:
|
||||||
|
if line["section"] != sc["key"]:
|
||||||
|
continue
|
||||||
|
map_cfg = CASH_FLOW_PROFORMA_MAP.get(line["code"]) or {}
|
||||||
|
kpi_code = map_cfg.get("kpi_code")
|
||||||
|
note = map_cfg.get("note")
|
||||||
|
actual = _proforma_cf_actual(db, line, period)
|
||||||
|
budget, source, ver = None, "none", None
|
||||||
|
kpi = _find_kpi_by_code(db, kpi_code, entity_id) if kpi_code else None
|
||||||
|
if kpi:
|
||||||
|
budget, source, ver = _proforma_budget(db, kpi.id, period, version)
|
||||||
|
has_budget = budget is not None
|
||||||
|
if has_budget:
|
||||||
|
sec_has_budget = True
|
||||||
|
if ver:
|
||||||
|
found_versions.add(ver)
|
||||||
|
if actual is not None:
|
||||||
|
subtotal_actual += actual
|
||||||
|
if budget is not None:
|
||||||
|
subtotal_budget += budget
|
||||||
|
dev = _proforma_deviation(actual, budget)
|
||||||
|
lines.append({
|
||||||
|
"code": line["code"],
|
||||||
|
"name": line["name"],
|
||||||
|
"actual_value": round(actual, 2) if actual is not None else None,
|
||||||
|
"budget_value": budget,
|
||||||
|
"deviation_amount": dev.get("deviation_amount"),
|
||||||
|
"deviation_rate": dev.get("deviation_rate"),
|
||||||
|
"has_budget": has_budget,
|
||||||
|
"mapped_kpi_code": kpi_code,
|
||||||
|
"ratio_kpi": False,
|
||||||
|
"budget_source": source,
|
||||||
|
"note": note,
|
||||||
|
})
|
||||||
|
# 经营净额:优先取 F_OP_CFLOW(真实),预算取 F_OP_CFLOW 预算
|
||||||
|
net_actual = subtotal_actual
|
||||||
|
net_budget = subtotal_budget
|
||||||
|
if sc["key"] == "operating":
|
||||||
|
op_kpi = _find_kpi_by_code(db, "F_OP_CFLOW", entity_id)
|
||||||
|
if op_kpi:
|
||||||
|
op_actual = _get_kpi_val(db, "F_OP_CFLOW", period, entity_id)
|
||||||
|
if op_actual is not None:
|
||||||
|
net_actual = round(float(op_actual), 2)
|
||||||
|
op_budget, op_source, op_ver = _proforma_budget(db, op_kpi.id, period, version)
|
||||||
|
if op_budget is not None:
|
||||||
|
net_budget = op_budget
|
||||||
|
sec_has_budget = True
|
||||||
|
if op_ver:
|
||||||
|
found_versions.add(op_ver)
|
||||||
|
sections.append({
|
||||||
|
"key": sc["key"],
|
||||||
|
"name": sc["name"],
|
||||||
|
"short": sc["short"],
|
||||||
|
"net_actual": round(net_actual, 2),
|
||||||
|
"net_budget": round(net_budget, 2),
|
||||||
|
"has_budget": sec_has_budget,
|
||||||
|
"lines": lines,
|
||||||
|
})
|
||||||
|
|
||||||
|
net_increase_actual = round(sum(s["net_actual"] for s in sections), 2)
|
||||||
|
net_increase_budget = round(sum(s["net_budget"] for s in sections), 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"budget_version": _proforma_versions(found_versions),
|
||||||
|
"requested_version": version,
|
||||||
|
"title": f"预算版现金流量表({period})",
|
||||||
|
"sections": sections,
|
||||||
|
"summary": {
|
||||||
|
"net_increase_actual": net_increase_actual,
|
||||||
|
"net_increase_budget": net_increase_budget,
|
||||||
|
},
|
||||||
|
"budget_source_hint": "budget_plan=预算方案 / target_split=KPI目标值按月分摊 / none=无预算",
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""实际值自动归集 API — 管理会计OS (P1-④ 2026-08-28)
|
||||||
|
|
||||||
|
取数映射管理(kpi_value_sources) + 手动触发采集 + 采集日志 + 覆盖率统计
|
||||||
|
采集器本体: scripts/kpi_value_collector.py(系统 crontab 每日 06:30)
|
||||||
|
"""
|
||||||
|
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_auth, require_role
|
||||||
|
from app.models import KPIValueSource, KPIValueCollectLog, KPIDefinition, KPIValue
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/cma/budget",
|
||||||
|
tags=["实际值归集"],
|
||||||
|
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 取数映射 CRUD ──────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/value-sources")
|
||||||
|
def list_value_sources(
|
||||||
|
kpi_id: Optional[int] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""取数映射列表(按 entity_id 隔离)"""
|
||||||
|
query = db.query(KPIValueSource).filter(KPIValueSource.entity_id == entity_id)
|
||||||
|
if kpi_id:
|
||||||
|
query = query.filter(KPIValueSource.kpi_id == kpi_id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(KPIValueSource.status == status)
|
||||||
|
rows = query.order_by(KPIValueSource.id.desc()).all()
|
||||||
|
|
||||||
|
kpi_ids = {r.kpi_id for r in rows}
|
||||||
|
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
kpi = kpis.get(r.kpi_id)
|
||||||
|
result.append({
|
||||||
|
"id": r.id,
|
||||||
|
"entity_id": r.entity_id,
|
||||||
|
"kpi_id": r.kpi_id,
|
||||||
|
"kpi_code": kpi.kpi_code if kpi else "",
|
||||||
|
"kpi_name": kpi.kpi_name if kpi else "",
|
||||||
|
"source_table": r.source_table,
|
||||||
|
"source_field": r.source_field,
|
||||||
|
"aggregate": r.aggregate,
|
||||||
|
"filter_rule": r.filter_rule,
|
||||||
|
"period_field": r.period_field,
|
||||||
|
"unit_conversion": r.unit_conversion,
|
||||||
|
"status": r.status,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
})
|
||||||
|
return {"data": result, "total": len(result)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/value-sources")
|
||||||
|
def create_value_source(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""新建取数映射"""
|
||||||
|
kpi_id = data.get("kpi_id")
|
||||||
|
source_table = data.get("source_table")
|
||||||
|
source_field = data.get("source_field")
|
||||||
|
if not kpi_id or not source_table or not source_field:
|
||||||
|
raise HTTPException(400, "缺少必要参数: kpi_id, source_table, source_field")
|
||||||
|
|
||||||
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.id == kpi_id,
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
|
||||||
|
existing = db.query(KPIValueSource).filter(
|
||||||
|
KPIValueSource.entity_id == entity_id,
|
||||||
|
KPIValueSource.kpi_id == kpi_id,
|
||||||
|
KPIValueSource.source_table == source_table,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(400, f"该KPI({kpi_id})已存在 {source_table} 取数映射")
|
||||||
|
|
||||||
|
row = KPIValueSource(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
source_table=source_table,
|
||||||
|
source_field=source_field,
|
||||||
|
aggregate=data.get("aggregate", "sum"),
|
||||||
|
filter_rule=data.get("filter_rule"),
|
||||||
|
period_field=data.get("period_field", "period"),
|
||||||
|
unit_conversion=data.get("unit_conversion", 1),
|
||||||
|
status=data.get("status", "active"),
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return {"message": "取数映射已创建", "id": row.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/value-sources/{source_id}")
|
||||||
|
def update_value_source(
|
||||||
|
source_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""更新取数映射"""
|
||||||
|
row = db.query(KPIValueSource).filter(
|
||||||
|
KPIValueSource.id == source_id,
|
||||||
|
KPIValueSource.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "映射不存在")
|
||||||
|
for field in ("source_table", "source_field", "aggregate", "filter_rule",
|
||||||
|
"period_field", "unit_conversion", "status"):
|
||||||
|
if field in data:
|
||||||
|
setattr(row, field, data[field])
|
||||||
|
db.commit()
|
||||||
|
return {"message": "映射已更新", "id": row.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/value-sources/{source_id}")
|
||||||
|
def delete_value_source(
|
||||||
|
source_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""删除取数映射"""
|
||||||
|
row = db.query(KPIValueSource).filter(
|
||||||
|
KPIValueSource.id == source_id,
|
||||||
|
KPIValueSource.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "映射不存在")
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "映射已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/value-sources/test")
|
||||||
|
def test_value_source(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""试跑单条映射返回预览值(不写库)"""
|
||||||
|
from scripts.kpi_value_collector import collect_for_mapping
|
||||||
|
|
||||||
|
mapping = KPIValueSource(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=data.get("kpi_id"),
|
||||||
|
source_table=data.get("source_table"),
|
||||||
|
source_field=data.get("source_field"),
|
||||||
|
aggregate=data.get("aggregate", "sum"),
|
||||||
|
filter_rule=data.get("filter_rule"),
|
||||||
|
period_field=data.get("period_field", "period"),
|
||||||
|
unit_conversion=data.get("unit_conversion", 1),
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
period = data.get("period") or _default_period()
|
||||||
|
try:
|
||||||
|
value, message = collect_for_mapping(db, mapping, period, write_kpi=False)
|
||||||
|
return {"success": True, "period": period, "value": value, "message": message}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "period": period, "value": None, "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 采集器触发 ──────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/value-collect/run")
|
||||||
|
def run_value_collect(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""手动触发采集器(可选 period 参数,默认当月)"""
|
||||||
|
from scripts.kpi_value_collector import run_collector
|
||||||
|
|
||||||
|
period = data.get("period") or _default_period()
|
||||||
|
kpi_id = data.get("kpi_id") # 可选: 只采集单个KPI
|
||||||
|
result = run_collector(db, entity_id=entity_id, period=period, kpi_id=kpi_id)
|
||||||
|
result["period"] = period
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── 采集日志 ──────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/value-collect/logs")
|
||||||
|
def list_collect_logs(
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
period: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""采集日志(status过滤)"""
|
||||||
|
query = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.entity_id == entity_id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(KPIValueCollectLog.status == status)
|
||||||
|
if period:
|
||||||
|
query = query.filter(KPIValueCollectLog.period == period)
|
||||||
|
rows = query.order_by(KPIValueCollectLog.collected_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
kpi_ids = {r.kpi_id for r in rows}
|
||||||
|
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
kpi = kpis.get(r.kpi_id)
|
||||||
|
result.append({
|
||||||
|
"id": r.id,
|
||||||
|
"kpi_id": r.kpi_id,
|
||||||
|
"kpi_code": kpi.kpi_code if kpi else "",
|
||||||
|
"kpi_name": kpi.kpi_name if kpi else "",
|
||||||
|
"period": r.period,
|
||||||
|
"source_table": r.source_table,
|
||||||
|
"collected_value": r.collected_value,
|
||||||
|
"status": r.status,
|
||||||
|
"message": r.message,
|
||||||
|
"collected_at": r.collected_at.isoformat() if r.collected_at else None,
|
||||||
|
})
|
||||||
|
return {"data": result, "total": len(result)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 覆盖率统计 ──────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/value-sources/coverage")
|
||||||
|
def value_source_coverage(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""覆盖率统计:已配映射KPI数 / 总活跃KPI数 / 未配置清单"""
|
||||||
|
total_kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
).count()
|
||||||
|
|
||||||
|
mapped_rows = db.query(KPIValueSource).filter(
|
||||||
|
KPIValueSource.entity_id == entity_id,
|
||||||
|
KPIValueSource.status == "active",
|
||||||
|
).all()
|
||||||
|
mapped_kpi_ids = {r.kpi_id for r in mapped_rows}
|
||||||
|
|
||||||
|
all_kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
).all()
|
||||||
|
unmapped = [{
|
||||||
|
"kpi_id": k.id,
|
||||||
|
"kpi_code": k.kpi_code,
|
||||||
|
"kpi_name": k.kpi_name,
|
||||||
|
} for k in all_kpis if k.id not in mapped_kpi_ids]
|
||||||
|
|
||||||
|
coverage = round(len(mapped_kpi_ids) / total_kpis * 100, 1) if total_kpis else 0
|
||||||
|
return {
|
||||||
|
"mapped_count": len(mapped_kpi_ids),
|
||||||
|
"total_kpis": total_kpis,
|
||||||
|
"coverage_pct": coverage,
|
||||||
|
"unmapped_count": len(unmapped),
|
||||||
|
"unmapped": unmapped,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_period() -> str:
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.now().strftime("%Y-%m")
|
||||||
+52
-11
@@ -8,6 +8,12 @@ POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果
|
|||||||
→ 验证通过 → 所属OKR progress +15%
|
→ 验证通过 → 所属OKR progress +15%
|
||||||
→ 通知任总 (send_wecom_message)
|
→ 通知任总 (send_wecom_message)
|
||||||
|
|
||||||
|
⚠️ 双 verify 入口关系(2026-08-30 评审收敛,暂不重构):
|
||||||
|
- 本文件: /api/cma/verify/{plan_id} — 完整链路(回填KPIValue + OKR联动 + 企微通知)
|
||||||
|
- bot_bridge_v2.py: /api/cma/bot-bridge/verify/{action_plan_id} — 轻量版(仅记 verify_log,
|
||||||
|
不回填KPIValue、不联动OKR、不通知),供财务Bot/研学Bot桥接通道调用
|
||||||
|
- 两者行为不一致,勿混用:Bot 通道走 bot_bridge_v2,业务侧手动/自动重验走本文件。
|
||||||
|
|
||||||
规则格式(新):
|
规则格式(新):
|
||||||
{
|
{
|
||||||
"kpi_code": "C_REBATE_RATE",
|
"kpi_code": "C_REBATE_RATE",
|
||||||
@@ -86,14 +92,22 @@ def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def update_okr_progress(db: Session, plan: ActionPlan) -> dict:
|
def update_okr_progress(db: Session, plan: ActionPlan, already_verified: bool = False, force_recalc: bool = False) -> dict:
|
||||||
"""验证通过 → 所属OKR progress +15%(每通过1个KR)"""
|
"""验证通过 → 所属OKR progress +15%(每通过1个KR)
|
||||||
|
|
||||||
|
缺陷1修复(2026-08-30):幂等防重复累加
|
||||||
|
- already_verified=True(调用前 plan 已 verify_status=='passed' 且 verified_at 非空)
|
||||||
|
→ 跳过累加,返回当前值(保持原值)
|
||||||
|
- force_recalc=True 时强制重新累加(业务确需重验场景由调用方显式开启;默认 False)
|
||||||
|
"""
|
||||||
if not plan.objective_id:
|
if not plan.objective_id:
|
||||||
return {"updated": False, "reason": "no_objective"}
|
return {"updated": False, "reason": "no_objective"}
|
||||||
obj = db.query(Objective).filter(Objective.id == plan.objective_id).first()
|
obj = db.query(Objective).filter(Objective.id == plan.objective_id).first()
|
||||||
if not obj:
|
if not obj:
|
||||||
return {"updated": False, "reason": "objective_not_found"}
|
return {"updated": False, "reason": "objective_not_found"}
|
||||||
before = obj.progress or 0
|
before = obj.progress or 0
|
||||||
|
if already_verified and not force_recalc:
|
||||||
|
return {"updated": False, "reason": "already_verified", "objective_id": obj.id, "before": before, "after": before}
|
||||||
obj.progress = min(100, before + 15)
|
obj.progress = min(100, before + 15)
|
||||||
db.flush()
|
db.flush()
|
||||||
return {"updated": True, "objective_id": obj.id, "before": before, "after": obj.progress}
|
return {"updated": True, "objective_id": obj.id, "before": before, "after": obj.progress}
|
||||||
@@ -148,12 +162,26 @@ def build_auto_verify_rule(kpi, baseline_value=None, verify_after_days: int = 7)
|
|||||||
|
|
||||||
|
|
||||||
def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"):
|
def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"):
|
||||||
"""回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录"""
|
"""回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录
|
||||||
|
|
||||||
|
缺陷2修复(2026-08-30)多租户隔离:
|
||||||
|
- KPIValue 创建时设置 entity_id(从 plan 关联 KPI 定义取,即 plan.kpi_id → kpi_definitions.entity_id)
|
||||||
|
- kpi_code 查询 KPIDefinition 时带 entity_id 过滤(防跨租户误匹配 kpi_code)
|
||||||
|
"""
|
||||||
kpi = None
|
kpi = None
|
||||||
kpi_code = rule.get("kpi_code") if rule else None
|
kpi_code = rule.get("kpi_code") if rule else None
|
||||||
# 优先按验证规则指定的KPI编码查询;无规则时才回退到plan.kpi_id
|
# 确定 plan 所属 entity_id(从 plan.kpi_id → KPIDefinition.entity_id 向上取)
|
||||||
|
entity_id = None
|
||||||
|
if plan.kpi_id:
|
||||||
|
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||||
|
if pkpi:
|
||||||
|
entity_id = pkpi.entity_id
|
||||||
|
# 优先按验证规则指定的KPI编码查询(带 entity_id 过滤);无规则时才回退到plan.kpi_id
|
||||||
if kpi_code:
|
if kpi_code:
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
|
||||||
|
if entity_id is not None:
|
||||||
|
q = q.filter(KPIDefinition.entity_id == entity_id)
|
||||||
|
kpi = q.first()
|
||||||
elif plan.kpi_id:
|
elif plan.kpi_id:
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||||
|
|
||||||
@@ -166,6 +194,7 @@ def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source
|
|||||||
if kpi:
|
if kpi:
|
||||||
new_val = KPIValue(
|
new_val = KPIValue(
|
||||||
kpi_id=kpi.id,
|
kpi_id=kpi.id,
|
||||||
|
entity_id=kpi.entity_id, # 缺陷2修复:多租户回填 entity_id
|
||||||
period=datetime.now().strftime("%Y-%m"),
|
period=datetime.now().strftime("%Y-%m"),
|
||||||
actual_value=actual,
|
actual_value=actual,
|
||||||
source_type="verify",
|
source_type="verify",
|
||||||
@@ -215,19 +244,27 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed}
|
return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed}
|
||||||
|
|
||||||
# 2. 缺省actual → 取KPI最新值
|
# 2. 缺省actual → 取KPI最新值(缺陷3修复:按 period <= 当前月过滤,跨月验证不取历史期间;支持调用方显式传 period 覆盖,默认当前月)
|
||||||
if actual is None:
|
if actual is None:
|
||||||
kpi = None
|
kpi = None
|
||||||
kpi_code = rule.get("kpi_code")
|
kpi_code = rule.get("kpi_code")
|
||||||
if plan.kpi_id:
|
if plan.kpi_id:
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||||
elif kpi_code:
|
elif kpi_code:
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
# 缺陷2修复:kpi_code 查询带 entity_id 过滤(防跨租户误匹配)
|
||||||
|
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
|
||||||
|
if plan.kpi_id:
|
||||||
|
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||||
|
if pkpi and pkpi.entity_id is not None:
|
||||||
|
q = q.filter(KPIDefinition.entity_id == pkpi.entity_id)
|
||||||
|
kpi = q.first()
|
||||||
if kpi:
|
if kpi:
|
||||||
|
period_limit = payload.get("period") or datetime.now().strftime("%Y-%m")
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
).order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
|
KPIValue.period <= period_limit, # 缺陷3修复:只取当前月及之前的期间
|
||||||
|
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
|
||||||
if latest:
|
if latest:
|
||||||
actual = latest.actual_value
|
actual = latest.actual_value
|
||||||
|
|
||||||
@@ -237,6 +274,10 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
|
|||||||
# 4. 回写KPI当前值
|
# 4. 回写KPI当前值
|
||||||
backfill = backfill_kpi_value(db, plan, actual, rule, source=source)
|
backfill = backfill_kpi_value(db, plan, actual, rule, source=source)
|
||||||
|
|
||||||
|
# 缺陷1修复:调用前先记录 plan 是否已处于"验证通过"状态(防止重复累加 OKR progress)
|
||||||
|
already_verified = bool(plan.verify_status == "passed" and plan.verified_at is not None)
|
||||||
|
force_recalc = bool(payload.get("force_recalc", False))
|
||||||
|
|
||||||
# 5. 更新状态
|
# 5. 更新状态
|
||||||
plan.verify_status = "passed" if passed else "failed"
|
plan.verify_status = "passed" if passed else "failed"
|
||||||
plan.verify_result = "pass" if passed else "fail"
|
plan.verify_result = "pass" if passed else "fail"
|
||||||
@@ -250,14 +291,14 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
|
|||||||
"note": note,
|
"note": note,
|
||||||
}]
|
}]
|
||||||
if passed:
|
if passed:
|
||||||
plan.status = "done"
|
plan.status = "completed" # 缺陷4修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
|
||||||
plan.progress = 100
|
plan.progress = 100
|
||||||
plan.verified_at = datetime.now()
|
plan.verified_at = datetime.now()
|
||||||
|
|
||||||
# 6. OKR进度联动(验证通过 → +15%)
|
# 6. OKR进度联动(验证通过 → +15%;缺陷1修复:已通过过的 plan 不再重复累加,force_recalc 可强制重算)
|
||||||
okr_update = None
|
okr_update = None
|
||||||
if passed:
|
if passed:
|
||||||
okr_update = update_okr_progress(db, plan)
|
okr_update = update_okr_progress(db, plan, already_verified=already_verified, force_recalc=force_recalc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,18 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
|
|||||||
|
|
||||||
@router.get("/{map_id}/versions")
|
@router.get("/{map_id}/versions")
|
||||||
def list_versions(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def list_versions(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""查看版本历史"""
|
"""查看版本历史(账套隔离, OpenCode审查#9)"""
|
||||||
versions = db.query(StrategicMapVersion).filter(
|
versions = db.query(StrategicMapVersion).filter(
|
||||||
StrategicMapVersion.map_id == map_id
|
StrategicMapVersion.map_id == map_id,
|
||||||
|
StrategicMapVersion.entity_id == entity_id,
|
||||||
).order_by(StrategicMapVersion.id.desc()).all()
|
).order_by(StrategicMapVersion.id.desc()).all()
|
||||||
return {"data": [v_to_dict(v) for v in versions]}
|
return {"data": [v_to_dict(v) for v in versions]}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{map_id}/versions/snapshot")
|
@router.post("/{map_id}/versions/snapshot")
|
||||||
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""手动创建快照"""
|
"""手动创建快照(账套隔离)"""
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id), StrategicMap.entity_id == entity_id.first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), enti
|
|||||||
|
|
||||||
snapshot = StrategicMapVersion(
|
snapshot = StrategicMapVersion(
|
||||||
map_id=map_id,
|
map_id=map_id,
|
||||||
|
entity_id=entity_id, # 继承地图企业
|
||||||
version=new_ver,
|
version=new_ver,
|
||||||
dimensions=dims,
|
dimensions=dims,
|
||||||
canvas_data=canvas,
|
canvas_data=canvas,
|
||||||
@@ -64,7 +66,7 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), enti
|
|||||||
@router.post("/{map_id}/versions/{ver_id}/rollback")
|
@router.post("/{map_id}/versions/{ver_id}/rollback")
|
||||||
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""回滚到指定版本"""
|
"""回滚到指定版本"""
|
||||||
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:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""零基预算逐项论证 API — 管理会计OS (P2-① 2026-08-28)
|
||||||
|
|
||||||
|
CRUD 逐项论证项(budget_zero_based_items) + generate 生成零基预算写 budget_plans。
|
||||||
|
method-comparison 的 zero_based 分支优先读论证项(有数据→逐项求和 is_demo=false)。
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
|
from app.auth_middleware import require_auth, require_role
|
||||||
|
from app.models import BudgetZeroBasedItem, KPIDefinition, BudgetPlan
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/cma/budget",
|
||||||
|
tags=["零基预算"],
|
||||||
|
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zero-based/items")
|
||||||
|
def list_zero_based_items(
|
||||||
|
kpi_id: Optional[int] = Query(None),
|
||||||
|
period: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""零基逐项论证列表(kpi_id+period 过滤)"""
|
||||||
|
query = db.query(BudgetZeroBasedItem).filter(BudgetZeroBasedItem.entity_id == entity_id)
|
||||||
|
if kpi_id:
|
||||||
|
query = query.filter(BudgetZeroBasedItem.kpi_id == kpi_id)
|
||||||
|
if period:
|
||||||
|
query = query.filter(BudgetZeroBasedItem.period == period)
|
||||||
|
rows = query.order_by(BudgetZeroBasedItem.id.desc()).all()
|
||||||
|
|
||||||
|
kpi_ids = {r.kpi_id for r in rows}
|
||||||
|
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
kpi = kpis.get(r.kpi_id)
|
||||||
|
result.append({
|
||||||
|
"id": r.id,
|
||||||
|
"kpi_id": r.kpi_id,
|
||||||
|
"kpi_code": kpi.kpi_code if kpi else "",
|
||||||
|
"kpi_name": kpi.kpi_name if kpi else "",
|
||||||
|
"period": r.period,
|
||||||
|
"item_name": r.item_name,
|
||||||
|
"item_category": r.item_category,
|
||||||
|
"base_value": r.base_value,
|
||||||
|
"justification": r.justification,
|
||||||
|
"proposed_value": r.proposed_value,
|
||||||
|
"status": r.status,
|
||||||
|
"created_by": r.created_by,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
})
|
||||||
|
total_proposed = round(sum(r.proposed_value for r in rows), 2)
|
||||||
|
return {"data": result, "total": len(result), "total_proposed": total_proposed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/zero-based/items")
|
||||||
|
def create_zero_based_item(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""新建逐项论证项"""
|
||||||
|
kpi_id = data.get("kpi_id")
|
||||||
|
period = data.get("period")
|
||||||
|
item_name = data.get("item_name")
|
||||||
|
base_value = data.get("base_value")
|
||||||
|
proposed_value = data.get("proposed_value")
|
||||||
|
if not kpi_id or not period or not item_name:
|
||||||
|
raise HTTPException(400, "缺少必要参数: kpi_id, period, item_name")
|
||||||
|
if base_value is None:
|
||||||
|
base_value = 0
|
||||||
|
if proposed_value is None:
|
||||||
|
proposed_value = 0
|
||||||
|
|
||||||
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.id == kpi_id,
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
|
||||||
|
row = BudgetZeroBasedItem(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
period=period,
|
||||||
|
item_name=item_name,
|
||||||
|
item_category=data.get("item_category", "discretionary"),
|
||||||
|
base_value=base_value,
|
||||||
|
justification=data.get("justification"),
|
||||||
|
proposed_value=proposed_value,
|
||||||
|
status=data.get("status", "draft"),
|
||||||
|
created_by=data.get("created_by") or (current_user.name if hasattr(current_user, "name") else None),
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return {"message": "论证项已创建", "id": row.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/zero-based/items/{item_id}")
|
||||||
|
def update_zero_based_item(
|
||||||
|
item_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""更新逐项论证项"""
|
||||||
|
row = db.query(BudgetZeroBasedItem).filter(
|
||||||
|
BudgetZeroBasedItem.id == item_id,
|
||||||
|
BudgetZeroBasedItem.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "论证项不存在")
|
||||||
|
for field in ("item_name", "item_category", "base_value", "justification",
|
||||||
|
"proposed_value", "status"):
|
||||||
|
if field in data:
|
||||||
|
setattr(row, field, data[field])
|
||||||
|
db.commit()
|
||||||
|
return {"message": "论证项已更新", "id": row.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/zero-based/items/{item_id}")
|
||||||
|
def delete_zero_based_item(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""删除逐项论证项"""
|
||||||
|
row = db.query(BudgetZeroBasedItem).filter(
|
||||||
|
BudgetZeroBasedItem.id == item_id,
|
||||||
|
BudgetZeroBasedItem.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "论证项不存在")
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "论证项已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/zero-based/generate")
|
||||||
|
def generate_zero_based_budget(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""生成零基预算 = Σ(proposed_value),写 budget_plans(version='zbb-YYYYMMDD',calc_logic='zero_based_itemized')"""
|
||||||
|
kpi_id = data.get("kpi_id")
|
||||||
|
period = data.get("period")
|
||||||
|
year = data.get("year")
|
||||||
|
if not kpi_id or not period:
|
||||||
|
raise HTTPException(400, "缺少必要参数: kpi_id, period")
|
||||||
|
|
||||||
|
items = db.query(BudgetZeroBasedItem).filter(
|
||||||
|
BudgetZeroBasedItem.entity_id == entity_id,
|
||||||
|
BudgetZeroBasedItem.kpi_id == kpi_id,
|
||||||
|
BudgetZeroBasedItem.period == period,
|
||||||
|
).all()
|
||||||
|
if not items:
|
||||||
|
raise HTTPException(400, f"期间 {period} 无逐项论证项,请先录入")
|
||||||
|
|
||||||
|
total = round(sum(i.proposed_value for i in items), 2)
|
||||||
|
# 解析年份
|
||||||
|
if not year:
|
||||||
|
try:
|
||||||
|
year = int(period.split("-")[0])
|
||||||
|
except Exception:
|
||||||
|
year = datetime.now().year
|
||||||
|
month = 0
|
||||||
|
try:
|
||||||
|
month = int(period.split("-")[1]) if "-" in period else 0
|
||||||
|
except Exception:
|
||||||
|
month = 0
|
||||||
|
|
||||||
|
version = f"zbb-{datetime.now().strftime('%Y%m%d')}"
|
||||||
|
# 删除同KPI同期间同版本旧预算,防重复
|
||||||
|
db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
|
BudgetPlan.kpi_id == kpi_id,
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.version == version,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
bp = BudgetPlan(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
period=period,
|
||||||
|
budget_value=total,
|
||||||
|
budget_year=year,
|
||||||
|
budget_month=month,
|
||||||
|
version=version,
|
||||||
|
status="active",
|
||||||
|
source_type="zero_based",
|
||||||
|
calc_logic="zero_based_itemized",
|
||||||
|
remark=f"零基逐项论证生成: {len(items)}项 Σ(proposed_value)={total}",
|
||||||
|
)
|
||||||
|
db.add(bp)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"零基预算已生成: {total}({len(items)}项论证)",
|
||||||
|
"kpi_id": kpi_id,
|
||||||
|
"period": period,
|
||||||
|
"total": total,
|
||||||
|
"item_count": len(items),
|
||||||
|
"version": version,
|
||||||
|
"plan_id": bp.id,
|
||||||
|
}
|
||||||
@@ -97,6 +97,47 @@ def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"user_entities初始化跳过: {e}")
|
logger.warning(f"user_entities初始化跳过: {e}")
|
||||||
|
|
||||||
|
# ── scenario_suggestions 告警场景建议 seed(2026-08-28 告警归因 P1-③)──
|
||||||
|
# 幂等:仅补缺失的 alert_type,不覆盖已有模板
|
||||||
|
try:
|
||||||
|
inspector = inspect(get_engine())
|
||||||
|
if "scenario_suggestions" in inspector.get_table_names():
|
||||||
|
Session = get_session_local()
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
from app.models import ScenarioSuggestion
|
||||||
|
existing_types = {s.alert_type for s in session.query(ScenarioSuggestion).all()}
|
||||||
|
seeds = [
|
||||||
|
dict(alert_type="cash_low", title="现金流紧张 — 加强回款催收",
|
||||||
|
description="现金余额接近警戒线,建议优先处理应收款项,压缩非紧急支出。",
|
||||||
|
action_template="1. 列出未来30天应收清单,逐笔催收\n2. 暂停非紧急采购/费用支出\n3. 与银行沟通短期授信额度",
|
||||||
|
priority="high", sort_order=1),
|
||||||
|
dict(alert_type="cash_critical", title="现金流危急 — 立即止血",
|
||||||
|
description="现金余额已低于安全阈值,存在断流风险,需要立即采取止血措施。",
|
||||||
|
action_template="1. 冻结一切非必要支出\n2. 高管紧急复盘资金计划\n3. 启动应收账款特别催收\n4. 评估短期融资",
|
||||||
|
priority="high", sort_order=2),
|
||||||
|
dict(alert_type="cost_high", title="成本超支 — 核查费用构成",
|
||||||
|
description="实际成本超出预算,建议拆解到科目明细定位超支源头。",
|
||||||
|
action_template="1. 查看科目明细拆解,定位超支前3科目\n2. 分析价差/量差成因(单价上涨/用量增加)\n3. 对可控费用制定压降方案",
|
||||||
|
priority="medium", sort_order=3),
|
||||||
|
dict(alert_type="revenue_drop", title="收入下滑 — 追量提效",
|
||||||
|
description="实际收入低于预算,建议从子KPI量级分解查找差距来源。",
|
||||||
|
action_template="1. 查看子KPI拆解,定位量差最大维度\n2. 分析客户/渠道/产品线缺口\n3. 制定增量获客或转化提升方案",
|
||||||
|
priority="medium", sort_order=4),
|
||||||
|
]
|
||||||
|
added = 0
|
||||||
|
for s in seeds:
|
||||||
|
if s["alert_type"] not in existing_types:
|
||||||
|
session.add(ScenarioSuggestion(**s))
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
session.commit()
|
||||||
|
logger.info(f"scenario_suggestions seed: 新增{added}条场景建议模板")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"scenario_suggestions seed跳过: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _seed_org_data(db_session):
|
def _seed_org_data(db_session):
|
||||||
"""插入5层级组织示例数据"""
|
"""插入5层级组织示例数据"""
|
||||||
|
|||||||
+11
-1
@@ -5,10 +5,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products
|
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify
|
||||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||||
from scripts.erp_sync import run_sync as run_erp_sync
|
from scripts.erp_sync import run_sync as run_erp_sync
|
||||||
from app.auth_middleware import require_auth
|
from app.auth_middleware import require_auth
|
||||||
|
from app.api.audit_log import bot_audit_middleware
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Bot API 操作审计(L1-L4分级标注 + JSON行审计日志,不阻塞业务)
|
||||||
|
app.middleware("http")(bot_audit_middleware)
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(kpis.router)
|
app.include_router(kpis.router)
|
||||||
app.include_router(kpi_governance.router)
|
app.include_router(kpi_governance.router)
|
||||||
@@ -39,6 +43,7 @@ app.include_router(dashboard.router)
|
|||||||
app.include_router(data.router)
|
app.include_router(data.router)
|
||||||
app.include_router(alerts.router)
|
app.include_router(alerts.router)
|
||||||
app.include_router(ai_analysis.router)
|
app.include_router(ai_analysis.router)
|
||||||
|
app.include_router(ai_suggestions.router)
|
||||||
app.include_router(alert_rules.router)
|
app.include_router(alert_rules.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(thresholds.router)
|
app.include_router(thresholds.router)
|
||||||
@@ -50,6 +55,10 @@ app.include_router(org.router)
|
|||||||
app.include_router(objectives.router)
|
app.include_router(objectives.router)
|
||||||
app.include_router(versions.router)
|
app.include_router(versions.router)
|
||||||
app.include_router(budget.router)
|
app.include_router(budget.router)
|
||||||
|
app.include_router(value_sources.router)
|
||||||
|
app.include_router(zero_based.router)
|
||||||
|
app.include_router(derivation_rules.router)
|
||||||
|
app.include_router(cash_classify.router)
|
||||||
app.include_router(cost.router)
|
app.include_router(cost.router)
|
||||||
app.include_router(predict.router)
|
app.include_router(predict.router)
|
||||||
app.include_router(growth_quality.router)
|
app.include_router(growth_quality.router)
|
||||||
@@ -80,6 +89,7 @@ app.include_router(bot_iron_law.router)
|
|||||||
app.include_router(analysis_results.router)
|
app.include_router(analysis_results.router)
|
||||||
app.include_router(expenses.router)
|
app.include_router(expenses.router)
|
||||||
app.include_router(cash.router)
|
app.include_router(cash.router)
|
||||||
|
app.include_router(data_classification.router)
|
||||||
app.include_router(tax_compliance.router)
|
app.include_router(tax_compliance.router)
|
||||||
app.include_router(verify.router)
|
app.include_router(verify.router)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ class KPIDefinition(Base):
|
|||||||
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
|
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
|
||||||
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
|
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
|
||||||
category = Column(String(50), nullable=True, comment="BSC二级类别: revenue_growth/profitability/cost_control/asset_efficiency/cash_risk/customer_scale/customer_concentration/customer_satisfaction/supply_chain/delivery_quality/talent_pipeline/employee_engagement/innovation")
|
category = Column(String(50), nullable=True, comment="BSC二级类别: revenue_growth/profitability/cost_control/asset_efficiency/cash_risk/customer_scale/customer_concentration/customer_satisfaction/supply_chain/delivery_quality/talent_pipeline/employee_engagement/innovation")
|
||||||
|
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
|
||||||
|
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
|
||||||
|
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
|
||||||
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
||||||
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
||||||
kpi_level = Column(String(20), default="operational", comment="strategic/operational")
|
kpi_level = Column(String(20), default="operational", comment="strategic/operational")
|
||||||
@@ -116,6 +119,7 @@ class DataSourceConfig(Base):
|
|||||||
"""数据源配置"""
|
"""数据源配置"""
|
||||||
__tablename__ = "data_source_config"
|
__tablename__ = "data_source_config"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
|
||||||
name = Column(String(200), nullable=False, comment="数据源名称")
|
name = Column(String(200), nullable=False, comment="数据源名称")
|
||||||
source_type = Column(String(20), nullable=False, comment="erp/business/excel")
|
source_type = Column(String(20), nullable=False, comment="erp/business/excel")
|
||||||
api_endpoint = Column(String(500), nullable=True, comment="API地址")
|
api_endpoint = Column(String(500), nullable=True, comment="API地址")
|
||||||
@@ -131,6 +135,7 @@ class KPIAlert(Base):
|
|||||||
"""预警记录"""
|
"""预警记录"""
|
||||||
__tablename__ = "kpi_alerts"
|
__tablename__ = "kpi_alerts"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
|
||||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
|
||||||
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
|
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
|
||||||
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
|
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
|
||||||
@@ -214,9 +219,11 @@ class ActionPlan(Base):
|
|||||||
"""改善行动计划"""
|
"""改善行动计划"""
|
||||||
__tablename__ = "action_plans"
|
__tablename__ = "action_plans"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
|
||||||
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警")
|
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警")
|
||||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=True, comment="关联OKR目标")
|
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=True, comment="关联OKR目标")
|
||||||
|
kr_id = Column(Integer, ForeignKey("krs.id"), nullable=True, comment="关联KR (行动挂KR 2026-08-27)")
|
||||||
title = Column(String(200), nullable=False, comment="计划标题")
|
title = Column(String(200), nullable=False, comment="计划标题")
|
||||||
description = Column(Text, nullable=True, comment="详细描述")
|
description = Column(Text, nullable=True, comment="详细描述")
|
||||||
assignee = Column(String(100), nullable=True, comment="负责人")
|
assignee = Column(String(100), nullable=True, comment="负责人")
|
||||||
@@ -243,6 +250,7 @@ class OrgNode(Base):
|
|||||||
"""组织节点: 集团→事业部→区域→部门→班组 5级"""
|
"""组织节点: 集团→事业部→区域→部门→班组 5级"""
|
||||||
__tablename__ = "org_nodes"
|
__tablename__ = "org_nodes"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
|
||||||
parent_id = Column(Integer, ForeignKey("org_nodes.id"), nullable=True, comment="父节点ID")
|
parent_id = Column(Integer, ForeignKey("org_nodes.id"), nullable=True, comment="父节点ID")
|
||||||
name = Column(String(100), nullable=False, comment="节点名称")
|
name = Column(String(100), nullable=False, comment="节点名称")
|
||||||
code = Column(String(50), unique=True, nullable=True, comment="编码")
|
code = Column(String(50), unique=True, nullable=True, comment="编码")
|
||||||
@@ -259,6 +267,7 @@ class StrategicMapVersion(Base):
|
|||||||
"""战略地图版本快照"""
|
"""战略地图版本快照"""
|
||||||
__tablename__ = "strategic_map_versions"
|
__tablename__ = "strategic_map_versions"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (OpenCode审查#9 2026-08-26)")
|
||||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
||||||
version = Column(String(20), nullable=False, comment="版本号 v1.0 v1.1 ...")
|
version = Column(String(20), nullable=False, comment="版本号 v1.0 v1.1 ...")
|
||||||
dimensions = Column(JSON, nullable=False, comment="维度数据快照")
|
dimensions = Column(JSON, nullable=False, comment="维度数据快照")
|
||||||
@@ -268,6 +277,22 @@ class StrategicMapVersion(Base):
|
|||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewRecord(Base):
|
||||||
|
"""复盘记录 — 战略回顾会/运营复盘会结论持久化(大PDCA Act闭环 2026-08-27)"""
|
||||||
|
__tablename__ = "review_records"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-27)")
|
||||||
|
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联战略地图")
|
||||||
|
review_type = Column(String(20), default="quarterly", comment="monthly/quarterly 月度运营复盘/季度战略回顾")
|
||||||
|
review_date = Column(Date, nullable=False, comment="复盘日期")
|
||||||
|
summary = Column(Text, nullable=False, comment="复盘结论(为什么没达成/怎么调整)")
|
||||||
|
adjustments = Column(JSON, nullable=True, comment="调整项JSON [{type,target,action}]")
|
||||||
|
next_priorities = Column(JSON, nullable=True, comment="下阶段优先级 [str,...]")
|
||||||
|
created_by = Column(Integer, nullable=True, comment="创建人用户ID")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
class MapObjective(Base):
|
class MapObjective(Base):
|
||||||
"""战略地图目标: 每个维度下的具体目标"""
|
"""战略地图目标: 每个维度下的具体目标"""
|
||||||
__tablename__ = "map_objectives"
|
__tablename__ = "map_objectives"
|
||||||
@@ -287,12 +312,17 @@ class KPICausality(Base):
|
|||||||
"""KPI因果链 — 记录KPI间的因果关系"""
|
"""KPI因果链 — 记录KPI间的因果关系"""
|
||||||
__tablename__ = "kpi_causality"
|
__tablename__ = "kpi_causality"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-27)")
|
||||||
source_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="源KPI(因)")
|
source_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="源KPI(因)")
|
||||||
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
|
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
|
||||||
strength = Column(Float, default=0.5, comment="影响强度 0~1")
|
strength = Column(Float, default=0.5, comment="影响强度 0~1")
|
||||||
lag_months = Column(Integer, default=1, comment="滞后期(月)")
|
lag_months = Column(Integer, default=1, comment="滞后期(月)")
|
||||||
formula = Column(String(500), nullable=True, comment="影响公式描述")
|
formula = Column(String(500), nullable=True, comment="影响公式描述")
|
||||||
direction = Column(String(10), default="positive", comment="positive/negative 正向/负向影响")
|
direction = Column(String(10), default="positive", comment="positive/negative 正向/负向影响")
|
||||||
|
source_type = Column(String(20), default="manual", comment="建链来源 AI_suggested/manual/imported (2026-08-27 验证机制)")
|
||||||
|
verify_status = Column(String(20), default="pending", comment="验证状态 pending/data_verified/human_verified/disputed (2026-08-27)")
|
||||||
|
verified_at = Column(DateTime, nullable=True, comment="验证时间")
|
||||||
|
verified_by = Column(String(50), nullable=True, comment="验证人/AI/脚本")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
@@ -459,6 +489,8 @@ class CashPlan(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
|
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
|
||||||
plan_type = Column(String(10), nullable=False, comment="receive收/pay付")
|
plan_type = Column(String(10), nullable=False, comment="receive收/pay付")
|
||||||
|
related_kpi_id = Column(Integer, nullable=True, comment="关联KPI(预算联动 2026-08-27)")
|
||||||
|
budget_plan_id = Column(Integer, nullable=True, comment="来源预算计划ID")
|
||||||
amount = Column(Float, nullable=False, comment="金额(万元)")
|
amount = Column(Float, nullable=False, comment="金额(万元)")
|
||||||
plan_date = Column(DateTime, nullable=False, comment="计划日期(应收即到期日)")
|
plan_date = Column(DateTime, nullable=False, comment="计划日期(应收即到期日)")
|
||||||
counterparty = Column(String(200), nullable=True, comment="关联客户/供应商")
|
counterparty = Column(String(200), nullable=True, comment="关联客户/供应商")
|
||||||
@@ -496,6 +528,9 @@ class BudgetDeviationAlert(Base):
|
|||||||
alert_level = Column(String(20), default="warning", comment="warning/critical")
|
alert_level = Column(String(20), default="warning", comment="warning/critical")
|
||||||
status = Column(String(20), default="open", comment="open/resolved/ignored")
|
status = Column(String(20), default="open", comment="open/resolved/ignored")
|
||||||
suggestion = Column(String(500), nullable=True, comment="处理建议")
|
suggestion = Column(String(500), nullable=True, comment="处理建议")
|
||||||
|
alert_type = Column(String(30), nullable=True, comment="归因场景: cost_high/revenue_drop/cash_low/cash_critical (2026-08-28 告警归因P1-③)")
|
||||||
|
attribution = Column(JSON, nullable=True, comment="归因JSON: 子KPI拆解+科目拆解+量价差+趋势 (2026-08-28)")
|
||||||
|
scenario_id = Column(Integer, nullable=True, comment="FK scenario_suggestions.id 场景建议 (2026-08-28)")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
@@ -503,6 +538,7 @@ class ReportHistory(Base):
|
|||||||
"""自动生成的经营分析报告记录"""
|
"""自动生成的经营分析报告记录"""
|
||||||
__tablename__ = "report_history"
|
__tablename__ = "report_history"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
|
||||||
report_type = Column(String(20), nullable=False, comment="weekly/monthly/special")
|
report_type = Column(String(20), nullable=False, comment="weekly/monthly/special")
|
||||||
period = Column(String(20), nullable=False, comment="期间: 2026-W30 / 2026-07 / 2026-Q2")
|
period = Column(String(20), nullable=False, comment="期间: 2026-W30 / 2026-07 / 2026-Q2")
|
||||||
title = Column(String(200), nullable=False, comment="报告标题")
|
title = Column(String(200), nullable=False, comment="报告标题")
|
||||||
@@ -574,6 +610,9 @@ class Subject(Base):
|
|||||||
level = Column(Integer, default=1, comment="科目级别 1-4")
|
level = Column(Integer, default=1, comment="科目级别 1-4")
|
||||||
category = Column(String(50), nullable=True, comment="科目类别")
|
category = Column(String(50), nullable=True, comment="科目类别")
|
||||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类: operating/investing/financing/tax/discontinued")
|
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类: operating/investing/financing/tax/discontinued")
|
||||||
|
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
|
||||||
|
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
|
||||||
|
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
|
||||||
is_active = Column(Integer, default=1, comment="是否启用")
|
is_active = Column(Integer, default=1, comment="是否启用")
|
||||||
remark = Column(String(500), nullable=True, comment="备注")
|
remark = Column(String(500), nullable=True, comment="备注")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
@@ -581,9 +620,10 @@ class Subject(Base):
|
|||||||
|
|
||||||
|
|
||||||
class VoucherDetail(Base):
|
class VoucherDetail(Base):
|
||||||
"""凭证明细 — 新30号准则分类"""
|
"""凭证明细 — 新30号准则分类 (网银流水导入 2026-08-28)"""
|
||||||
__tablename__ = "voucher_details"
|
__tablename__ = "voucher_details"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)")
|
||||||
voucher_no = Column(String(50), nullable=False, comment="凭证编号")
|
voucher_no = Column(String(50), nullable=False, comment="凭证编号")
|
||||||
voucher_date = Column(DateTime, nullable=False, comment="凭证日期")
|
voucher_date = Column(DateTime, nullable=False, comment="凭证日期")
|
||||||
subject_code = Column(String(20), nullable=False, comment="科目编码")
|
subject_code = Column(String(20), nullable=False, comment="科目编码")
|
||||||
@@ -591,8 +631,27 @@ class VoucherDetail(Base):
|
|||||||
debit_amount = Column(Float, default=0, comment="借方金额")
|
debit_amount = Column(Float, default=0, comment="借方金额")
|
||||||
credit_amount = Column(Float, default=0, comment="贷方金额")
|
credit_amount = Column(Float, default=0, comment="贷方金额")
|
||||||
summary = Column(String(500), nullable=True, comment="摘要")
|
summary = Column(String(500), nullable=True, comment="摘要")
|
||||||
|
carry_forward = Column(Integer, default=0, nullable=False, comment="结转行标记(1=结转行不参与现金流)")
|
||||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
|
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
|
||||||
period = Column(String(20), nullable=True, comment="期间 YYYY-MM")
|
period = Column(String(20), nullable=True, comment="期间 YYYY-MM")
|
||||||
|
batch = Column(String(100), nullable=True, comment="导入批次号")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ImportLog(Base):
|
||||||
|
"""数据导入日志 — 网银流水/Excel导入批次记录 (2026-08-28)"""
|
||||||
|
__tablename__ = "import_logs"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)")
|
||||||
|
filename = Column(String(500), nullable=False, comment="文件名")
|
||||||
|
batch = Column(String(100), nullable=False, comment="批次号")
|
||||||
|
total_rows = Column(Integer, nullable=True, comment="总行数")
|
||||||
|
success_rows = Column(Integer, nullable=True, comment="成功行数")
|
||||||
|
failed_rows = Column(Integer, nullable=True, comment="失败行数")
|
||||||
|
errors = Column(JSON, nullable=True, comment="失败详情 [{row, field, reason}]")
|
||||||
|
period = Column(String(20), nullable=True, comment="导入期间")
|
||||||
|
import_type = Column(String(20), nullable=True, comment="导入类型: vouchers/kpi")
|
||||||
|
created_by = Column(String(100), nullable=True, comment="导入人")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
@@ -743,9 +802,141 @@ class KR(Base):
|
|||||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
||||||
title = Column(String(200), nullable=False, comment="KR标题")
|
title = Column(String(200), nullable=False, comment="KR标题")
|
||||||
metric_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=True, comment="度量KPI ID")
|
metric_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=True, comment="度量KPI ID")
|
||||||
|
operator = Column(String(5), default=">=", nullable=False, comment="方向符号: >=/<=/>/</= (KR完整修复 2026-08-27)")
|
||||||
|
tolerance = Column(Numeric(10, 4), nullable=True, comment="容差(仅=时使用)")
|
||||||
|
weight = Column(Numeric(5, 2), nullable=True, comment="KR权重%")
|
||||||
target_value = Column(Numeric(15, 2), nullable=True, comment="目标值")
|
target_value = Column(Numeric(15, 2), nullable=True, comment="目标值")
|
||||||
current_value = Column(Numeric(15, 2), nullable=True, comment="当前值")
|
current_value = Column(Numeric(15, 2), nullable=True, comment="当前值")
|
||||||
progress = Column(Integer, default=0, comment="完成进度 0-100")
|
progress = Column(Integer, default=0, comment="完成进度 0-100")
|
||||||
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled")
|
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled/achieved")
|
||||||
due_date = Column(Date, nullable=True, comment="截止日期")
|
due_date = Column(Date, nullable=True, comment="截止日期")
|
||||||
|
monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]")
|
||||||
|
sort_order = Column(Integer, default=0, comment="排序")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 预算系统技术改进 (2026-08-28 yanxue-budget-tech-improve)
|
||||||
|
# ① kpi_value_sources/kpi_value_collect_logs: 实际值自动归集 P1-④
|
||||||
|
# ② budget_zero_based_items: 真零基逐项论证 P2-①
|
||||||
|
# ③ budget_derivation_rules: 派生规则可配置 P2-②
|
||||||
|
# ④ cash_plan_classify_rules/cash_plan_unclassified: 现金流分类规则 P2-⑥
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class KPIValueSource(Base):
|
||||||
|
"""KPI实际值取数映射 — 自动归集源头 (P1-④ 2026-08-28)"""
|
||||||
|
__tablename__ = "kpi_value_sources"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||||
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI")
|
||||||
|
source_table = Column(String(50), nullable=False, comment="源头表: voucher_details/product_inventory/product_inventory_detail/cash_plans")
|
||||||
|
source_field = Column(String(50), nullable=False, comment="金额字段: credit_amount/debit_amount/amount/qty")
|
||||||
|
aggregate = Column(String(10), default="sum", comment="sum/avg/count/max/min")
|
||||||
|
filter_rule = Column(JSON, nullable=True, comment="过滤: {\"subject_code\":\"6601\",\"direction\":\"credit\"}")
|
||||||
|
period_field = Column(String(50), default="period", comment="期间字段: period/voucher_date")
|
||||||
|
unit_conversion = Column(Float, default=1, comment="单位倍率(元→万元/10000)")
|
||||||
|
status = Column(String(20), default="active", comment="active/inactive")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
__table_args__ = (UniqueConstraint("entity_id", "kpi_id", "source_table", name="uk_source"),)
|
||||||
|
|
||||||
|
|
||||||
|
class KPIValueCollectLog(Base):
|
||||||
|
"""实际值采集日志 — 每次自动归集记录 (P1-④ 2026-08-28)"""
|
||||||
|
__tablename__ = "kpi_value_collect_logs"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, nullable=False, comment="企业ID")
|
||||||
|
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
|
||||||
|
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||||
|
source_table = Column(String(50), nullable=False, comment="源头表")
|
||||||
|
collected_value = Column(Float, nullable=True, comment="采集到的值")
|
||||||
|
status = Column(String(20), default="success", comment="success/failed")
|
||||||
|
message = Column(String(500), nullable=True, comment="说明/错误信息")
|
||||||
|
collected_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetZeroBasedItem(Base):
|
||||||
|
"""零基预算逐项论证项 (P2-① 2026-08-28)"""
|
||||||
|
__tablename__ = "budget_zero_based_items"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||||
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||||
|
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||||
|
item_name = Column(String(200), nullable=False, comment="费用科目名")
|
||||||
|
item_category = Column(String(20), default="discretionary", comment="fixed/variable/discretionary")
|
||||||
|
base_value = Column(Float, nullable=False, comment="基准值(上年/上月实际)")
|
||||||
|
justification = Column(Text, nullable=True, comment="逐项论证理由(为何保留/削减/取消)")
|
||||||
|
proposed_value = Column(Float, nullable=False, comment="论证后金额")
|
||||||
|
status = Column(String(20), default="draft", comment="draft/approved")
|
||||||
|
created_by = Column(String(100), nullable=True, comment="创建人")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetDerivationRule(Base):
|
||||||
|
"""KPI派生规则 — apply-method 可配置派生 (P2-② 2026-08-28)"""
|
||||||
|
__tablename__ = "budget_derivation_rules"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||||
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI")
|
||||||
|
rule_type = Column(String(30), nullable=False, comment="incremental/percentage_of/formula")
|
||||||
|
base_kpi_id = Column(Integer, nullable=True, comment="来源KPI(percentage_of用)")
|
||||||
|
params = Column(JSON, nullable=True, comment="{\"rate\":0.02,\"field\":\"net_profit\"}")
|
||||||
|
formula_text = Column(String(500), nullable=True, comment="可读公式说明")
|
||||||
|
status = Column(String(20), default="active", comment="active/inactive")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
__table_args__ = (UniqueConstraint("entity_id", "kpi_id", "rule_type", name="uk_rule"),)
|
||||||
|
|
||||||
|
|
||||||
|
class CashPlanClassifyRule(Base):
|
||||||
|
"""现金流收付分类规则 — KPI→receive/pay 可维护 (P2-⑥ 2026-08-28)"""
|
||||||
|
__tablename__ = "cash_plan_classify_rules"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||||
|
kpi_id = Column(Integer, nullable=True, comment="精确匹配KPI,优先")
|
||||||
|
kpi_code_pattern = Column(String(200), nullable=True, comment="关键词/编码模式匹配,兜底")
|
||||||
|
plan_type = Column(String(10), nullable=False, comment="receive/pay")
|
||||||
|
priority = Column(Integer, default=10, comment="匹配顺序,小优先")
|
||||||
|
status = Column(String(20), default="active", comment="active/inactive")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class CashPlanUnclassified(Base):
|
||||||
|
"""现金流待分类KPI队列 — 无法判别的KPI不静默跳过 (P2-⑥ 2026-08-28)"""
|
||||||
|
__tablename__ = "cash_plan_unclassified"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||||
|
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
|
||||||
|
kpi_name = Column(String(200), nullable=True, comment="KPI名称")
|
||||||
|
period = Column(String(20), nullable=True, comment="期间")
|
||||||
|
budget_value = Column(Float, nullable=True, comment="预算值")
|
||||||
|
reason = Column(String(200), nullable=True, comment="无法分类原因")
|
||||||
|
status = Column(String(20), default="pending", comment="pending/classified/ignored")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
resolved_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AISuggestion(Base):
|
||||||
|
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)"""
|
||||||
|
__tablename__ = "ai_suggestions"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID")
|
||||||
|
user_id = Column(Integer, nullable=True, comment="建议创建人ID")
|
||||||
|
source = Column(String(30), default="dashboard", comment="来源: dashboard/kpi/budget/manual/rule")
|
||||||
|
suggestion_type = Column(String(30), nullable=False, comment="kpi_target/budget_adjust/action_plan")
|
||||||
|
target_type = Column(String(30), nullable=False, comment="kpi/budget/action_plan")
|
||||||
|
category = Column(String(20), default="decision", comment="分类: decision决策类 / alert预警类(预警类不推送)")
|
||||||
|
pushed = Column(Integer, default=0, comment="决策类建议是否已推送企微 0/1(防轰炸)")
|
||||||
|
target_id = Column(Integer, nullable=True, comment="目标ID (KPI ID/预算KPI ID等)")
|
||||||
|
title = Column(String(300), nullable=False, comment="建议标题")
|
||||||
|
content = Column(Text, nullable=True, comment="建议内容/理由")
|
||||||
|
suggestion_data = Column(JSON, nullable=True, comment="应用参数: {target_value, period, budget_value, plan_title, ...}")
|
||||||
|
status = Column(String(20), default="unapplied", comment="unapplied/applied/dismissed")
|
||||||
|
applied_by = Column(String(100), nullable=True, comment="应用人姓名")
|
||||||
|
applied_user_id = Column(Integer, nullable=True, comment="应用人ID")
|
||||||
|
applied_at = Column(DateTime, nullable=True, comment="应用时间")
|
||||||
|
apply_detail = Column(JSON, nullable=True, comment="应用结果明细: [{target_type,target_id,action,before,after}]")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class BudgetPlan(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||||
|
map_id = Column(Integer, nullable=True, comment="归属战略地图ID (预算按地图隔离 2026-08-27)")
|
||||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||||
period = Column(String(20), nullable=False, comment="预算期间 2026-05")
|
period = Column(String(20), nullable=False, comment="预算期间 2026-05")
|
||||||
budget_value = Column(Float, nullable=False, comment="预算值")
|
budget_value = Column(Float, nullable=False, comment="预算值")
|
||||||
@@ -16,6 +17,9 @@ class BudgetPlan(Base):
|
|||||||
budget_month = Column(Integer, nullable=False, comment="预算月份 1-12")
|
budget_month = Column(Integer, nullable=False, comment="预算月份 1-12")
|
||||||
version = Column(String(20), default="v1.0", comment="版本号 v1.0/v2.0")
|
version = Column(String(20), default="v1.0", comment="版本号 v1.0/v2.0")
|
||||||
status = Column(String(20), default="active", comment="active/archived")
|
status = Column(String(20), default="active", comment="active/archived")
|
||||||
|
source_type = Column(String(50), nullable=True, comment="来源类型: manual/kpi_generated/roll_forward等")
|
||||||
|
source_kpi_id = Column(Integer, nullable=True, comment="来源KPI ID(KPI推算生成时记录)")
|
||||||
|
calc_logic = Column(Text, nullable=True, comment="预算计算逻辑说明")
|
||||||
remark = Column(String(500), nullable=True, comment="备注")
|
remark = Column(String(500), nullable=True, comment="备注")
|
||||||
created_by = Column(String(100), nullable=True)
|
created_by = Column(String(100), nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
CMA Bot API 风险分级(L1-L4)与路由标注装饰器
|
||||||
|
=============================================
|
||||||
|
|
||||||
|
分级定义(方案文档 nexa-permission-autonomy-plan-20260828 第二节):
|
||||||
|
L1 只读查询 — X-BOT-KEY验证后直接放行
|
||||||
|
L2 业务写(单条) — 放行 + 写前校验(entity归属/字段校验)
|
||||||
|
L3 批量写/创建 — 放行 + 限制批量 + source_batch审计
|
||||||
|
L4 危险 — 不向Bot API开放(DROP/TRUNCATE/批量DELETE/生产结构修改)
|
||||||
|
|
||||||
|
安全底线: Bot API面不存在L4端点(0项开放 = 天然隔离)。
|
||||||
|
"""
|
||||||
|
import functools
|
||||||
|
|
||||||
|
RISK_LEVELS = {
|
||||||
|
"L1": "只读",
|
||||||
|
"L2": "业务写",
|
||||||
|
"L3": "批量写",
|
||||||
|
"L4": "危险",
|
||||||
|
}
|
||||||
|
|
||||||
|
RISK_HANDLING = {
|
||||||
|
"L1": "X-BOT-KEY验证后直接放行",
|
||||||
|
"L2": "放行 + 写前校验(entity归属/字段校验)",
|
||||||
|
"L3": "放行 + 限制批量 + source_batch审计",
|
||||||
|
"L4": "不向Bot API开放(终端层拦截 + 人工审批)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────
|
||||||
|
# API → 风险级别 清单(Bot API面全量路由)
|
||||||
|
# 键格式: "METHOD path"(path 与 FastAPI route.path 一致,含 {param} 占位符)
|
||||||
|
# ────────────────────────────────────────────────
|
||||||
|
API_RISK_MAP = {
|
||||||
|
# ── L1 只读(20项方案清单 + risk-levels查询端点) ──
|
||||||
|
"GET /api/cma/bot/ping": "L1",
|
||||||
|
"GET /api/cma/bot/overview": "L1",
|
||||||
|
"GET /api/cma/bot/kpis": "L1",
|
||||||
|
"GET /api/cma/bot/kpis/{kpi_id}/history": "L1",
|
||||||
|
"GET /api/cma/bot/strategic-maps": "L1",
|
||||||
|
"GET /api/cma/bot/alerts": "L1",
|
||||||
|
"GET /api/cma/bot/budget/plans": "L1",
|
||||||
|
"GET /api/cma/bot/cost/standard": "L1",
|
||||||
|
"GET /api/cma/bot/cost/actual": "L1",
|
||||||
|
"GET /api/cma/bot/actions": "L1",
|
||||||
|
"GET /api/cma/bot/organization": "L1",
|
||||||
|
"GET /api/cma/bot/data-sources": "L1",
|
||||||
|
"GET /api/cma/bot/users": "L1",
|
||||||
|
"GET /api/cma/bot/query": "L1",
|
||||||
|
"GET /api/cma/bot/okr/list": "L1",
|
||||||
|
"GET /api/cma/bot/nlp": "L1",
|
||||||
|
"GET /api/cma/bot/iron-law": "L1",
|
||||||
|
"GET /api/cma/bot/iron-law/bots": "L1",
|
||||||
|
"GET /api/cma/bot-bridge/verify/{action_plan_id}/history": "L1",
|
||||||
|
"GET /api/cma/bot-kpis": "L1",
|
||||||
|
# 本任务新增的只读端点
|
||||||
|
"GET /api/cma/bot/risk-levels": "L1",
|
||||||
|
# ── L2 业务写(4项方案清单 + okr/create单条业务写) ──
|
||||||
|
"POST /api/cma/bot/kpi-value-with-check": "L2",
|
||||||
|
"POST /api/cma/bot-bridge/kpi-result": "L2",
|
||||||
|
"POST /api/cma/bot-kpis/{kpi_id}/value": "L2",
|
||||||
|
"POST /api/cma/bot-bridge/verify/{action_plan_id}": "L2",
|
||||||
|
"POST /api/cma/bot/okr/create": "L2", # 单条OKR创建(方案清单未列出,按单条业务写归类)
|
||||||
|
# ── L3 批量写/创建(3项) ──
|
||||||
|
"POST /api/cma/bot/import": "L3",
|
||||||
|
"POST /api/cma/bot/kpis/create-with-links": "L3",
|
||||||
|
"POST /api/cma/bot-bridge/mpm-result": "L3",
|
||||||
|
# ── L4 危险:Bot API面不存在(安全底线,不添加) ──
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def risk_level(level: str):
|
||||||
|
"""路由标注装饰器: @risk_level('L1') 挂在路由函数上(router.get/post 之下)。
|
||||||
|
|
||||||
|
同时把级别属性写到原函数与包装函数上,保证 route.endpoint 无论取到哪个
|
||||||
|
都能通过 getattr(endpoint, 'risk_level') 解析。
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
func.risk_level = level
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
wrapper.risk_level = level
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def get_risk_level(method: str, path: str):
|
||||||
|
"""按 METHOD + path(FastAPI模板路径)查级别,未标注返回 None"""
|
||||||
|
return API_RISK_MAP.get(f"{method.upper()} {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_handling(level: str) -> str:
|
||||||
|
"""级别 → 处理方式说明"""
|
||||||
|
return RISK_HANDLING.get(level, "")
|
||||||
|
|
||||||
|
|
||||||
|
def list_api_risk_map() -> list:
|
||||||
|
"""返回 API→级别→处理方式 清单(供 GET /api/cma/bot/risk-levels 使用)"""
|
||||||
|
items = []
|
||||||
|
for key, level in API_RISK_MAP.items():
|
||||||
|
method, path = key.split(" ", 1)
|
||||||
|
items.append({
|
||||||
|
"method": method,
|
||||||
|
"path": path,
|
||||||
|
"risk_level": level,
|
||||||
|
"handling": get_handling(level),
|
||||||
|
})
|
||||||
|
items.sort(key=lambda x: (x["risk_level"], x["method"], x["path"]))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def risk_summary() -> dict:
|
||||||
|
"""各级别端点数量统计"""
|
||||||
|
summary = {lv: 0 for lv in RISK_LEVELS}
|
||||||
|
for level in API_RISK_MAP.values():
|
||||||
|
summary[level] = summary.get(level, 0) + 1
|
||||||
|
return summary
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""因果链验证核心逻辑 — 数据验证(Pearson相关性)+ 状态机 (2026-08-27 P2)
|
||||||
|
|
||||||
|
三层验证:
|
||||||
|
1. 数据验证(自动化):kpi_values 历史值 → Pearson 相关系数 + 方向一致性 + 滞后对齐
|
||||||
|
2. AI/人工验证:战略回顾会人工打标 human_verified(API PUT /verify)
|
||||||
|
3. 状态机流转:
|
||||||
|
pending(初始)→ data_verified / disputed(数据验证 cron)
|
||||||
|
pending/data_verified/disputed → human_verified(人工确认,最终)
|
||||||
|
任何矛盾 → disputed(待检)
|
||||||
|
|
||||||
|
判定规则(可解释、可测试):
|
||||||
|
- 对齐后数据点 < MIN_POINTS(4) → pending(数据不足,无法统计验证)
|
||||||
|
- |r| >= CORR_THRESHOLD(0.5) 且方向与 direction 一致 → data_verified
|
||||||
|
- 否则(点数足够但弱相关/方向矛盾)→ disputed
|
||||||
|
- human_verified 为人工最终确认,脚本默认不覆盖(respect_human=True),
|
||||||
|
但若数据矛盾会在报告中给出警示(disputed_note)
|
||||||
|
|
||||||
|
脚本入口: scripts/correlation-check.py
|
||||||
|
测试入口: tests/test_causality_verification.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger("causality-verification")
|
||||||
|
|
||||||
|
# 验证状态
|
||||||
|
STATUS_PENDING = "pending"
|
||||||
|
STATUS_DATA_VERIFIED = "data_verified"
|
||||||
|
STATUS_HUMAN_VERIFIED = "human_verified"
|
||||||
|
STATUS_DISPUTED = "disputed"
|
||||||
|
ALL_STATUSES = (STATUS_PENDING, STATUS_DATA_VERIFIED, STATUS_HUMAN_VERIFIED, STATUS_DISPUTED)
|
||||||
|
|
||||||
|
# 建链来源
|
||||||
|
SOURCE_MANUAL = "manual"
|
||||||
|
SOURCE_AI = "AI_suggested"
|
||||||
|
SOURCE_IMPORTED = "imported"
|
||||||
|
ALL_SOURCE_TYPES = (SOURCE_MANUAL, SOURCE_AI, SOURCE_IMPORTED)
|
||||||
|
|
||||||
|
# 判定参数
|
||||||
|
MIN_POINTS = 4 # 最少对齐数据点(少于则无法统计验证)
|
||||||
|
CORR_THRESHOLD = 0.5 # |r| 阈值:达到且方向一致 → 数据证实
|
||||||
|
VERIFIER_SCRIPT = "correlation-check"
|
||||||
|
|
||||||
|
# period 粒度(对齐时只允许同粒度配对,避免月度/年度量纲混用)
|
||||||
|
GRANULARITY_ORDER = ("month", "half", "year")
|
||||||
|
|
||||||
|
_PERIOD_RE = {
|
||||||
|
"month": re.compile(r"^(\d{4})-(\d{2})$"),
|
||||||
|
"half": re.compile(r"^(\d{4})-H([12])$"),
|
||||||
|
"year": re.compile(r"^(\d{4})$"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_period(period: str) -> Optional[Tuple[str, int]]:
|
||||||
|
"""解析 period 为 (granularity, seq)。
|
||||||
|
|
||||||
|
seq = year*12 + 月序号(0-indexed),可比较/做滞后偏移。
|
||||||
|
- '2026-07' → ('month', 2026*12+6)
|
||||||
|
- '2026-H1' → ('half', 2026*12+5) (H1≈6月)
|
||||||
|
- '2026-H2' → ('half', 2026*12+11) (H2≈12月)
|
||||||
|
- '2026' → ('year', 2026*12+5) (年中)
|
||||||
|
无法解析 → None
|
||||||
|
"""
|
||||||
|
if not period:
|
||||||
|
return None
|
||||||
|
s = str(period).strip()
|
||||||
|
m = _PERIOD_RE["month"].match(s)
|
||||||
|
if m:
|
||||||
|
year, mon = int(m.group(1)), int(m.group(2))
|
||||||
|
if 1 <= mon <= 12:
|
||||||
|
return ("month", year * 12 + (mon - 1))
|
||||||
|
m = _PERIOD_RE["half"].match(s)
|
||||||
|
if m:
|
||||||
|
year, half = int(m.group(1)), int(m.group(2))
|
||||||
|
return ("half", year * 12 + (5 if half == 1 else 11))
|
||||||
|
m = _PERIOD_RE["year"].match(s)
|
||||||
|
if m:
|
||||||
|
return ("year", int(m.group(1)) * 12 + 5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pearson(xs: List[float], ys: List[float]) -> Tuple[Optional[float], int]:
|
||||||
|
"""Pearson 相关系数。点数 < 2 返回 (None, n)。"""
|
||||||
|
n = len(xs)
|
||||||
|
if n < 2:
|
||||||
|
return None, n
|
||||||
|
mx = sum(xs) / n
|
||||||
|
my = sum(ys) / n
|
||||||
|
sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
|
||||||
|
sxx = sum((x - mx) ** 2 for x in xs)
|
||||||
|
syy = sum((y - my) ** 2 for y in ys)
|
||||||
|
if sxx <= 0 or syy <= 0: # 常数列 → 无相关
|
||||||
|
return None, n
|
||||||
|
r = sxy / math.sqrt(sxx * syy)
|
||||||
|
# 数值保护:浮点误差可能略超 [-1,1]
|
||||||
|
return max(-1.0, min(1.0, r)), n
|
||||||
|
|
||||||
|
|
||||||
|
def align_series(
|
||||||
|
source_values: List[Tuple[str, float]],
|
||||||
|
target_values: List[Tuple[str, float]],
|
||||||
|
lag_months: int = 0,
|
||||||
|
) -> Tuple[Optional[str], List[Tuple[float, float]]]:
|
||||||
|
"""按滞后期对齐 source/target 时间序列,返回 (granularity, pairs)。
|
||||||
|
|
||||||
|
语义:source 是因(先发生),target 是果(滞后 lag 月出现)。
|
||||||
|
pair = (source[t], target[t + lag])。
|
||||||
|
只使用同粒度(month/half/year)数据配对,避免量纲混用。
|
||||||
|
按粒度优先级 month > half > year 选取数据点最多的粒度。
|
||||||
|
"""
|
||||||
|
parsed_src: Dict[str, Dict[int, float]] = {g: {} for g in GRANULARITY_ORDER}
|
||||||
|
parsed_tgt: Dict[str, Dict[int, float]] = {g: {} for g in GRANULARITY_ORDER}
|
||||||
|
for period, val in source_values:
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
r = parse_period(period)
|
||||||
|
if r:
|
||||||
|
g, seq = r
|
||||||
|
parsed_src[g][seq] = float(val)
|
||||||
|
for period, val in target_values:
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
r = parse_period(period)
|
||||||
|
if r:
|
||||||
|
g, seq = r
|
||||||
|
parsed_tgt[g][seq] = float(val)
|
||||||
|
|
||||||
|
best_g, best_pairs = None, []
|
||||||
|
for g in GRANULARITY_ORDER:
|
||||||
|
src_map, tgt_map = parsed_src[g], parsed_tgt[g]
|
||||||
|
pairs = []
|
||||||
|
for seq, sv in sorted(src_map.items()):
|
||||||
|
tv = tgt_map.get(seq + lag_months)
|
||||||
|
if tv is not None:
|
||||||
|
pairs.append((sv, tv))
|
||||||
|
if len(pairs) > len(best_pairs):
|
||||||
|
best_g, best_pairs = g, pairs
|
||||||
|
return best_g, best_pairs
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_chain(
|
||||||
|
source_values: List[Tuple[str, float]],
|
||||||
|
target_values: List[Tuple[str, float]],
|
||||||
|
lag_months: int = 0,
|
||||||
|
direction: str = "positive",
|
||||||
|
min_points: int = MIN_POINTS,
|
||||||
|
corr_threshold: float = CORR_THRESHOLD,
|
||||||
|
) -> dict:
|
||||||
|
"""对单条因果链做数据验证。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
granularity, n, r, expected_sign, actual_sign, direction_consistent,
|
||||||
|
status (pending/data_verified/disputed), reason
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
granularity, pairs = align_series(source_values, target_values, lag_months)
|
||||||
|
n = len(pairs)
|
||||||
|
r = None
|
||||||
|
if n >= 2:
|
||||||
|
r, _ = pearson([p[0] for p in pairs], [p[1] for p in pairs])
|
||||||
|
|
||||||
|
expected_sign = 1 if direction == "positive" else -1
|
||||||
|
actual_sign = 1 if r is not None and r > 0 else (-1 if r is not None and r < 0 else 0)
|
||||||
|
direction_consistent = r is not None and actual_sign == expected_sign
|
||||||
|
|
||||||
|
if n < min_points or r is None:
|
||||||
|
return {
|
||||||
|
"granularity": granularity, "n": n, "r": r,
|
||||||
|
"expected_sign": expected_sign, "actual_sign": actual_sign,
|
||||||
|
"direction_consistent": direction_consistent,
|
||||||
|
"status": STATUS_PENDING,
|
||||||
|
"reason": f"数据不足(对齐后{n}点,需≥{min_points}点)" if n < min_points else "序列无方差,无法计算相关性",
|
||||||
|
}
|
||||||
|
|
||||||
|
abs_r = abs(r)
|
||||||
|
if abs_r >= corr_threshold and direction_consistent:
|
||||||
|
return {
|
||||||
|
"granularity": granularity, "n": n, "r": r,
|
||||||
|
"expected_sign": expected_sign, "actual_sign": actual_sign,
|
||||||
|
"direction_consistent": True,
|
||||||
|
"status": STATUS_DATA_VERIFIED,
|
||||||
|
"reason": f"|r|={abs_r:.3f}≥{corr_threshold} 且方向一致({direction}) → 数据证实",
|
||||||
|
}
|
||||||
|
if not direction_consistent:
|
||||||
|
return {
|
||||||
|
"granularity": granularity, "n": n, "r": r,
|
||||||
|
"expected_sign": expected_sign, "actual_sign": actual_sign,
|
||||||
|
"direction_consistent": False,
|
||||||
|
"status": STATUS_DISPUTED,
|
||||||
|
"reason": f"方向矛盾: 声明{direction}但实际相关方向{'正' if r > 0 else '负'} (r={r:.3f})",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"granularity": granularity, "n": n, "r": r,
|
||||||
|
"expected_sign": expected_sign, "actual_sign": actual_sign,
|
||||||
|
"direction_consistent": True,
|
||||||
|
"status": STATUS_DISPUTED,
|
||||||
|
"reason": f"弱相关: |r|={abs_r:.3f}<{corr_threshold},数据暂不能证实该因果强度",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_state_machine(
|
||||||
|
current_status: str,
|
||||||
|
eval_status: str,
|
||||||
|
respect_human: bool = True,
|
||||||
|
) -> Tuple[str, Optional[str]]:
|
||||||
|
"""状态机:根据数据验证结果流转状态。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- human_verified 是人工最终确认: respect_human=True 时不被脚本覆盖
|
||||||
|
(返回原状态 + disputed_note 警示)
|
||||||
|
- 数据不足(pending) → 保持当前状态(不降级已有结论)
|
||||||
|
- data_verified → 覆盖为非 human_verified 的当前状态
|
||||||
|
- disputed → 覆盖为非 human_verified 的当前状态
|
||||||
|
"""
|
||||||
|
if current_status == STATUS_HUMAN_VERIFIED and respect_human:
|
||||||
|
if eval_status == STATUS_DISPUTED:
|
||||||
|
return current_status, "人工已确认但数据复核矛盾,建议重新核对"
|
||||||
|
return current_status, None
|
||||||
|
if eval_status == STATUS_PENDING:
|
||||||
|
return current_status, None
|
||||||
|
return eval_status, None
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(results: List[dict]) -> dict:
|
||||||
|
"""验证结果汇总统计。"""
|
||||||
|
counter = {s: 0 for s in ALL_STATUSES}
|
||||||
|
for r in results:
|
||||||
|
counter[r.get("status", STATUS_PENDING)] = counter.get(r.get("status", STATUS_PENDING), 0) + 1
|
||||||
|
return {
|
||||||
|
"total": len(results),
|
||||||
|
"by_status": counter,
|
||||||
|
"data_verified_ratio": round(counter[STATUS_DATA_VERIFIED] / len(results), 3) if results else 0,
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,203 @@
|
|||||||
|
"""告警归因引擎 — 管理会计OS (P1-③ 2026-08-28)
|
||||||
|
|
||||||
|
告警从"差多少"到"差在哪+怎么办":
|
||||||
|
- 子KPI维度拆解: 查 kpi_hierarchy 下级KPI各自差异(量差方向)
|
||||||
|
- 科目明细拆解: 查 kpi_subject_map → voucher_details 汇总科目发生额(价差方向)
|
||||||
|
- 趋势归因: 复用 deviation_engine.check_trend_anomaly 连续3期检测
|
||||||
|
- 场景建议: 按 alert_type 联查 scenario_suggestions
|
||||||
|
|
||||||
|
attribution JSON 结构:
|
||||||
|
{
|
||||||
|
"dimensions": [{"kpi_id":1,"kpi_name":"销售费用","deviation_value":-3.2,"deviation_rate":-18.6,"weight":0.5}],
|
||||||
|
"subjects": [{"subject_code":"6601","subject_name":"销售费用","amount_diff":2.1,"share_pct":34.5}],
|
||||||
|
"variance_type": "quantity_diff|price_diff|mixed",
|
||||||
|
"trend": {"anomaly":true,"type":"continuous_decline","periods":["2026-06","2026-07","2026-08"],"message":"连续3期下滑"}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.models import KPIHierarchy, KPISubjectMap, VoucherDetail, ScenarioSuggestion, KPIDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger("cma.alert_attribution")
|
||||||
|
|
||||||
|
# 收入型KPI特征(量差方向: 子KPI量级偏离)
|
||||||
|
REVENUE_TYPE_CODES = (
|
||||||
|
"SALES_TOTAL", "REVENUE", "F_REVENUE", "SALES_PROFIT_RATE",
|
||||||
|
"CUSTOMER_COUNT", "NEW_CUSTOMER", "TURNOVER_RATE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dimension_attribution(db: Session, kpi_id: int, period: str) -> list:
|
||||||
|
"""子KPI维度拆解 — 查 kpi_hierarchy 下级KPI各自差异(实际vs预算)"""
|
||||||
|
from app.models import KPIValue, BudgetPlan
|
||||||
|
|
||||||
|
children = db.query(KPIHierarchy).filter(
|
||||||
|
KPIHierarchy.parent_kpi_id == kpi_id
|
||||||
|
).all()
|
||||||
|
if not children:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for rel in children:
|
||||||
|
child_id = rel.child_kpi_id
|
||||||
|
actual = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == child_id,
|
||||||
|
KPIValue.period == period,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
|
budget = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.kpi_id == child_id,
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
).order_by(BudgetPlan.updated_at.desc()).first()
|
||||||
|
|
||||||
|
av = actual.actual_value if actual else None
|
||||||
|
bv = budget.budget_value if budget else None
|
||||||
|
dev_value = None
|
||||||
|
dev_rate = None
|
||||||
|
if av is not None and bv is not None and bv != 0:
|
||||||
|
dev_value = round(av - bv, 2)
|
||||||
|
dev_rate = round(dev_value / bv * 100, 2)
|
||||||
|
|
||||||
|
child_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == child_id).first()
|
||||||
|
result.append({
|
||||||
|
"kpi_id": child_id,
|
||||||
|
"kpi_name": child_kpi.kpi_name if child_kpi else f"KPI-{child_id}",
|
||||||
|
"actual_value": av,
|
||||||
|
"budget_value": bv,
|
||||||
|
"deviation_value": dev_value,
|
||||||
|
"deviation_rate": dev_rate,
|
||||||
|
"weight": float(rel.weight or 0),
|
||||||
|
})
|
||||||
|
# 按偏差绝对值降序,最异常的排前面
|
||||||
|
result.sort(key=lambda x: -(abs(x["deviation_value"]) if x["deviation_value"] is not None else 0))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_subject_attribution(db: Session, kpi_id: int, period: str) -> list:
|
||||||
|
"""科目明细拆解 — 查 kpi_subject_map → voucher_details 汇总科目发生额"""
|
||||||
|
mappings = db.query(KPISubjectMap).filter(KPISubjectMap.kpi_id == kpi_id).all()
|
||||||
|
if not mappings:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for m in mappings:
|
||||||
|
q = db.query(
|
||||||
|
func.coalesce(func.sum(VoucherDetail.debit_amount), 0),
|
||||||
|
func.coalesce(func.sum(VoucherDetail.credit_amount), 0),
|
||||||
|
).filter(
|
||||||
|
VoucherDetail.subject_code == m.subject_code,
|
||||||
|
VoucherDetail.period == period,
|
||||||
|
)
|
||||||
|
row = q.first()
|
||||||
|
debit_sum = float(row[0] or 0)
|
||||||
|
credit_sum = float(row[1] or 0)
|
||||||
|
# 方向: credit贷方(收入/流入) / debit借方(费用/流出)
|
||||||
|
if m.calc_type == "ratio":
|
||||||
|
amount = credit_sum - debit_sum
|
||||||
|
elif m.calc_type in ("avg", "other"):
|
||||||
|
amount = (credit_sum - debit_sum) / 2
|
||||||
|
else: # sum
|
||||||
|
amount = credit_sum - debit_sum
|
||||||
|
amount = round(amount * float(m.weight or 1.0), 2)
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"subject_code": m.subject_code,
|
||||||
|
"subject_name": m.remark or m.subject_code,
|
||||||
|
"amount_diff": amount,
|
||||||
|
"calc_type": m.calc_type,
|
||||||
|
"weight": float(m.weight or 1.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
total = sum(abs(r["amount_diff"]) for r in result) or 0
|
||||||
|
for r in result:
|
||||||
|
r["share_pct"] = round(abs(r["amount_diff"]) / total * 100, 1) if total else 0
|
||||||
|
result.sort(key=lambda x: -abs(x["amount_diff"]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def detect_variance_type(kpi_code: str, dimensions: list, subjects: list) -> str:
|
||||||
|
"""量价差判定简化版:
|
||||||
|
成本型KPI科目发生额偏离 → price_diff(价差)
|
||||||
|
收入型KPI子KPI量级偏离 → quantity_diff(量差)
|
||||||
|
两者都有 → mixed
|
||||||
|
"""
|
||||||
|
has_dimension_dev = any(d.get("deviation_value") is not None and abs(d["deviation_value"]) > 0.01 for d in dimensions)
|
||||||
|
has_subject_dev = any(abs(s.get("amount_diff", 0)) > 0.01 for s in subjects)
|
||||||
|
|
||||||
|
is_revenue = any(code in (kpi_code or "").upper() for code in REVENUE_TYPE_CODES)
|
||||||
|
|
||||||
|
if is_revenue:
|
||||||
|
# 收入型: 子KPI(量)偏离为主 → quantity_diff
|
||||||
|
if has_dimension_dev:
|
||||||
|
return "quantity_diff"
|
||||||
|
if has_subject_dev:
|
||||||
|
return "price_diff"
|
||||||
|
return "mixed"
|
||||||
|
else:
|
||||||
|
# 成本型: 科目发生额(价)偏离为主 → price_diff
|
||||||
|
if has_subject_dev:
|
||||||
|
return "price_diff"
|
||||||
|
if has_dimension_dev:
|
||||||
|
return "quantity_diff"
|
||||||
|
return "mixed"
|
||||||
|
|
||||||
|
|
||||||
|
def match_scenario(db: Session, alert_type: Optional[str]) -> Optional[dict]:
|
||||||
|
"""按 alert_type 取 scenario_suggestions 建议(四类模板)"""
|
||||||
|
if not alert_type:
|
||||||
|
return None
|
||||||
|
s = db.query(ScenarioSuggestion).filter(
|
||||||
|
ScenarioSuggestion.alert_type == alert_type
|
||||||
|
).order_by(ScenarioSuggestion.sort_order.asc(), ScenarioSuggestion.id.asc()).first()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"scenario_id": s.id,
|
||||||
|
"alert_type": s.alert_type,
|
||||||
|
"title": s.title,
|
||||||
|
"description": s.description,
|
||||||
|
"action_template": s.action_template,
|
||||||
|
"priority": s.priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_attribution(db: Session, kpi_id: int, period: str, alert_type: Optional[str] = None) -> dict:
|
||||||
|
"""组装完整归因JSON(供告警生成/详情接口共用)"""
|
||||||
|
from app.utils.deviation_engine import check_trend_anomaly
|
||||||
|
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
|
kpi_code = kpi.kpi_code if kpi else ""
|
||||||
|
|
||||||
|
dimensions = build_dimension_attribution(db, kpi_id, period)
|
||||||
|
subjects = build_subject_attribution(db, kpi_id, period)
|
||||||
|
variance_type = detect_variance_type(kpi_code, dimensions, subjects)
|
||||||
|
trend = check_trend_anomaly(db, kpi_id, period, consecutive=3)
|
||||||
|
|
||||||
|
# 默认场景归类(未显式传入时按KPI名称特征推断)
|
||||||
|
if not alert_type:
|
||||||
|
alert_type = infer_alert_type(kpi_code, kpi.kpi_name if kpi else "")
|
||||||
|
|
||||||
|
attribution = {
|
||||||
|
"dimensions": dimensions,
|
||||||
|
"subjects": subjects,
|
||||||
|
"variance_type": variance_type,
|
||||||
|
"trend": trend,
|
||||||
|
}
|
||||||
|
return attribution, alert_type
|
||||||
|
|
||||||
|
|
||||||
|
def infer_alert_type(kpi_code: str = "", kpi_name: str = "") -> str:
|
||||||
|
"""按KPI特征推断告警场景类型(四类: cash_low/cash_critical/cost_high/revenue_drop)"""
|
||||||
|
text = (kpi_code or "").upper() + (kpi_name or "")
|
||||||
|
if any(k in text for k in ("CASH", "现金", "货币资金", "资金")):
|
||||||
|
return "cash_low"
|
||||||
|
if any(k in text for k in ("COST", "费用", "成本", "支出")):
|
||||||
|
return "cost_high"
|
||||||
|
if any(k in text for k in ("REVENUE", "收入", "销售", "营收")):
|
||||||
|
return "revenue_drop"
|
||||||
|
return "cost_high"
|
||||||
@@ -5,7 +5,7 @@ from app.database import get_session_local
|
|||||||
from app.models import KPIDefinition, KPIValue
|
from app.models import KPIDefinition, KPIValue
|
||||||
|
|
||||||
ERP_API = "http://127.0.0.1:8300"
|
ERP_API = "http://127.0.0.1:8300"
|
||||||
ERP_KEY = os.environ.get("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
ERP_KEY = os.environ.get("ERP_API_KEY", "")
|
||||||
|
|
||||||
async def _get(url: str, params: dict = None):
|
async def _get(url: str, params: dict = None):
|
||||||
async with httpx.AsyncClient(timeout=20) as c:
|
async with httpx.AsyncClient(timeout=20) as c:
|
||||||
|
|||||||
@@ -680,7 +680,21 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
|
|||||||
days_late = (today - p.plan_date).days
|
days_late = (today - p.plan_date).days
|
||||||
msg = (f"【到期未收款】应收款{p.counterparty or '客户'} {p.amount:.1f}万 "
|
msg = (f"【到期未收款】应收款{p.counterparty or '客户'} {p.amount:.1f}万 "
|
||||||
f"原计划{p.plan_date.strftime('%Y-%m-%d')}到期,已逾期{days_late}天未收回")
|
f"原计划{p.plan_date.strftime('%Y-%m-%d')}到期,已逾期{days_late}天未收回")
|
||||||
if _exists(msg):
|
# 2026-08-30修复: 按plan_id去重(消息含"已逾期X天"动态文本,全文匹配会每天生成重复预警)
|
||||||
|
existing_plan_alert = db.query(KPIAlert).filter(
|
||||||
|
KPIAlert.alert_type == "cash_plan",
|
||||||
|
KPIAlert.status.in_(["pending", "processing"]),
|
||||||
|
).all()
|
||||||
|
dup = False
|
||||||
|
for ea in existing_plan_alert:
|
||||||
|
try:
|
||||||
|
es = _json.loads(ea.suggestion) if ea.suggestion else {}
|
||||||
|
if isinstance(es, dict) and es.get("plan_id") == p.id:
|
||||||
|
dup = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if dup:
|
||||||
continue
|
continue
|
||||||
alert = KPIAlert(
|
alert = KPIAlert(
|
||||||
kpi_id=ar_kpi.id,
|
kpi_id=ar_kpi.id,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation, KPI
|
|||||||
logger = logging.getLogger("cma.cost")
|
logger = logging.getLogger("cma.cost")
|
||||||
|
|
||||||
ERP_API = "http://127.0.0.1:8300"
|
ERP_API = "http://127.0.0.1:8300"
|
||||||
ERP_KEY = os.environ.get("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
ERP_KEY = os.environ.get("ERP_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -8,15 +8,40 @@
|
|||||||
4. 差异预警触发(集成到现有预警系统)
|
4. 差异预警触发(集成到现有预警系统)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.database import get_session_local
|
from app.database import get_session_local
|
||||||
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan
|
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
|
||||||
|
|
||||||
logger = logging.getLogger("cma.deviation")
|
logger = logging.getLogger("cma.deviation")
|
||||||
|
|
||||||
|
|
||||||
|
# 越高越好型KPI默认列表(P2-⑤ 2026-08-28: 提为 system_configs 可配置)
|
||||||
|
DEFAULT_HIGHER_BETTER = [
|
||||||
|
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
|
||||||
|
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
|
||||||
|
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
|
||||||
|
]
|
||||||
|
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
|
||||||
|
|
||||||
|
|
||||||
|
def get_higher_better_codes(db, entity_id: int = None) -> list:
|
||||||
|
"""读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)"""
|
||||||
|
cfg = db.query(SystemConfig).filter(
|
||||||
|
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||||
|
).first()
|
||||||
|
if cfg and cfg.config_value:
|
||||||
|
try:
|
||||||
|
codes = json.loads(cfg.config_value)
|
||||||
|
if isinstance(codes, list):
|
||||||
|
return [str(c) for c in codes]
|
||||||
|
except Exception:
|
||||||
|
logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER)
|
||||||
|
return list(DEFAULT_HIGHER_BETTER)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 差异计算
|
# 差异计算
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -210,8 +235,83 @@ def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> d
|
|||||||
# 差异预警触发
|
# 差异预警触发
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: float = 10.0) -> dict:
|
||||||
|
"""统一告警构建 — 双出口共享一套逻辑 (P2-⑤ 2026-08-28)
|
||||||
|
|
||||||
|
预算告警(budget_deviation_alerts) 与 KPIAlert 都调用本函数,差异仅级别映射:
|
||||||
|
- budget 出口: warning/critical @ 20/50
|
||||||
|
- KPIAlert 出口: yellow/red @ 10/30
|
||||||
|
归因(P1-③): attribution 拆解 + 场景建议 由 alert_attribution 组装。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
triggered: bool 是否触发
|
||||||
|
level: budget出口级别 warning/critical
|
||||||
|
kpi_alert_level: KPIAlert出口级别 yellow/red
|
||||||
|
deviation: calc_period_deviation 结果
|
||||||
|
suggestion: 模板建议文案
|
||||||
|
alert_type: 归因场景类型
|
||||||
|
attribution: 归因JSON dict
|
||||||
|
scenario_id: 场景建议ID
|
||||||
|
"""
|
||||||
|
from app.utils.alert_attribution import build_attribution, match_scenario
|
||||||
|
|
||||||
|
deviation = calc_period_deviation(db, kpi.id, period)
|
||||||
|
if deviation.get("deviation_rate") is None:
|
||||||
|
return {"triggered": False}
|
||||||
|
|
||||||
|
rate = abs(deviation["deviation_rate"])
|
||||||
|
actual = deviation.get("actual_value")
|
||||||
|
budget = deviation.get("budget_value")
|
||||||
|
|
||||||
|
# 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better)
|
||||||
|
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
|
||||||
|
|
||||||
|
if higher_better:
|
||||||
|
# 实际低于预算才是问题
|
||||||
|
if not (actual is not None and budget is not None and actual < budget and rate >= min_rate):
|
||||||
|
return {"triggered": False}
|
||||||
|
suggestion = (
|
||||||
|
f"实际值低于预算 {rate}%,建议分析业务量未达预期的原因(子KPI拆解见归因),"
|
||||||
|
f"制定增量获客或转化提升计划"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 实际高于预算才是问题(成本型)
|
||||||
|
if not (actual is not None and budget is not None and actual > budget and rate >= min_rate):
|
||||||
|
return {"triggered": False}
|
||||||
|
suggestion = (
|
||||||
|
f"实际值超出预算 {rate}%,建议核查超支原因(科目明细拆解见归因)并采取控制措施"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 级别映射(双出口)
|
||||||
|
budget_level = "critical" if rate > 50 else "warning"
|
||||||
|
kpi_alert_level = "red" if rate >= 30 else "yellow"
|
||||||
|
|
||||||
|
# 归因组装 (P1-③)
|
||||||
|
alert_type = None
|
||||||
|
attribution = None
|
||||||
|
scenario_id = None
|
||||||
|
try:
|
||||||
|
attribution, alert_type = build_attribution(db, kpi.id, period)
|
||||||
|
scenario = match_scenario(db, alert_type)
|
||||||
|
if scenario:
|
||||||
|
scenario_id = scenario["scenario_id"]
|
||||||
|
except Exception as e: # 归因失败不阻断告警主流程
|
||||||
|
logger.warning("归因组装失败 kpi=%s: %s", kpi.kpi_code, e)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"triggered": True,
|
||||||
|
"level": budget_level,
|
||||||
|
"kpi_alert_level": kpi_alert_level,
|
||||||
|
"deviation": deviation,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
"alert_type": alert_type,
|
||||||
|
"attribution": attribution,
|
||||||
|
"scenario_id": scenario_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_deviation_check(db_session, period: str = None) -> int:
|
def run_deviation_check(db_session, period: str = None) -> int:
|
||||||
"""运行差异预警检查,返回新增预警数"""
|
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)"""
|
||||||
if period is None:
|
if period is None:
|
||||||
period = datetime.now().strftime("%Y-%m")
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
@@ -221,33 +321,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
|||||||
|
|
||||||
new_count = 0
|
new_count = 0
|
||||||
for kpi in kpis:
|
for kpi in kpis:
|
||||||
# 1. 差异预警:实际 vs 预算
|
# 1. 差异预警:实际 vs 预算(统一逻辑)
|
||||||
deviation = calc_period_deviation(db_session, kpi.id, period)
|
result = build_deviation_alert(db_session, kpi, period)
|
||||||
if deviation.get("deviation_rate") is not None:
|
if result["triggered"]:
|
||||||
rate = abs(deviation["deviation_rate"])
|
deviation = result["deviation"]
|
||||||
|
|
||||||
# 差异化阈值:越高越好型 vs 越低越好型
|
|
||||||
higher_better = kpi.kpi_code in [
|
|
||||||
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
|
|
||||||
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
|
|
||||||
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
|
|
||||||
]
|
|
||||||
|
|
||||||
if higher_better:
|
|
||||||
# 实际低于预算才是问题
|
|
||||||
if deviation["actual_value"] < deviation["budget_value"] and rate >= 10:
|
|
||||||
level = "yellow" if rate >= 10 else "green"
|
|
||||||
level = "red" if rate >= 30 else level
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# 实际高于预算才是问题(成本型)
|
|
||||||
if deviation["actual_value"] > deviation["budget_value"] and rate >= 10:
|
|
||||||
level = "yellow" if rate >= 10 else "green"
|
|
||||||
level = "red" if rate >= 30 else level
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
alert_msg = (
|
alert_msg = (
|
||||||
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
|
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
|
||||||
f"vs 预算{deviation['budget_value']},"
|
f"vs 预算{deviation['budget_value']},"
|
||||||
@@ -265,13 +342,15 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
|||||||
if not existing:
|
if not existing:
|
||||||
alert = KPIAlert(
|
alert = KPIAlert(
|
||||||
kpi_id=kpi.id,
|
kpi_id=kpi.id,
|
||||||
alert_level=level,
|
alert_level=result["kpi_alert_level"],
|
||||||
alert_message=f"[差异预警] {alert_msg}",
|
alert_message=f"[差异预警] {alert_msg}",
|
||||||
|
alert_type=result["alert_type"] or "actual",
|
||||||
|
suggestion=result["suggestion"],
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
db_session.add(alert)
|
db_session.add(alert)
|
||||||
new_count += 1
|
new_count += 1
|
||||||
logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||||
|
|
||||||
# 2. 趋势异常检测(每期检查连续3期)
|
# 2. 趋势异常检测(每期检查连续3期)
|
||||||
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
|
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 预算↔现金流↔行动 闭环自检报告
|
||||||
|
**检查时间**: 2026-08-30 12:06:44
|
||||||
|
|
||||||
|
## 账套 #1 · 期间 2026-08
|
||||||
|
- 🔴 营业收入(2026-08)
|
||||||
|
预算 75 / 实际 150000 = 执行率 200000.0%(超预算)
|
||||||
|
现金流计划: 0 条 | 行动方案: 11 条
|
||||||
|
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
|
||||||
|
💡 预算执行率200000%异常,请同步现金流情况核对(营业收入 2026-08)
|
||||||
|
- 🟡 净利润(2026-08)
|
||||||
|
预算 16.67 / 实际 0 = 执行率 0.0%(低执行)
|
||||||
|
现金流计划: 0 条 | 行动方案: 3 条
|
||||||
|
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
|
||||||
|
💡 预算执行率0%异常,请同步现金流情况核对(净利润 2026-08)
|
||||||
|
- 🔴 渠补率(2026-08)
|
||||||
|
预算 12.78 / 实际 75 = 执行率 586.9%(超预算)
|
||||||
|
现金流计划: 0 条 | 行动方案: 4 条
|
||||||
|
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
|
||||||
|
💡 预算执行率587%异常,请同步现金流情况核对(渠补率 2026-08)
|
||||||
|
- 🟡 经营性现金流(2026-08)
|
||||||
|
预算 16.67 / 实际 -93000 = 执行率 -557888.4%(低执行)
|
||||||
|
现金流计划: 0 条 | 行动方案: 0 条
|
||||||
|
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)、行动方案
|
||||||
|
💡 预算执行率-557888%异常,请同步现金流情况核对、行动方案(经营性现金流 2026-08)
|
||||||
|
|
||||||
|
---
|
||||||
|
共发现异常 4 项
|
||||||
@@ -28,7 +28,7 @@ try:
|
|||||||
else:
|
else:
|
||||||
s = DataSourceConfig(name='ERP系统 - 博海网络', source_type='erp',
|
s = DataSourceConfig(name='ERP系统 - 博海网络', source_type='erp',
|
||||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||||
api_key='erp-gateway-key-bhwl-2026', sync_type='batch', status='active')
|
api_key=os.getenv("ERP_API_KEY", ""), sync_type='batch', status='active')
|
||||||
db.add(s); db.commit(); db.refresh(s)
|
db.add(s); db.commit(); db.refresh(s)
|
||||||
print(f" [OK] 插入成功: id={s.id}")
|
print(f" [OK] 插入成功: id={s.id}")
|
||||||
for r in db.query(DataSourceConfig).all():
|
for r in db.query(DataSourceConfig).all():
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ try:
|
|||||||
name='ERP系统 - 博海网络',
|
name='ERP系统 - 博海网络',
|
||||||
source_type='erp',
|
source_type='erp',
|
||||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||||
api_key='erp-gateway-key-bhwl-2026',
|
api_key=os.getenv("ERP_API_KEY", ""),
|
||||||
sync_type='batch',
|
sync_type='batch',
|
||||||
status='active',
|
status='active',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ try:
|
|||||||
name='ERP系统 - 博海网络',
|
name='ERP系统 - 博海网络',
|
||||||
source_type='erp',
|
source_type='erp',
|
||||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||||
api_key='erp-gateway-key-bhwl-2026',
|
api_key=os.getenv("ERP_API_KEY", ""),
|
||||||
sync_type='batch',
|
sync_type='batch',
|
||||||
status='active',
|
status='active',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ try:
|
|||||||
name='ERP系统 - 博海网络',
|
name='ERP系统 - 博海网络',
|
||||||
source_type='erp',
|
source_type='erp',
|
||||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||||
api_key='erp-gateway-key-bhwl-2026',
|
api_key=os.getenv("ERP_API_KEY", ""),
|
||||||
sync_type='batch',
|
sync_type='batch',
|
||||||
status='active',
|
status='active',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ try:
|
|||||||
name='ERP系统 - 博海网络',
|
name='ERP系统 - 博海网络',
|
||||||
source_type='erp',
|
source_type='erp',
|
||||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||||
api_key='erp-gateway-key-bhwl-2026',
|
api_key=os.getenv("ERP_API_KEY", ""),
|
||||||
sync_type='batch',
|
sync_type='batch',
|
||||||
status='active',
|
status='active',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def generate_weekly_report():
|
|||||||
failed = sum(1 for p in plans if p.verify_status == "failed")
|
failed = sum(1 for p in plans if p.verify_status == "failed")
|
||||||
escalated = sum(1 for p in plans if p.verify_status == "escalated")
|
escalated = sum(1 for p in plans if p.verify_status == "escalated")
|
||||||
pending = sum(1 for p in plans if p.verify_status in ("pending", "retrying"))
|
pending = sum(1 for p in plans if p.verify_status in ("pending", "retrying"))
|
||||||
done = sum(1 for p in plans if p.status == "done")
|
done = sum(1 for p in plans if p.status == "completed") # done 不在枚举(pending/in_progress/completed/cancelled),改 completed
|
||||||
|
|
||||||
msg = (
|
msg = (
|
||||||
f"📋 行动计划执行周报({datetime.now().strftime('%Y-%m-%d')})\n"
|
f"📋 行动计划执行周报({datetime.now().strftime('%Y-%m-%d')})\n"
|
||||||
|
|||||||
@@ -52,14 +52,25 @@ def run_auto_verify():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 查最新KPI值
|
# 查最新KPI值
|
||||||
kpi = db.query(KPIDefinition).filter(
|
# 缺陷2残留修复(对齐 verify.py):从 plan 关联 KPI 向上取 entity_id,kpi_code 查询带 entity_id 过滤(防跨租户误匹配)
|
||||||
KPIDefinition.kpi_code == kpi_code
|
entity_id = None
|
||||||
).order_by(KPIDefinition.id.desc()).first()
|
if plan.kpi_id:
|
||||||
|
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||||
|
if pkpi:
|
||||||
|
entity_id = pkpi.entity_id
|
||||||
|
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
|
||||||
|
if entity_id is not None:
|
||||||
|
q = q.filter(KPIDefinition.entity_id == entity_id)
|
||||||
|
kpi = q.order_by(KPIDefinition.id.desc()).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
continue
|
continue
|
||||||
|
# 缺陷3残留修复(对齐 verify.py):KPIValue 按 period <= 当前月过滤,跨月验证不取未来期间
|
||||||
|
period_limit = now.strftime("%Y-%m")
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id
|
KPIValue.kpi_id == kpi.id,
|
||||||
).order_by(KPIValue.calculated_at.desc()).first()
|
KPIValue.actual_value.isnot(None),
|
||||||
|
KPIValue.period <= period_limit,
|
||||||
|
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
|
||||||
if not latest or latest.actual_value is None:
|
if not latest or latest.actual_value is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -74,11 +85,14 @@ def run_auto_verify():
|
|||||||
plan.kpi_current_after = actual
|
plan.kpi_current_after = actual
|
||||||
|
|
||||||
if passed:
|
if passed:
|
||||||
|
# 缺陷1残留修复(对齐 verify.py):plan 已处于"验证通过"状态(passed + verified_at 非空)则跳过 OKR 累加,防重复累加
|
||||||
|
already_verified = bool(plan.verify_status == "passed" and plan.verified_at is not None)
|
||||||
plan.verify_status = "passed"
|
plan.verify_status = "passed"
|
||||||
plan.verified_at = now
|
plan.verified_at = now
|
||||||
plan.status = "done"
|
plan.status = "completed" # 缺陷4残留修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
|
||||||
|
plan.progress = 100
|
||||||
# 阶段3: OKR progress更新
|
# 阶段3: OKR progress更新
|
||||||
update_okr_progress(db, plan, +15)
|
update_okr_progress(db, plan, +15, already_verified=already_verified)
|
||||||
messages.append(f"✅ 行动计划#{plan.id}验证通过: {plan.title} ({kpi_code}: {rule.get('baseline_value')}→{actual})")
|
messages.append(f"✅ 行动计划#{plan.id}验证通过: {plan.title} ({kpi_code}: {rule.get('baseline_value')}→{actual})")
|
||||||
verified_count += 1
|
verified_count += 1
|
||||||
else:
|
else:
|
||||||
@@ -107,16 +121,24 @@ def run_auto_verify():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def update_okr_progress(db, plan, delta):
|
def update_okr_progress(db, plan, delta, already_verified=False):
|
||||||
"""验证通过→更新所属OKR progress"""
|
"""验证通过→更新所属OKR progress
|
||||||
|
|
||||||
|
缺陷1残留修复(对齐 verify.py 2026-08-30):幂等防重复累加
|
||||||
|
- already_verified=True(plan 已处于验证通过状态且 verified_at 非空)→ 跳过累加,保持原值
|
||||||
|
"""
|
||||||
okr_id = getattr(plan, "okr_id", None) or getattr(plan, "objective_id", None)
|
okr_id = getattr(plan, "okr_id", None) or getattr(plan, "objective_id", None)
|
||||||
if not okr_id:
|
if not okr_id:
|
||||||
return
|
return {"updated": False, "reason": "no_objective"}
|
||||||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
||||||
if obj:
|
if not obj:
|
||||||
|
return {"updated": False, "reason": "objective_not_found"}
|
||||||
|
if already_verified:
|
||||||
|
return {"updated": False, "reason": "already_verified", "objective_id": obj.id, "progress": obj.progress or 0}
|
||||||
current = obj.progress or 0
|
current = obj.progress or 0
|
||||||
obj.progress = min(current + delta, 100)
|
obj.progress = min(current + delta, 100)
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
|
return {"updated": True, "objective_id": obj.id, "progress": obj.progress}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""CDP e2e 续:预算管理页 → 年度分解弹窗实测"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import websocket
|
||||||
|
|
||||||
|
CDP_HTTP = "http://127.0.0.1:9222"
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(path):
|
||||||
|
with urllib.request.urlopen(CDP_HTTP + path, timeout=5) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
class CDP:
|
||||||
|
def __init__(self, ws_url):
|
||||||
|
self.ws = websocket.create_connection(ws_url, timeout=30)
|
||||||
|
self.msg_id = 0
|
||||||
|
|
||||||
|
def call(self, method, params=None):
|
||||||
|
self.msg_id += 1
|
||||||
|
mid = self.msg_id
|
||||||
|
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
|
||||||
|
while True:
|
||||||
|
resp = json.loads(self.ws.recv())
|
||||||
|
if resp.get("id") == mid:
|
||||||
|
if "error" in resp:
|
||||||
|
raise RuntimeError(f"{method}: {resp['error']}")
|
||||||
|
return resp.get("result", {})
|
||||||
|
if resp.get("method") in ("Page.loadEventFired", "Page.frameStoppedLoading"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def eval(self, expr):
|
||||||
|
r = self.call("Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True})
|
||||||
|
if r.get("exceptionDetails"):
|
||||||
|
return "EXC: " + json.dumps(r["exceptionDetails"], ensure_ascii=False)[:200]
|
||||||
|
return r.get("result", {}).get("value")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def new_page(url):
|
||||||
|
ver = http_get("/json/version")
|
||||||
|
bws = CDP(ver["webSocketDebuggerUrl"])
|
||||||
|
t = bws.call("Target.createTarget", {"url": url})
|
||||||
|
tid = t["targetId"]
|
||||||
|
bws.close()
|
||||||
|
time.sleep(2)
|
||||||
|
for p in http_get("/json"):
|
||||||
|
if p["id"] == tid:
|
||||||
|
return tid, p["webSocketDebuggerUrl"]
|
||||||
|
return tid, None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tid, ws = new_page("https://cma.sxbh.ltd/")
|
||||||
|
if not ws:
|
||||||
|
print("FAIL: no ws")
|
||||||
|
sys.exit(1)
|
||||||
|
c = CDP(ws)
|
||||||
|
c.call("Page.enable")
|
||||||
|
c.call("Runtime.enable")
|
||||||
|
time.sleep(6)
|
||||||
|
|
||||||
|
# 点击侧边栏"预算管理"
|
||||||
|
r = c.eval("""(() => {
|
||||||
|
const els = Array.from(document.querySelectorAll('a, li, span, div'));
|
||||||
|
const target = els.find(e => e.innerText && e.innerText.trim() === '预算管理' && e.offsetParent !== null);
|
||||||
|
if (!target) return 'NO_MENU';
|
||||||
|
target.click();
|
||||||
|
return 'CLICKED';
|
||||||
|
})()""")
|
||||||
|
print("MENU_CLICK:", r)
|
||||||
|
time.sleep(4)
|
||||||
|
print("URL_NOW:", c.eval("location.href"))
|
||||||
|
body = c.eval("document.body ? document.body.innerText.slice(0,400) : ''")
|
||||||
|
print("BODY:", body.replace("\n", " | ")[:400])
|
||||||
|
|
||||||
|
# 检查是否有 预算管理 页面关键元素:年份选择 + 年度分解按钮
|
||||||
|
btns = c.eval("""Array.from(document.querySelectorAll('button')).map((b,idx)=>({idx, text:(b.innerText||'').trim().slice(0,20)})).filter(x=>x.text)""")
|
||||||
|
print("BUTTONS:", json.dumps(btns, ensure_ascii=False))
|
||||||
|
|
||||||
|
c.close()
|
||||||
|
print("TARGET:", tid)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""预算↔现金流↔行动 三闭环异常自检 — 路线图R5 (2026-08-30)
|
||||||
|
|
||||||
|
预算闭环加固:预算执行率异常(<70% 或 >110%)的KPI,
|
||||||
|
检查是否同步了 现金流计划(CashPlan) 和 行动方案(ActionPlan),
|
||||||
|
缺失则输出提示(防止"预算改了,现金流/行动没跟上")。
|
||||||
|
|
||||||
|
输出:控制台 + reports/closed_loop_check_YYYYMMDD.md
|
||||||
|
用法: /root/cma-management/backend/venv/bin/python3 scripts/closed_loop_check.py [--period 2026-08] [--push]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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, BudgetPlan, CashPlan, ActionPlan
|
||||||
|
|
||||||
|
LOW_RATIO = 0.7
|
||||||
|
HIGH_RATIO = 1.1
|
||||||
|
REPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "reports")
|
||||||
|
|
||||||
|
|
||||||
|
def check_entity(db, entity_id: int, period: str) -> dict:
|
||||||
|
"""检测一个账套的闭环状态"""
|
||||||
|
issues = []
|
||||||
|
rows = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.budget_value > 0,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for b in rows:
|
||||||
|
key = (b.kpi_id, b.period)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
|
||||||
|
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
|
||||||
|
|
||||||
|
actual = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == b.kpi_id,
|
||||||
|
KPIValue.period == b.period,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
|
|
||||||
|
actual_val = actual.actual_value if actual else None
|
||||||
|
if actual_val is None:
|
||||||
|
continue
|
||||||
|
ratio = actual_val / b.budget_value
|
||||||
|
abnormal = ratio < LOW_RATIO or ratio > HIGH_RATIO
|
||||||
|
if not abnormal:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 现金流检查:该KPI该期间是否有收付款计划(related_kpi_id 或 budget_plan_id 关联)
|
||||||
|
period_start = datetime.strptime(period + "-01", "%Y-%m-%d")
|
||||||
|
if period.endswith("-12"):
|
||||||
|
period_end = datetime(period_start.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
period_end = datetime(period_start.year, period_start.month + 1, 1)
|
||||||
|
cash_plans = db.query(CashPlan).filter(
|
||||||
|
CashPlan.entity_id == entity_id,
|
||||||
|
CashPlan.status.in_(["pending", "completed"]),
|
||||||
|
CashPlan.plan_date >= period_start,
|
||||||
|
CashPlan.plan_date < period_end,
|
||||||
|
).filter(
|
||||||
|
(CashPlan.related_kpi_id == b.kpi_id) | (CashPlan.budget_plan_id == b.id)
|
||||||
|
).count()
|
||||||
|
# 兜底:无关联但期间内有任意现金流计划也算基本闭环
|
||||||
|
any_cash = db.query(CashPlan).filter(
|
||||||
|
CashPlan.entity_id == entity_id,
|
||||||
|
CashPlan.status.in_(["pending", "completed"]),
|
||||||
|
CashPlan.plan_date >= period_start,
|
||||||
|
CashPlan.plan_date < period_end,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 行动检查:该KPI是否有非完成的行动方案
|
||||||
|
actions = db.query(ActionPlan).filter(
|
||||||
|
ActionPlan.kpi_id == b.kpi_id,
|
||||||
|
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||||
|
).count()
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
if cash_plans == 0:
|
||||||
|
if any_cash > 0:
|
||||||
|
missing.append("现金流(本期间有其他计划但未关联本KPI)")
|
||||||
|
else:
|
||||||
|
missing.append("现金流")
|
||||||
|
if actions == 0:
|
||||||
|
missing.append("行动方案")
|
||||||
|
|
||||||
|
level = "critical" if ratio > HIGH_RATIO else "warning"
|
||||||
|
issues.append({
|
||||||
|
"kpi_id": b.kpi_id,
|
||||||
|
"kpi_name": kpi_name,
|
||||||
|
"period": period,
|
||||||
|
"budget_value": b.budget_value,
|
||||||
|
"actual_value": actual_val,
|
||||||
|
"exec_ratio": round(ratio * 100, 1),
|
||||||
|
"abnormal_type": "超预算" if ratio > HIGH_RATIO else "低执行",
|
||||||
|
"level": level,
|
||||||
|
"cash_plan_count": cash_plans,
|
||||||
|
"action_plan_count": actions,
|
||||||
|
"missing": missing,
|
||||||
|
"suggestion": (
|
||||||
|
f"预算执行率{ratio*100:.0f}%异常,请同步"
|
||||||
|
+ ("现金流计划" if "现金流" in missing else "现金流情况核对")
|
||||||
|
+ ("、行动方案" if "行动方案" in missing else "")
|
||||||
|
+ f"({kpi_name} {period})"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"entity_id": entity_id, "period": period, "issues": issues}
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(results: list, checked_at: str) -> str:
|
||||||
|
lines = [f"# 预算↔现金流↔行动 闭环自检报告", f"**检查时间**: {checked_at}", ""]
|
||||||
|
total_issues = 0
|
||||||
|
for r in results:
|
||||||
|
lines.append(f"## 账套 #{r['entity_id']} · 期间 {r['period']}")
|
||||||
|
if not r["issues"]:
|
||||||
|
lines.append("- ✅ 无预算执行率异常")
|
||||||
|
for it in r["issues"]:
|
||||||
|
total_issues += 1
|
||||||
|
icon = "🔴" if it["level"] == "critical" else "🟡"
|
||||||
|
lines.append(f"- {icon} {it['kpi_name']}({it['period']})")
|
||||||
|
lines.append(f" 预算 {it['budget_value']:g} / 实际 {it['actual_value']:g} = 执行率 {it['exec_ratio']}%({it['abnormal_type']})")
|
||||||
|
lines.append(f" 现金流计划: {it['cash_plan_count']} 条 | 行动方案: {it['action_plan_count']} 条")
|
||||||
|
if it["missing"]:
|
||||||
|
lines.append(f" ⚠️ 缺失: {'、'.join(it['missing'])}")
|
||||||
|
lines.append(f" 💡 {it['suggestion']}")
|
||||||
|
else:
|
||||||
|
lines.append(f" ✅ 三闭环已同步")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"---")
|
||||||
|
lines.append(f"共发现异常 {total_issues} 项")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--period", default=datetime.now().strftime("%Y-%m"))
|
||||||
|
parser.add_argument("--entity-id", type=int, default=1)
|
||||||
|
parser.add_argument("--push", action="store_true", help="异常时推送企微(8800/send)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||||||
|
checked_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
result = check_entity(db, args.entity_id, args.period)
|
||||||
|
report = build_report([result], checked_at)
|
||||||
|
print(report)
|
||||||
|
|
||||||
|
# 写报告文件
|
||||||
|
fname = f"closed_loop_check_{datetime.now().strftime('%Y%m%d')}.md"
|
||||||
|
fpath = os.path.join(REPORTS_DIR, fname)
|
||||||
|
with open(fpath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(report)
|
||||||
|
print(f"\n📄 报告已写入: {fpath}")
|
||||||
|
|
||||||
|
# 异常推送
|
||||||
|
if args.push and result["issues"]:
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
content = f"## 🔄 预算闭环自检({args.period})\n"
|
||||||
|
for it in result["issues"][:10]:
|
||||||
|
content += f"- {it['kpi_name']} 执行率{it['exec_ratio']}% 缺{'/'.join(it['missing']) or '无'}\n"
|
||||||
|
content += f"\n共{len(result['issues'])}项异常,详见系统报告"
|
||||||
|
data = urllib.parse.urlencode({"msg": content, "source": "管理会计OS"}).encode("utf-8")
|
||||||
|
req = urllib.request.Request("http://127.0.0.1:8800/send", data=data)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
print("推送:", resp.read().decode()[:200])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"推送失败: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -21,7 +21,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
|
|||||||
logger = logging.getLogger("erp_schema_collect")
|
logger = logging.getLogger("erp_schema_collect")
|
||||||
|
|
||||||
ERP_API_BASE = "http://127.0.0.1:8300/api/v1"
|
ERP_API_BASE = "http://127.0.0.1:8300/api/v1"
|
||||||
ERP_API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
ERP_API_KEY = os.getenv("ERP_API_KEY", "")
|
||||||
|
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
"X-API-Key": ERP_API_KEY,
|
"X-API-Key": ERP_API_KEY,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
|
|||||||
logger = logging.getLogger("erp_schema_v2")
|
logger = logging.getLogger("erp_schema_v2")
|
||||||
|
|
||||||
API_BASE = "http://127.0.0.1:8300/api/v1"
|
API_BASE = "http://127.0.0.1:8300/api/v1"
|
||||||
API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
API_KEY = os.getenv("ERP_API_KEY", "")
|
||||||
HEADERS = {"X-API-Key": API_KEY}
|
HEADERS = {"X-API-Key": API_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""因果链数据验证脚本 — 每月 cron 自动跑 (2026-08-27 P2)
|
||||||
|
|
||||||
|
对 kpi_causality 每条链:
|
||||||
|
取 source/target KPI 的 kpi_values 历史值
|
||||||
|
→ Pearson 相关系数 + 方向一致性 + 滞后对齐(lag_months)
|
||||||
|
→ 更新 verify_status: data_verified / disputed / pending
|
||||||
|
→ 输出验证报告 JSON + 控制台摘要
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 scripts/correlation-check.py # 全部企业,写库
|
||||||
|
python3 scripts/correlation-check.py --entity-id 1 # 指定企业
|
||||||
|
python3 scripts/correlation-check.py --dry-run # 只算不写库
|
||||||
|
|
||||||
|
月度 cron: 0 9 1 * * cd /root/cma-management/backend && python3 scripts/correlation-check.py
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy import text # noqa: E402
|
||||||
|
|
||||||
|
from app.database import get_engine # noqa: E402
|
||||||
|
from app.services.causality_verification import ( # noqa: E402
|
||||||
|
STATUS_DATA_VERIFIED,
|
||||||
|
STATUS_DISPUTED,
|
||||||
|
STATUS_HUMAN_VERIFIED,
|
||||||
|
STATUS_PENDING,
|
||||||
|
VERIFIER_SCRIPT,
|
||||||
|
apply_state_machine,
|
||||||
|
evaluate_chain,
|
||||||
|
summarize,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("correlation-check")
|
||||||
|
|
||||||
|
REPORT_DIR = Path(__file__).resolve().parent / "reports"
|
||||||
|
|
||||||
|
|
||||||
|
def load_chains(engine, entity_id: int = None) -> list:
|
||||||
|
"""加载因果链 + 两端KPI信息。"""
|
||||||
|
q = """
|
||||||
|
SELECT c.id, c.entity_id, c.source_kpi_id, c.target_kpi_id,
|
||||||
|
c.strength, c.lag_months, c.direction, c.source_type, c.verify_status,
|
||||||
|
s.kpi_code AS src_code, s.kpi_name AS src_name,
|
||||||
|
t.kpi_code AS tgt_code, t.kpi_name AS tgt_name
|
||||||
|
FROM kpi_causality c
|
||||||
|
JOIN kpi_definitions s ON s.id = c.source_kpi_id
|
||||||
|
JOIN kpi_definitions t ON t.id = c.target_kpi_id
|
||||||
|
"""
|
||||||
|
if entity_id is not None:
|
||||||
|
q += " WHERE c.entity_id = :eid"
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = conn.execute(text(q), {"eid": entity_id} if entity_id is not None else {}).mappings().all()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def load_values(engine, kpi_ids: list) -> dict:
|
||||||
|
"""加载 KPI 历史值: {kpi_id: [(period, actual_value), ...]}"""
|
||||||
|
if not kpi_ids:
|
||||||
|
return {}
|
||||||
|
ids = list(set(int(i) for i in kpi_ids))
|
||||||
|
q = """
|
||||||
|
SELECT kpi_id, period, actual_value
|
||||||
|
FROM kpi_values
|
||||||
|
WHERE kpi_id IN :ids AND actual_value IS NOT NULL
|
||||||
|
ORDER BY period
|
||||||
|
"""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = conn.execute(text(q).bindparams(ids=ids), {"ids": ids}).mappings().all()
|
||||||
|
result = {}
|
||||||
|
for r in rows:
|
||||||
|
result.setdefault(r["kpi_id"], []).append((r["period"], r["actual_value"]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="因果链数据验证")
|
||||||
|
ap.add_argument("--entity-id", type=int, default=None, help="只验证指定企业(默认全部)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="只计算不写库")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
chains = load_chains(engine, args.entity_id)
|
||||||
|
if not chains:
|
||||||
|
logger.info("无因果链,退出")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
kpi_ids = [c["source_kpi_id"] for c in chains] + [c["target_kpi_id"] for c in chains]
|
||||||
|
values = load_values(engine, kpi_ids)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
results = []
|
||||||
|
updated = {"data_verified": 0, "disputed": 0, "unchanged": 0}
|
||||||
|
notes = []
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for c in chains:
|
||||||
|
src_vals = values.get(c["source_kpi_id"], [])
|
||||||
|
tgt_vals = values.get(c["target_kpi_id"], [])
|
||||||
|
ev = evaluate_chain(
|
||||||
|
src_vals, tgt_vals,
|
||||||
|
lag_months=c["lag_months"] or 0,
|
||||||
|
direction=c["direction"] or "positive",
|
||||||
|
)
|
||||||
|
new_status, note = apply_state_machine(c["verify_status"], ev["status"], respect_human=True)
|
||||||
|
|
||||||
|
if note:
|
||||||
|
notes.append({"causality_id": c["id"], "note": note})
|
||||||
|
|
||||||
|
changed = new_status != c["verify_status"]
|
||||||
|
if changed:
|
||||||
|
updated[new_status if new_status in updated else "unchanged"] = \
|
||||||
|
updated.get(new_status if new_status in updated else "unchanged", 0) + 1
|
||||||
|
else:
|
||||||
|
updated["unchanged"] += 1
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
conn.execute(text(
|
||||||
|
"UPDATE kpi_causality SET verify_status = :st, verified_at = :va, verified_by = :vb "
|
||||||
|
"WHERE id = :cid"
|
||||||
|
), {
|
||||||
|
"st": new_status, "va": now, "vb": VERIFIER_SCRIPT, "cid": c["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"causality_id": c["id"],
|
||||||
|
"source": f'{c["src_code"]}({c["src_name"]})',
|
||||||
|
"target": f'{c["tgt_code"]}({c["tgt_name"]})',
|
||||||
|
"direction": c["direction"],
|
||||||
|
"lag_months": c["lag_months"],
|
||||||
|
"strength": c["strength"],
|
||||||
|
"granularity": ev["granularity"],
|
||||||
|
"n_points": ev["n"],
|
||||||
|
"r": round(ev["r"], 4) if ev["r"] is not None else None,
|
||||||
|
"direction_consistent": ev["direction_consistent"],
|
||||||
|
"old_status": c["verify_status"],
|
||||||
|
"new_status": new_status,
|
||||||
|
"reason": ev["reason"],
|
||||||
|
})
|
||||||
|
|
||||||
|
summary = summarize([{"status": r["new_status"]} for r in results])
|
||||||
|
report = {
|
||||||
|
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"script": VERIFIER_SCRIPT,
|
||||||
|
"dry_run": args.dry_run,
|
||||||
|
"entity_id": args.entity_id,
|
||||||
|
"summary": summary,
|
||||||
|
"updated": updated,
|
||||||
|
"human_verified_notes": notes,
|
||||||
|
"chains": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
REPORT_DIR.mkdir(exist_ok=True)
|
||||||
|
report_path = REPORT_DIR / f"causality_verification_{now.strftime('%Y%m%d_%H%M%S')}.json"
|
||||||
|
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
# 控制台摘要(cron 输出即消息)
|
||||||
|
lines = [
|
||||||
|
f"因果链数据验证{'[dry-run]' if args.dry_run else ''} {now.strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
f"总数: {summary['total']} | 数据证实: {summary['by_status'][STATUS_DATA_VERIFIED]} | "
|
||||||
|
f"存疑: {summary['by_status'][STATUS_DISPUTED]} | 待检(数据不足): {summary['by_status'][STATUS_PENDING]} | "
|
||||||
|
f"人工确认: {summary['by_status'][STATUS_HUMAN_VERIFIED]}",
|
||||||
|
f"本次更新: data_verified={updated['data_verified']} disputed={updated['disputed']} unchanged={updated['unchanged']}",
|
||||||
|
]
|
||||||
|
verified = [r for r in results if r["new_status"] == STATUS_DATA_VERIFIED]
|
||||||
|
disputed = [r for r in results if r["new_status"] == STATUS_DISPUTED]
|
||||||
|
if verified:
|
||||||
|
lines.append("── 数据证实 ──")
|
||||||
|
for r in verified:
|
||||||
|
lines.append(f" #{r['causality_id']} {r['source']}→{r['target']} r={r['r']} n={r['n_points']}")
|
||||||
|
if disputed:
|
||||||
|
lines.append("── 数据存疑 ──")
|
||||||
|
for r in disputed:
|
||||||
|
lines.append(f" #{r['causality_id']} {r['source']}→{r['target']} r={r['r']} n={r['n_points']} ({r['reason']})")
|
||||||
|
if notes:
|
||||||
|
lines.append("── 人工确认链的数据警示 ──")
|
||||||
|
for n in notes:
|
||||||
|
lines.append(f" #{n['causality_id']}: {n['note']}")
|
||||||
|
lines.append(f"报告: {report_path}")
|
||||||
|
print("\n".join(lines))
|
||||||
|
logger.info("报告已写入 %s", report_path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""每日数据找人推送 — 路线图R2 (2026-08-30)
|
||||||
|
|
||||||
|
北极星③:主动推送扩大 —— 异常 + 机会两类。
|
||||||
|
- 异常类:待处理预警(kpi_alerts pending)
|
||||||
|
- 机会类:KPI向好 / 预算余量 / 预测上行(opportunity_detector)
|
||||||
|
复用企微通道 8800/send(公司群中继服务)。
|
||||||
|
|
||||||
|
用法: /root/cma-management/backend/venv/bin/python3 scripts/daily_push.py [--dry-run]
|
||||||
|
cron: 15 9 * * * (alert_generator 9:00 之后)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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, KPIAlert
|
||||||
|
from scripts.opportunity_detector import detect_all, flatten
|
||||||
|
|
||||||
|
logger = logging.getLogger("cma.daily_push")
|
||||||
|
|
||||||
|
RELAY_URL = "http://127.0.0.1:8800/send"
|
||||||
|
SOURCE = "管理会计OS"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_exceptions(db, limit: int = 10) -> list:
|
||||||
|
"""异常类:待处理预警(red/yellow)"""
|
||||||
|
out = []
|
||||||
|
alerts = db.query(KPIAlert).filter(
|
||||||
|
KPIAlert.status == "pending",
|
||||||
|
KPIAlert.alert_level.in_(["red", "yellow"]),
|
||||||
|
).order_by(KPIAlert.created_at.desc()).limit(limit).all()
|
||||||
|
for a in alerts:
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
|
||||||
|
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
|
||||||
|
icon = "🔴" if a.alert_level == "red" else "🟡"
|
||||||
|
out.append({
|
||||||
|
"type": "exception",
|
||||||
|
"title": f"{icon} {kpi_name} 预警",
|
||||||
|
"detail": f"({a.alert_level}) {a.alert_message}",
|
||||||
|
"kpi_id": a.kpi_id,
|
||||||
|
"kpi_name": kpi_name,
|
||||||
|
"period": "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(exceptions: list, opportunities: list) -> str:
|
||||||
|
"""组装 markdown 推送内容"""
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
lines = [f"## 📊 管理会计OS · 每日经营播报", f"**{now}**", ""]
|
||||||
|
|
||||||
|
lines.append("### ⚠️ 异常关注")
|
||||||
|
if exceptions:
|
||||||
|
for e in exceptions:
|
||||||
|
lines.append(f"- {e['title']}")
|
||||||
|
lines.append(f" {e['detail']}")
|
||||||
|
else:
|
||||||
|
lines.append("- 今日无待处理预警 ✅")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### 🎯 机会发现")
|
||||||
|
if opportunities:
|
||||||
|
for o in opportunities:
|
||||||
|
lines.append(f"- {o['title']}")
|
||||||
|
lines.append(f" {o['detail']}")
|
||||||
|
else:
|
||||||
|
lines.append("- 今日暂无显著机会")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("💡 数据找人:异常要处理,机会要把握。详情见 CMA 系统。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def push_wecom(msg: str) -> dict:
|
||||||
|
"""通过8800中继推送企微"""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"msg": msg,
|
||||||
|
"source": SOURCE,
|
||||||
|
"msgtype": "markdown",
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(RELAY_URL, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
result = json.loads(resp.read().decode("utf-8"))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"推送异常: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="只打印不推送")
|
||||||
|
parser.add_argument("--entity-id", type=int, default=1)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
exceptions = collect_exceptions(db)
|
||||||
|
opportunities = flatten(detect_all(db, args.entity_id))
|
||||||
|
msg = build_message(exceptions, opportunities)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(msg)
|
||||||
|
print(f"\n[DRY-RUN] 异常{len(exceptions)}条 / 机会{len(opportunities)}条")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = push_wecom(msg)
|
||||||
|
print(f"推送结果: {json.dumps(result, ensure_ascii=False)}")
|
||||||
|
print(f"统计: 异常{len(exceptions)}条 / 机会{len(opportunities)}条")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
main()
|
||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger("erp_sync")
|
|||||||
|
|
||||||
# ERP API 配置
|
# ERP API 配置
|
||||||
ERP_API_BASE = os.getenv("ERP_API_BASE", "http://127.0.0.1:8300/api/v1")
|
ERP_API_BASE = os.getenv("ERP_API_BASE", "http://127.0.0.1:8300/api/v1")
|
||||||
ERP_API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
ERP_API_KEY = os.getenv("ERP_API_KEY", "")
|
||||||
|
|
||||||
# 无DB会话时使用的静态映射(保底,使用真实KPI编码)
|
# 无DB会话时使用的静态映射(保底,使用真实KPI编码)
|
||||||
# 对应 data_source_config 表的 active 端点(id=1,2,3,4)
|
# 对应 data_source_config 表的 active 端点(id=1,2,3,4)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
|
|||||||
logger = logging.getLogger("schema_fix")
|
logger = logging.getLogger("schema_fix")
|
||||||
|
|
||||||
API_BASE = "http://127.0.0.1:8300/api/v1"
|
API_BASE = "http://127.0.0.1:8300/api/v1"
|
||||||
API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
|
API_KEY = os.getenv("ERP_API_KEY", "")
|
||||||
HEADERS = {"X-API-Key": API_KEY}
|
HEADERS = {"X-API-Key": API_KEY}
|
||||||
|
|
||||||
def api_get(path):
|
def api_get(path):
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""生成网银流水标准导入模板 xlsx — 列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要
|
||||||
|
|
||||||
|
用法: python scripts/gen_voucher_import_template.py
|
||||||
|
输出: backend/scripts/templates/网银流水导入模板.xlsx(表头 + 1行示例)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
OUT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates", "网银流水导入模板.xlsx")
|
||||||
|
|
||||||
|
HEADERS = ["凭证日期", "凭证号", "科目编码", "科目名称", "借方金额", "贷方金额", "摘要"]
|
||||||
|
# 1行示例(借贷平衡)
|
||||||
|
EXAMPLE = ["2026-08-01", "记-001", "1002", "银行存款-工行", 50000, 0, "收到客户回款"]
|
||||||
|
# 附赠一行结转示例行(注释说明用,不写入数据行)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "网银流水"
|
||||||
|
|
||||||
|
# 表头样式
|
||||||
|
header_font = Font(bold=True, color="FFFFFF")
|
||||||
|
header_fill = PatternFill("solid", fgColor="409EFF")
|
||||||
|
for col, h in enumerate(HEADERS, start=1):
|
||||||
|
cell = ws.cell(row=1, column=col, value=h)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
|
||||||
|
for col, val in enumerate(EXAMPLE, start=1):
|
||||||
|
cell = ws.cell(row=2, column=col, value=val)
|
||||||
|
cell.alignment = Alignment(horizontal="center" if col in (1, 3, 5, 6) else "left")
|
||||||
|
|
||||||
|
# 列宽
|
||||||
|
widths = [14, 12, 12, 22, 12, 12, 30]
|
||||||
|
for i, w in enumerate(widths, start=1):
|
||||||
|
ws.column_dimensions[get_column_letter(i)].width = w
|
||||||
|
|
||||||
|
# 说明sheet
|
||||||
|
note = wb.create_sheet("填写说明")
|
||||||
|
notes = [
|
||||||
|
["网银流水标准导入模板 — 填写说明"],
|
||||||
|
[""],
|
||||||
|
["1. 列说明(与凭证明细表 voucher_details 对齐):"],
|
||||||
|
[" 凭证日期: YYYY-MM-DD(必填,用于提取期间period)"],
|
||||||
|
[" 凭证号: 字符串(必填,如 记-001 / 银收-20260801-001)"],
|
||||||
|
[" 科目编码: 必填,如 1001库存现金 / 1002银行存款"],
|
||||||
|
[" 科目名称: 必填,如 银行存款-工行 / 库存现金"],
|
||||||
|
[" 借方金额: 数字,无则留空或0(与贷方二选一)"],
|
||||||
|
[" 贷方金额: 数字,无则留空或0(与借方二选一)"],
|
||||||
|
[" 摘要: 可选,含'结转'或科目名含'本年利润'的行将标记为结转行,不参与现金流计算"],
|
||||||
|
[""],
|
||||||
|
["2. 三校验规则(导入时自动执行):"],
|
||||||
|
[" ① 借贷平衡: 全文件Σ借方 = Σ贷方(容差0.01),不平衡将提示差额"],
|
||||||
|
[" ② 期间合计: 按期间(YYYY-MM)汇总借贷合计,供对账"],
|
||||||
|
[" ③ 结转行识别: 摘要含'结转' 或 科目名含'本年利润'/'结转' → carry_forward标记"],
|
||||||
|
[""],
|
||||||
|
["3. 现金流联动:货币资金科目(1001/1002开头)期末余额自动更新现金余额与EXT_现金类KPI、F_CASH_SAFETY现金安全垫"],
|
||||||
|
["4. 示例行(第2行)请删除后填入真实流水;不要修改表头列名"],
|
||||||
|
]
|
||||||
|
for row in notes:
|
||||||
|
note.append(row)
|
||||||
|
note.column_dimensions["A"].width = 90
|
||||||
|
|
||||||
|
wb.save(OUT_PATH)
|
||||||
|
print(f"模板已生成: {OUT_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""KPI实际值自动归集采集器 — 管理会计OS (P1-④ 2026-08-28)
|
||||||
|
|
||||||
|
按 kpi_value_sources 取数映射,从源头表(科目余额/进销存/现金流水)自动汇总写入 kpi_values。
|
||||||
|
- 源头: voucher_details(网银凭证明细) / product_inventory(库存汇总) / product_inventory_detail(库存明细) / cash_plans(收付款计划)
|
||||||
|
- 严格按 entity_id + period 过滤,避免跨账套/跨期串数
|
||||||
|
- 幂等: 同 kpi_id+period 已有 auto_collect 记录则更新;人工 excel/manual 写入不覆盖
|
||||||
|
- 调度: 系统 crontab 每日 06:30 (参考 auto_verify_cron.py 模式)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
/usr/bin/python3 scripts/kpi_value_collector.py # 全量采集当月
|
||||||
|
/usr/bin/python3 scripts/kpi_value_collector.py 2026-08 # 指定期间
|
||||||
|
/usr/bin/python3 scripts/kpi_value_collector.py 2026-08 5 # 指定期间+KPI
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("cma.kpi_collector")
|
||||||
|
|
||||||
|
# 保证从 backend 目录直接运行时能 import app
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.database import get_session_local
|
||||||
|
from app.models import (
|
||||||
|
KPIValueSource, KPIValueCollectLog, KPIValue, KPIDefinition,
|
||||||
|
VoucherDetail, CashPlan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 源头表 → ORM模型映射(动态 import 避免循环依赖)
|
||||||
|
def _source_model(table: str):
|
||||||
|
if table == "voucher_details":
|
||||||
|
return VoucherDetail
|
||||||
|
if table == "cash_plans":
|
||||||
|
return CashPlan
|
||||||
|
# product_inventory / product_inventory_detail 无ORM模型 → SQLAlchemy Table 反射
|
||||||
|
from sqlalchemy import Table, MetaData
|
||||||
|
from app.database import get_engine
|
||||||
|
md = MetaData()
|
||||||
|
return Table(table, md, autoload_with=get_engine())
|
||||||
|
|
||||||
|
|
||||||
|
def _field_expression(model, field: str, aggregate: str = "sum"):
|
||||||
|
"""聚合表达式: sum/avg/count/max/min"""
|
||||||
|
col = getattr(model, field)
|
||||||
|
if aggregate == "count":
|
||||||
|
return func.count(col)
|
||||||
|
if aggregate == "avg":
|
||||||
|
return func.avg(col)
|
||||||
|
if aggregate == "max":
|
||||||
|
return func.max(col)
|
||||||
|
if aggregate == "min":
|
||||||
|
return func.min(col)
|
||||||
|
return func.sum(col)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_for_mapping(db, mapping: KPIValueSource, period: str, write_kpi: bool = True) -> Tuple[Optional[float], str]:
|
||||||
|
"""执行单条取数映射,返回 (采集值, 说明)。write_kpi=False 时为试跑模式(不写库)。"""
|
||||||
|
table = mapping.source_table
|
||||||
|
field = mapping.source_field
|
||||||
|
aggregate = mapping.aggregate or "sum"
|
||||||
|
filter_rule = mapping.filter_rule or {}
|
||||||
|
period_field = mapping.period_field or "period"
|
||||||
|
unit = mapping.unit_conversion or 1
|
||||||
|
|
||||||
|
model = _source_model(table)
|
||||||
|
|
||||||
|
# 构建查询
|
||||||
|
col = getattr(model, field, None)
|
||||||
|
if col is None:
|
||||||
|
return None, f"字段 {field} 不存在于表 {table}"
|
||||||
|
|
||||||
|
q = db.query(_field_expression(model, field, aggregate))
|
||||||
|
|
||||||
|
# entity_id 过滤(所有源头表都有)
|
||||||
|
q = q.filter(model.entity_id == mapping.entity_id)
|
||||||
|
|
||||||
|
# 期间过滤
|
||||||
|
if period_field == "voucher_date":
|
||||||
|
# voucher_date 是 DATE 类型 → 按 %Y-%m 前缀匹配
|
||||||
|
q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period)
|
||||||
|
else:
|
||||||
|
pf = getattr(model, period_field, None)
|
||||||
|
if pf is None:
|
||||||
|
return None, f"期间字段 {period_field} 不存在于表 {table}"
|
||||||
|
q = q.filter(pf == period)
|
||||||
|
|
||||||
|
# 过滤规则: subject_code / direction / plan_type / carry_forward
|
||||||
|
subject_code = filter_rule.get("subject_code")
|
||||||
|
if subject_code and hasattr(model, "subject_code"):
|
||||||
|
q = q.filter(model.subject_code == subject_code)
|
||||||
|
direction = filter_rule.get("direction")
|
||||||
|
if direction:
|
||||||
|
# direction 覆盖: credit→只算贷方, debit→只算借方
|
||||||
|
if direction == "credit" and hasattr(model, "credit_amount"):
|
||||||
|
q = db.query(_field_expression(model, "credit_amount", aggregate))
|
||||||
|
q = q.filter(model.entity_id == mapping.entity_id)
|
||||||
|
if period_field == "voucher_date":
|
||||||
|
q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period)
|
||||||
|
else:
|
||||||
|
q = q.filter(getattr(model, period_field) == period)
|
||||||
|
field = "credit_amount"
|
||||||
|
elif direction == "debit" and hasattr(model, "debit_amount"):
|
||||||
|
q = db.query(_field_expression(model, "debit_amount", aggregate))
|
||||||
|
q = q.filter(model.entity_id == mapping.entity_id)
|
||||||
|
if period_field == "voucher_date":
|
||||||
|
q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period)
|
||||||
|
else:
|
||||||
|
q = q.filter(getattr(model, period_field) == period)
|
||||||
|
field = "debit_amount"
|
||||||
|
plan_type = filter_rule.get("plan_type")
|
||||||
|
if plan_type and hasattr(model, "plan_type"):
|
||||||
|
q = q.filter(model.plan_type == plan_type)
|
||||||
|
if filter_rule.get("exclude_carry_forward") and hasattr(model, "carry_forward"):
|
||||||
|
q = q.filter(model.carry_forward == 0)
|
||||||
|
|
||||||
|
value = q.scalar()
|
||||||
|
value = float(value or 0)
|
||||||
|
value = round(value * unit, 2)
|
||||||
|
|
||||||
|
message = f"表{table}.{field} {aggregate}(period={period}) × {unit}"
|
||||||
|
if subject_code:
|
||||||
|
message += f", 科目{subject_code}"
|
||||||
|
if direction:
|
||||||
|
message += f", 方向{direction}"
|
||||||
|
if plan_type:
|
||||||
|
message += f", 类型{plan_type}"
|
||||||
|
|
||||||
|
if write_kpi:
|
||||||
|
# upsert kpi_values: 同 kpi_id+period 已有 auto_collect 记录则更新
|
||||||
|
existing = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == mapping.kpi_id,
|
||||||
|
KPIValue.period == period,
|
||||||
|
KPIValue.source_type == "auto_collect",
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.actual_value = value
|
||||||
|
existing.source_batch = _batch_no()
|
||||||
|
existing.remark = f"自动归集: {table}"
|
||||||
|
existing.data_status = "pending"
|
||||||
|
else:
|
||||||
|
db.add(KPIValue(
|
||||||
|
entity_id=mapping.entity_id,
|
||||||
|
kpi_id=mapping.kpi_id,
|
||||||
|
period=period,
|
||||||
|
actual_value=value,
|
||||||
|
source_type="auto_collect",
|
||||||
|
source_batch=_batch_no(),
|
||||||
|
data_status="pending",
|
||||||
|
remark=f"自动归集: {table}",
|
||||||
|
))
|
||||||
|
|
||||||
|
return value, message
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_no() -> str:
|
||||||
|
return f"auto-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||||
|
|
||||||
|
|
||||||
|
def run_collector(db, entity_id: Optional[int] = None, period: Optional[str] = None, kpi_id: Optional[int] = None) -> dict:
|
||||||
|
"""运行采集器:遍历 active 映射 → 汇总 → upsert kpi_values → 写采集日志"""
|
||||||
|
if period is None:
|
||||||
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
query = db.query(KPIValueSource).filter(KPIValueSource.status == "active")
|
||||||
|
if entity_id is not None:
|
||||||
|
query = query.filter(KPIValueSource.entity_id == entity_id)
|
||||||
|
if kpi_id is not None:
|
||||||
|
query = query.filter(KPIValueSource.kpi_id == kpi_id)
|
||||||
|
mappings = query.all()
|
||||||
|
|
||||||
|
if not mappings:
|
||||||
|
return {"success": True, "collected": 0, "failed": 0, "message": "无激活取数映射"}
|
||||||
|
|
||||||
|
collected, failed = 0, 0
|
||||||
|
errors = []
|
||||||
|
for m in mappings:
|
||||||
|
try:
|
||||||
|
value, message = collect_for_mapping(db, m, period, write_kpi=True)
|
||||||
|
db.add(KPIValueCollectLog(
|
||||||
|
entity_id=m.entity_id,
|
||||||
|
kpi_id=m.kpi_id,
|
||||||
|
period=period,
|
||||||
|
source_table=m.source_table,
|
||||||
|
collected_value=value,
|
||||||
|
status="success",
|
||||||
|
message=message,
|
||||||
|
))
|
||||||
|
collected += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
|
errors.append({"kpi_id": m.kpi_id, "source_table": m.source_table, "error": str(e)})
|
||||||
|
db.add(KPIValueCollectLog(
|
||||||
|
entity_id=m.entity_id,
|
||||||
|
kpi_id=m.kpi_id,
|
||||||
|
period=period,
|
||||||
|
source_table=m.source_table,
|
||||||
|
collected_value=None,
|
||||||
|
status="failed",
|
||||||
|
message=str(e)[:500],
|
||||||
|
))
|
||||||
|
logger.error("采集失败 kpi=%s table=%s: %s", m.kpi_id, m.source_table, e)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info("采集完成: 成功%s 失败%s (period=%s)", collected, failed, period)
|
||||||
|
return {
|
||||||
|
"success": failed == 0,
|
||||||
|
"collected": collected,
|
||||||
|
"failed": failed,
|
||||||
|
"period": period,
|
||||||
|
"errors": errors[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
period_arg = sys.argv[1] if len(sys.argv) > 1 else None
|
||||||
|
kpi_arg = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
r = run_collector(db, period=period_arg, kpi_id=kpi_arg)
|
||||||
|
print(f"实际值自动归集完成: 成功{r['collected']} 失败{r['failed']} (period={r.get('period')})")
|
||||||
|
for e in r.get("errors", []):
|
||||||
|
print(f" 失败: kpi={e['kpi_id']} table={e['source_table']} -> {e['error']}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""kpi_causality 因果链验证机制迁移脚本 (2026-08-27 P2)
|
||||||
|
|
||||||
|
加列:
|
||||||
|
- source_type: varchar(20) 建链来源 AI_suggested/manual/imported
|
||||||
|
- verify_status: varchar(20) 验证状态 pending/data_verified/human_verified/disputed
|
||||||
|
- verified_at: datetime 验证时间
|
||||||
|
- verified_by: varchar(50) 验证人/AI/脚本
|
||||||
|
- entity_id: int 多租户隔离 (2026-08-27 收官补齐)
|
||||||
|
|
||||||
|
幂等: 列已存在则跳过; entity_id 回填只更新 NULL/0 行。
|
||||||
|
用法: python scripts/migrate_causality_verification.py
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.database import get_engine
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("migrate-causality-verification")
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
("source_type", "ALTER TABLE kpi_causality ADD COLUMN source_type VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT '建链来源 AI_suggested/manual/imported'"),
|
||||||
|
("verify_status", "ALTER TABLE kpi_causality ADD COLUMN verify_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '验证状态 pending/data_verified/human_verified/disputed'"),
|
||||||
|
("verified_at", "ALTER TABLE kpi_causality ADD COLUMN verified_at DATETIME NULL COMMENT '验证时间'"),
|
||||||
|
("verified_by", "ALTER TABLE kpi_causality ADD COLUMN verified_by VARCHAR(50) NULL COMMENT '验证人/AI/脚本'"),
|
||||||
|
("entity_id", "ALTER TABLE kpi_causality ADD COLUMN entity_id INT NOT NULL DEFAULT 1 COMMENT '企业ID (多租户隔离 2026-08-27)'"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
engine = get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# 1. 检查表是否存在
|
||||||
|
exists = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
|
||||||
|
)).scalar()
|
||||||
|
if not exists:
|
||||||
|
logger.error("kpi_causality 表不存在,跳过")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 2. 现有列
|
||||||
|
existing = {r[0] for r in conn.execute(text(
|
||||||
|
"SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
|
||||||
|
))}
|
||||||
|
logger.info("现有列: %s", sorted(existing))
|
||||||
|
|
||||||
|
# 3. 加列(幂等)
|
||||||
|
for col, ddl in COLUMNS:
|
||||||
|
if col in existing:
|
||||||
|
logger.info("列 %s 已存在,跳过", col)
|
||||||
|
else:
|
||||||
|
conn.execute(text(ddl))
|
||||||
|
logger.info("已添加列 %s", col)
|
||||||
|
|
||||||
|
# 4. 回填 entity_id(无条件从 source KPI 对齐,纠正默认值偏差)
|
||||||
|
# 仅当来源KPI存在才回填;无来源KPI的孤儿链保持原值
|
||||||
|
conn.execute(text(
|
||||||
|
"UPDATE kpi_causality c JOIN kpi_definitions k ON k.id = c.source_kpi_id "
|
||||||
|
"SET c.entity_id = k.entity_id"
|
||||||
|
))
|
||||||
|
orphan = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM kpi_causality c LEFT JOIN kpi_definitions k ON k.id = c.source_kpi_id "
|
||||||
|
"WHERE k.id IS NULL"
|
||||||
|
)).scalar()
|
||||||
|
if orphan:
|
||||||
|
logger.warning("%d 条因果链无来源KPI(孤儿链)", orphan)
|
||||||
|
else:
|
||||||
|
logger.info("entity_id 已全部按来源KPI回填")
|
||||||
|
|
||||||
|
# 5. 验证
|
||||||
|
cols = {r[0] for r in conn.execute(text(
|
||||||
|
"SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
|
||||||
|
))}
|
||||||
|
missing = {c for c, _ in COLUMNS} - cols
|
||||||
|
if missing:
|
||||||
|
logger.error("仍有缺失列: %s", missing)
|
||||||
|
return 1
|
||||||
|
row = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM kpi_causality WHERE entity_id IS NULL OR entity_id = 0"
|
||||||
|
)).scalar()
|
||||||
|
if row:
|
||||||
|
logger.error("仍有 %d 行 entity_id 为空", row)
|
||||||
|
return 1
|
||||||
|
total = conn.execute(text("SELECT COUNT(*) FROM kpi_causality")).scalar()
|
||||||
|
logger.info("迁移完成: kpi_causality %d 条, 新列: source_type/verify_status/verified_at/verified_by/entity_id", total)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(run())
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
KR完整修复(2026-08-27) 迁移脚本:
|
||||||
|
读 strategic_maps.dimensions JSON → 同步 objectives + krs 表
|
||||||
|
复用 maps.py 的 _sync_objectives_krs 逻辑
|
||||||
|
"""
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from app.database import get_session_local
|
||||||
|
from app.models import StrategicMap, KR, Objective
|
||||||
|
from app.api.maps import _sync_objectives_krs
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
maps = db.query(StrategicMap).all()
|
||||||
|
total_kr = 0
|
||||||
|
for m in maps:
|
||||||
|
before = db.query(KR).filter(KR.entity_id == (m.entity_id or 1)).count()
|
||||||
|
_sync_objectives_krs(m, db)
|
||||||
|
after = db.query(KR).filter(KR.entity_id == (m.entity_id or 1)).count()
|
||||||
|
total_kr += (after - before)
|
||||||
|
print(f"地图[{m.id}] {m.title} (entity={m.entity_id}): KR {before} → {after}")
|
||||||
|
# 汇总
|
||||||
|
print(f"\n迁移完成: krs表总记录 = {db.query(KR).count()}")
|
||||||
|
print(f"objectives表总记录 = {db.query(Objective).count()}")
|
||||||
|
# 显示样本
|
||||||
|
rows = db.query(KR).order_by(KR.id).limit(5).all()
|
||||||
|
for kr in rows:
|
||||||
|
print(f" KR#{kr.id}: {kr.title} | op={kr.operator} | target={kr.target_value} | weight={kr.weight} | obj={kr.objective_id}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""verify-engine-fix-20260830 DB 迁移脚本
|
||||||
|
|
||||||
|
1. kpi_values.entity_id 回填:kpi_id → kpi_definitions.entity_id(现有 67 条 NULL)
|
||||||
|
2. action_plans.status 'done' → 'completed'(枚举修正;当前 0 条,防御性执行)
|
||||||
|
|
||||||
|
执行前先 SELECT 预览影响行数,再执行 UPDATE,最后回查验证。
|
||||||
|
用法: venv/bin/python3 scripts/migrate_verify_fix_20260830.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.database import get_engine
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
engine = get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# ── 1. 预览 ──
|
||||||
|
preview_null = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM kpi_values kv "
|
||||||
|
"LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id "
|
||||||
|
"WHERE kv.entity_id IS NULL AND kd.entity_id IS NOT NULL"
|
||||||
|
)).fetchone()
|
||||||
|
preview_orphan = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM kpi_values kv "
|
||||||
|
"LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id "
|
||||||
|
"WHERE kv.entity_id IS NULL AND kd.id IS NULL"
|
||||||
|
)).fetchone()
|
||||||
|
preview_done = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM action_plans WHERE status = 'done'"
|
||||||
|
)).fetchone()
|
||||||
|
|
||||||
|
print(f"[预览] 可回填(entity_id NULL 且 kpi 存在): {preview_null.cnt}")
|
||||||
|
print(f"[预览] 无法回填(kpi 不存在): {preview_orphan.cnt}")
|
||||||
|
print(f"[预览] action_plans status='done': {preview_done.cnt}")
|
||||||
|
|
||||||
|
# ── 2. 执行回填 ──
|
||||||
|
r = conn.execute(text(
|
||||||
|
"UPDATE kpi_values kv "
|
||||||
|
"JOIN kpi_definitions kd ON kv.kpi_id = kd.id "
|
||||||
|
"SET kv.entity_id = kd.entity_id "
|
||||||
|
"WHERE kv.entity_id IS NULL AND kd.entity_id IS NOT NULL"
|
||||||
|
))
|
||||||
|
print(f"[执行] kpi_values entity_id 回填 {r.rowcount} 条")
|
||||||
|
|
||||||
|
# 无法关联的孤儿行(kpi 不存在)→ 置默认 entity_id=1 并留 remark
|
||||||
|
r2 = conn.execute(text(
|
||||||
|
"UPDATE kpi_values kv "
|
||||||
|
"LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id "
|
||||||
|
"SET kv.entity_id = 1, kv.remark = CONCAT(COALESCE(kv.remark, ''), '; verify-fix-20260830 孤儿行默认entity_id=1') "
|
||||||
|
"WHERE kv.entity_id IS NULL AND kd.id IS NULL"
|
||||||
|
))
|
||||||
|
if r2.rowcount:
|
||||||
|
print(f"[执行] 孤儿行置默认 entity_id=1: {r2.rowcount} 条")
|
||||||
|
else:
|
||||||
|
print("[执行] 无孤儿行需处理")
|
||||||
|
|
||||||
|
# ── 3. status done → completed ──
|
||||||
|
r3 = conn.execute(text(
|
||||||
|
"UPDATE action_plans SET status = 'completed' WHERE status = 'done'"
|
||||||
|
))
|
||||||
|
print(f"[执行] action_plans status done→completed: {r3.rowcount} 条")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ── 4. 验证 ──
|
||||||
|
after_null = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM kpi_values WHERE entity_id IS NULL"
|
||||||
|
)).fetchone()
|
||||||
|
after_done = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM action_plans WHERE status = 'done'"
|
||||||
|
)).fetchone()
|
||||||
|
print(f"[验证] kpi_values entity_id IS NULL 残留: {after_null.cnt}(目标 0)")
|
||||||
|
print(f"[验证] action_plans status='done' 残留: {after_done.cnt}(目标 0)")
|
||||||
|
if after_null.cnt != 0:
|
||||||
|
print("[结果] ❌ 回填不彻底,请人工检查")
|
||||||
|
sys.exit(1)
|
||||||
|
print("[结果] ✅ 迁移完成")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""机会检测器 — 路线图R2 数据找人扩大 (2026-08-30)
|
||||||
|
|
||||||
|
北极星③:主动推送扩大 —— 异常 + 机会两类。
|
||||||
|
本脚本检测三类机会(复用 budget/kpi 数据,不新建表):
|
||||||
|
1. KPI向好 (kpi_improving) : 最近3期执行率>110% 且最新期呈上升趋势
|
||||||
|
2. 预算余量 (budget_headroom): 可用预算>30%(预算执行率<70%)
|
||||||
|
3. 滚动机会 (rolling_up) : 预测值上升(kpi_forecast_log 最新>上期)
|
||||||
|
|
||||||
|
输出:机会列表 [{type, title, detail, kpi_id, kpi_name, period}]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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, BudgetPlan, KpiForecastLog
|
||||||
|
|
||||||
|
HIGH_RATIO = 1.1 # 执行率>110% = 超预期
|
||||||
|
LOW_EXEC_RATIO = 0.7 # 执行率<70% = 预算余量大(可用>30%)
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_ratio(actual, target):
|
||||||
|
if target is None or target == 0:
|
||||||
|
return None
|
||||||
|
return actual / target
|
||||||
|
|
||||||
|
|
||||||
|
def detect_kpi_improving(db, entity_id: int, min_ratio: float = HIGH_RATIO) -> list:
|
||||||
|
"""KPI向好:最近3期执行率均>110%,且最新期>上期(上升中)"""
|
||||||
|
out = []
|
||||||
|
kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
).all()
|
||||||
|
now = datetime.now()
|
||||||
|
for k in kpis:
|
||||||
|
if not k.target_value or k.target_value <= 0:
|
||||||
|
continue
|
||||||
|
vals = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.period.desc()).limit(3).all()
|
||||||
|
if len(vals) < 3:
|
||||||
|
continue
|
||||||
|
ratios = [_exec_ratio(v.actual_value, k.target_value) for v in vals]
|
||||||
|
if any(r is None or r < min_ratio for r in ratios):
|
||||||
|
continue
|
||||||
|
# 最新期 > 上期(上升趋势);若最新期低于上期但整体仍>110%,也算(持续向好)
|
||||||
|
latest, prev = vals[0], vals[1]
|
||||||
|
trend = "上升" if latest.actual_value > prev.actual_value else "高位"
|
||||||
|
out.append({
|
||||||
|
"type": "kpi_improving",
|
||||||
|
"title": f"📈 {k.kpi_name} 持续向好",
|
||||||
|
"detail": (f"{latest.period}实际{latest.actual_value:g}/目标{k.target_value:g}"
|
||||||
|
f" 达成率{ratios[0]*100:.0f}%({trend}),近3期均超110%"),
|
||||||
|
"kpi_id": k.id,
|
||||||
|
"kpi_name": k.kpi_name,
|
||||||
|
"period": latest.period,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_budget_headroom(db, entity_id: int, max_ratio: float = LOW_EXEC_RATIO) -> list:
|
||||||
|
"""预算余量:当月预算执行率<70%(可用预算>30%)
|
||||||
|
|
||||||
|
注意:跳过实际值为负的行(现金流/利润为负是异常不是余量),
|
||||||
|
同 KPI 同期间多版本预算只取一条(去重)。
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
rows = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.budget_value > 0,
|
||||||
|
).all()
|
||||||
|
seen = set()
|
||||||
|
for b in rows:
|
||||||
|
key = (b.kpi_id, b.period)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
actual = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == b.kpi_id,
|
||||||
|
KPIValue.period == b.period,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
|
if not actual or actual.actual_value is None or actual.actual_value <= 0:
|
||||||
|
continue
|
||||||
|
ratio = actual.actual_value / b.budget_value
|
||||||
|
if ratio < max_ratio:
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
|
||||||
|
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
|
||||||
|
headroom = (1 - ratio) * 100
|
||||||
|
out.append({
|
||||||
|
"type": "budget_headroom",
|
||||||
|
"title": f"💼 {kpi_name} 预算余量充足",
|
||||||
|
"detail": (f"{period}预算{b.budget_value:g}/实际{actual.actual_value:g}"
|
||||||
|
f" 执行率{ratio*100:.0f}%,可用预算余量约{headroom:.0f}%"),
|
||||||
|
"kpi_id": b.kpi_id,
|
||||||
|
"kpi_name": kpi_name,
|
||||||
|
"period": period,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_rolling_up(db, entity_id: int) -> list:
|
||||||
|
"""滚动机会:预测值上升(最新预测 > 上期预测)"""
|
||||||
|
out = []
|
||||||
|
# 每个KPI取最近两条预测记录
|
||||||
|
kpi_ids = [r[0] for r in db.query(KpiForecastLog.kpi_id).filter(
|
||||||
|
KpiForecastLog.entity_id == entity_id).distinct().limit(50).all()]
|
||||||
|
for kid in kpi_ids:
|
||||||
|
rows = db.query(KpiForecastLog).filter(
|
||||||
|
KpiForecastLog.entity_id == entity_id,
|
||||||
|
KpiForecastLog.kpi_id == kid,
|
||||||
|
KpiForecastLog.forecast_value.isnot(None),
|
||||||
|
).order_by(KpiForecastLog.created_at.desc(), KpiForecastLog.id.desc()).limit(2).all()
|
||||||
|
if len(rows) < 2:
|
||||||
|
continue
|
||||||
|
latest, prev = rows[0], rows[1]
|
||||||
|
if latest.forecast_value > prev.forecast_value:
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
|
||||||
|
kpi_name = k.kpi_name if k else f"KPI#{kid}"
|
||||||
|
pct = (latest.forecast_value / prev.forecast_value - 1) * 100 if prev.forecast_value else 0
|
||||||
|
out.append({
|
||||||
|
"type": "rolling_up",
|
||||||
|
"title": f"🔮 {kpi_name} 预测上行",
|
||||||
|
"detail": (f"预测值 {prev.forecast_value:g} → {latest.forecast_value:g}"
|
||||||
|
f" (+{pct:.1f}%),{latest.period}期间"),
|
||||||
|
"kpi_id": kid,
|
||||||
|
"kpi_name": kpi_name,
|
||||||
|
"period": latest.period,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_all(db, entity_id: int = 1) -> dict:
|
||||||
|
"""检测全部机会,按类型分组"""
|
||||||
|
return {
|
||||||
|
"kpi_improving": detect_kpi_improving(db, entity_id),
|
||||||
|
"budget_headroom": detect_budget_headroom(db, entity_id),
|
||||||
|
"rolling_up": detect_rolling_up(db, entity_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(detected: dict) -> list:
|
||||||
|
out = []
|
||||||
|
for cat in ("kpi_improving", "budget_headroom", "rolling_up"):
|
||||||
|
out.extend(detected.get(cat, []))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
detected = detect_all(db)
|
||||||
|
total = sum(len(v) for v in detected.values())
|
||||||
|
print(json.dumps(detected, ensure_ascii=False, indent=2))
|
||||||
|
print(f"\n机会总数: {total}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Schema 一致性检查 — 部署门禁
|
||||||
|
检查 ORM model 与数据库表结构是否一致,输出差异并给出修复建议。
|
||||||
|
运行: python3 scripts/schema_check.py
|
||||||
|
"""
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from app.database import get_engine
|
||||||
|
import app.models
|
||||||
|
|
||||||
|
def main():
|
||||||
|
engine = get_engine()
|
||||||
|
insp = inspect(engine)
|
||||||
|
db_tables = set(insp.get_table_names())
|
||||||
|
model_tables = set(app.models.Base.metadata.tables.keys())
|
||||||
|
|
||||||
|
print("===== CMA Schema 一致性检查 =====")
|
||||||
|
print(f"数据库表: {len(db_tables)} | ORM模型表: {len(model_tables)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
issues = 0
|
||||||
|
|
||||||
|
# 1. 表缺失
|
||||||
|
missing_tables = model_tables - db_tables
|
||||||
|
if missing_tables:
|
||||||
|
issues += len(missing_tables)
|
||||||
|
print(f"❌ ORM有但数据库缺失的表: {missing_tables}")
|
||||||
|
print(" 修复: 运行 Base.metadata.create_all() 或手动建表")
|
||||||
|
else:
|
||||||
|
print("✅ 表结构齐全")
|
||||||
|
|
||||||
|
# 2. 字段差异
|
||||||
|
for tbl in sorted(model_tables & db_tables):
|
||||||
|
model_cols = set(app.models.Base.metadata.tables[tbl].columns.keys())
|
||||||
|
db_cols = set(c["name"] for c in insp.get_columns(tbl))
|
||||||
|
missing = model_cols - db_cols
|
||||||
|
extra = db_cols - model_cols
|
||||||
|
if missing:
|
||||||
|
issues += len(missing)
|
||||||
|
print(f"❌ [{tbl}] 数据库缺ORM字段: {missing}")
|
||||||
|
print(f" 修复: ALTER TABLE {tbl} ADD COLUMN ...")
|
||||||
|
if extra:
|
||||||
|
# 数据库有但ORM没有的字段 — 检查是否NOT NULL且ORM创建时不填
|
||||||
|
print(f"⚠️ [{tbl}] ORM未定义但数据库有: {extra}")
|
||||||
|
|
||||||
|
# 3. 关键表字段检查(只报真实结构差异:ORM有DB缺,或DB有ORM缺)
|
||||||
|
critical = ["kpi_definitions", "kpi_values", "strategic_maps", "budget_plans", "action_plans"]
|
||||||
|
for tbl in critical:
|
||||||
|
if tbl not in db_tables:
|
||||||
|
continue
|
||||||
|
model_cols = set(app.models.Base.metadata.tables[tbl].columns.keys())
|
||||||
|
db_cols = set(c["name"] for c in insp.get_columns(tbl))
|
||||||
|
missing = model_cols - db_cols
|
||||||
|
extra = db_cols - model_cols
|
||||||
|
if missing:
|
||||||
|
issues += len(missing)
|
||||||
|
print(f"❌ [{tbl}] 数据库缺ORM字段: {missing}")
|
||||||
|
print(f" 修复: ALTER TABLE {tbl} ADD COLUMN ...")
|
||||||
|
if extra:
|
||||||
|
# DB有ORM无 → 可能影响ORM写入/读取,提示但不阻塞
|
||||||
|
print(f"⚠️ [{tbl}] ORM未定义但数据库有: {extra}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if issues:
|
||||||
|
print(f"🚨 发现 {issues} 处差异,需处理后再部署")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("✅ Schema 一致,无阻塞问题")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""my-dashboard 隔离补漏验证: 登录 entity1 -> 工作台 -> 回查DB归属全为 entity1"""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8010"
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, data):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=json.dumps(data).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def get(path, token):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path, headers={"Authorization": "Bearer " + token}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 1. 登录 entity1
|
||||||
|
r = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
|
||||||
|
token = r.get("access_token") or r.get("token")
|
||||||
|
assert token, f"登录失败: {r}"
|
||||||
|
print("1. 登录 entity1 OK")
|
||||||
|
|
||||||
|
# 2. 工作台
|
||||||
|
d = get("/api/cma/dashboard/my-dashboard", token)
|
||||||
|
plans = d.get("action_plans", [])
|
||||||
|
reminders = d.get("reminders", [])
|
||||||
|
print(f"2. my-dashboard OK: action_plans={len(plans)} 条, reminders={len(reminders)} 条")
|
||||||
|
|
||||||
|
conn = pymysql.connect(host="127.0.0.1", user="cma_user", password="cma_pass_2026",
|
||||||
|
database="cma", charset="utf8mb4")
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 3. action_plans 全部归属 entity1
|
||||||
|
ids = [p["id"] for p in plans]
|
||||||
|
if ids:
|
||||||
|
fmt = ",".join(["%s"] * len(ids))
|
||||||
|
cur.execute(f"SELECT id, entity_id FROM action_plans WHERE id IN ({fmt})", ids)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
bad = [r for r in rows if r[1] != 1]
|
||||||
|
print(f"3. action_plans 回查DB归属: 非entity1 = {bad if bad else '无'}")
|
||||||
|
else:
|
||||||
|
print("3. action_plans 返回 0 条 (跳过)")
|
||||||
|
|
||||||
|
# 4. reminders 中 action_plan 的 related_id 归属
|
||||||
|
plan_rids = [r["related_id"] for r in reminders if r.get("related_type") == "action_plan"]
|
||||||
|
isolation_bad = False
|
||||||
|
if plan_rids:
|
||||||
|
fmt = ",".join(["%s"] * len(plan_rids))
|
||||||
|
cur.execute(f"SELECT id, entity_id FROM action_plans WHERE id IN ({fmt})", plan_rids)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
bad = [r for r in rows if r[1] != 1]
|
||||||
|
isolation_bad = bool(bad)
|
||||||
|
print(f"4. reminders.action_plan related_id 共 {len(plan_rids)} 个, 非entity1 = {bad if bad else '无'}")
|
||||||
|
else:
|
||||||
|
print("4. reminders 无 action_plan 类型 (跳过)")
|
||||||
|
|
||||||
|
# 5. reminders 类型/严重度分布 (前端标签/排序数据源)
|
||||||
|
dist = {}
|
||||||
|
for r in reminders:
|
||||||
|
key = (r.get("related_type"), r.get("type"), r.get("severity"))
|
||||||
|
dist[key] = dist.get(key, 0) + 1
|
||||||
|
print("5. reminders 类型分布:", dist)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert not isolation_bad, "发现跨账套数据泄漏"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
print("PASS: entity1 工作台数据无跨账套泄漏")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""budget-decompose-dialog-fix 独立复核:API 级实测 auto-decompose 全链路"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8010"
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, body, token=None, method="POST"):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=json.dumps(body).encode("utf-8"),
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}" if token else "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(e.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return e.code, {"detail": e.read().decode("utf-8", "ignore")}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 1. 登录(账套模式 entity_id=1)
|
||||||
|
status, login = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
|
||||||
|
token = login.get("token") or login.get("access_token")
|
||||||
|
if status != 200 or not token:
|
||||||
|
print("FAIL login:", status, login)
|
||||||
|
sys.exit(1)
|
||||||
|
print("PASS 登录成功, token 前缀:", token[:12], "...")
|
||||||
|
|
||||||
|
# 2. 调用 auto-decompose(equal 均分)
|
||||||
|
status, r = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
|
||||||
|
print("auto-decompose status:", status)
|
||||||
|
if status != 200:
|
||||||
|
print(" detail:", r.get("detail", r))
|
||||||
|
print("FAIL auto-decompose 非200(可能该年无年度预算数据)")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
print(" message:", r.get("message"))
|
||||||
|
results = r.get("results") or []
|
||||||
|
print(" created:", r.get("created"), " results数:", len(results))
|
||||||
|
for res in results[:5]:
|
||||||
|
print(" -", res.get("kpi_code"), res.get("kpi_name"),
|
||||||
|
"annual=", res.get("annual_budget"), "method=", res.get("method"),
|
||||||
|
"monthly_count=", len(res.get("monthly") or []))
|
||||||
|
if not results:
|
||||||
|
print("FAIL results 为空")
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
# 3. 验证每条结果字段完整(前端表格依赖)
|
||||||
|
required = ["kpi_code", "kpi_name", "annual_budget", "method", "monthly"]
|
||||||
|
for res in results:
|
||||||
|
missing = [k for k in required if k not in res]
|
||||||
|
if missing:
|
||||||
|
print("FAIL 结果缺字段:", missing, res)
|
||||||
|
sys.exit(4)
|
||||||
|
if not res.get("monthly"):
|
||||||
|
print("FAIL monthly 为空:", res.get("kpi_code"))
|
||||||
|
sys.exit(5)
|
||||||
|
print("PASS 所有结果字段完整(kpi_code/kpi_name/annual_budget/method/monthly)")
|
||||||
|
|
||||||
|
# 4. 幂等抽查:再调一次,结果一致
|
||||||
|
status2, r2 = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
|
||||||
|
snap1 = {res["kpi_id"]: tuple(res.get("monthly") or []) for res in results}
|
||||||
|
snap2 = {res["kpi_id"]: tuple(res.get("monthly") or []) for res in (r2.get("results") or [])}
|
||||||
|
print("PASS 二次调用幂等一致" if snap1 == snap2 else "WARN 二次调用结果不同(非幂等)")
|
||||||
|
|
||||||
|
print("\nRESULT: API 全链路通过")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""验证年度预算分解幂等 — R5 (2026-08-30)
|
||||||
|
|
||||||
|
调用 /api/cma/budget/auto-decompose 3 次,对比月度预算值是否不变。
|
||||||
|
用法: cd /root/cma-management/backend && ./venv/bin/python3 scripts/verify_decompose_idempotent.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = os.getenv("CMA_BASE", "http://127.0.0.1:8010")
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, body, token=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=json.dumps(body).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}" if token else ""},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 登录(账套模式必须 entity_id)
|
||||||
|
login = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
|
||||||
|
token = login.get("token") or login.get("access_token")
|
||||||
|
if not token:
|
||||||
|
print("❌ 登录失败:", login)
|
||||||
|
sys.exit(1)
|
||||||
|
print("✅ 登录成功")
|
||||||
|
|
||||||
|
runs = []
|
||||||
|
for i in range(3):
|
||||||
|
r = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
|
||||||
|
print(f"第{i+1}次: {r.get('message', '')} created={r.get('created', 0)}")
|
||||||
|
# 提取 (kpi_id -> monthly tuple)
|
||||||
|
snap = {}
|
||||||
|
for res in r.get("results", []):
|
||||||
|
snap[res["kpi_id"]] = tuple(res.get("monthly") or [])
|
||||||
|
runs.append(snap)
|
||||||
|
|
||||||
|
# 对比三次结果
|
||||||
|
same = runs[0] == runs[1] == runs[2]
|
||||||
|
print(f"\n三次结果一致: {'✅ 是(幂等)' if same else '❌ 否(不幂等)'}")
|
||||||
|
if not same:
|
||||||
|
for i in range(1, 3):
|
||||||
|
for kid in runs[0]:
|
||||||
|
if runs[0].get(kid) != runs[i].get(kid):
|
||||||
|
print(f" KPI {kid} 第1次={runs[0].get(kid)} 第{i+1}次={runs[i].get(kid)}")
|
||||||
|
sys.exit(0 if same else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -82,9 +82,23 @@ import hashlib
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup_db():
|
def setup_db():
|
||||||
"""每个测试函数自动初始化和清理数据库"""
|
"""每个测试函数自动初始化和清理数据库"""
|
||||||
|
from app.utils import cache as cache_util
|
||||||
|
cache_util.delete("ai") # 清AI分析缓存,防测试间Redis污染(dashboard-analysis缓存全局共享)
|
||||||
Base.metadata.create_all(bind=TEST_ENGINE)
|
Base.metadata.create_all(bind=TEST_ENGINE)
|
||||||
yield
|
yield
|
||||||
Base.metadata.drop_all(bind=TEST_ENGINE)
|
Base.metadata.drop_all(bind=TEST_ENGINE)
|
||||||
|
cache_util.delete("ai")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _disable_ai_suggestion_push(monkeypatch):
|
||||||
|
"""R1触达修复(2026-08-31): 测试库把企微推送替换为 no-op,防测试建议推真实企微群
|
||||||
|
|
||||||
|
生产环境真实推送(8800 relay);测试只验证推送逻辑(决策类推/预警不推/幂等)不打真实企微。
|
||||||
|
测试类如需断言推送内容,可自行 monkeypatch.setattr 覆盖本 no-op。
|
||||||
|
"""
|
||||||
|
from app.api import ai_analysis
|
||||||
|
monkeypatch.setattr(ai_analysis, "_push_decision_suggestion", lambda s: True)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -6,7 +6,32 @@ from fastapi.testclient import TestClient
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||||
from app.models import ActionPlan
|
from app.models import ActionPlan, Objective, KR
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_objective_kr(db: Session, **kwargs):
|
||||||
|
"""创建测试目标+KR(行动挂KR 2026-08-27)"""
|
||||||
|
obj = Objective(
|
||||||
|
entity_id=kwargs.get("entity_id", 1),
|
||||||
|
title=kwargs.get("objective_title", "测试目标"),
|
||||||
|
quarter="2026Q3",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
kr = KR(
|
||||||
|
entity_id=kwargs.get("entity_id", 1),
|
||||||
|
objective_id=obj.id,
|
||||||
|
title=kwargs.get("kr_title", "测试KR"),
|
||||||
|
operator=">=",
|
||||||
|
weight=50,
|
||||||
|
target_value=100,
|
||||||
|
)
|
||||||
|
db.add(kr)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kr)
|
||||||
|
return obj, kr
|
||||||
|
|
||||||
|
|
||||||
def create_test_plan(db: Session, **kwargs) -> ActionPlan:
|
def create_test_plan(db: Session, **kwargs) -> ActionPlan:
|
||||||
@@ -109,7 +134,7 @@ class TestActionPlans:
|
|||||||
assert data["data"][0]["title"] == "进行中"
|
assert data["data"][0]["title"] == "进行中"
|
||||||
|
|
||||||
def test_filter_by_keyword(self, client: TestClient, db: Session):
|
def test_filter_by_keyword(self, client: TestClient, db: Session):
|
||||||
"""关键词搜索"""
|
"""关键词搜索:keyword 应过滤 title(期望行为,2026-08-27 TDD示范)"""
|
||||||
user = create_test_user(db)
|
user = create_test_user(db)
|
||||||
token = get_token_for_user(client)
|
token = get_token_for_user(client)
|
||||||
kpi = create_test_kpi(db)
|
kpi = create_test_kpi(db)
|
||||||
@@ -119,8 +144,9 @@ class TestActionPlans:
|
|||||||
resp = client.get("/api/cma/action-plans?keyword=营收", headers=auth_header(token))
|
resp = client.get("/api/cma/action-plans?keyword=营收", headers=auth_header(token))
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# API当前未实现keyword过滤,返回全部2条
|
# 期望行为:keyword 过滤生效,只返回标题含"营收"的1条
|
||||||
assert len(data["data"]) == 2
|
assert len(data["data"]) == 1
|
||||||
|
assert data["data"][0]["title"] == "提升营收计划"
|
||||||
|
|
||||||
def test_update_plan(self, client: TestClient, db: Session):
|
def test_update_plan(self, client: TestClient, db: Session):
|
||||||
"""更新行动计划"""
|
"""更新行动计划"""
|
||||||
@@ -226,3 +252,90 @@ class TestActionPlans:
|
|||||||
assert data["pending"] == 1
|
assert data["pending"] == 1
|
||||||
assert data["in_progress"] == 1
|
assert data["in_progress"] == 1
|
||||||
assert data["completed"] == 1
|
assert data["completed"] == 1
|
||||||
|
|
||||||
|
# ── 行动挂KR (2026-08-27) ──
|
||||||
|
|
||||||
|
def test_create_plan_with_kr_id(self, client: TestClient, db: Session):
|
||||||
|
"""创建行动时挂KR:返回kr_id,objective_id从KR自动继承"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
obj, kr = create_test_objective_kr(db)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cma/action-plans",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={
|
||||||
|
"title": "降低费用率行动",
|
||||||
|
"kpi_id": kpi.id,
|
||||||
|
"kr_id": kr.id,
|
||||||
|
"assignee": "张三",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["kr_id"] == kr.id
|
||||||
|
# 未显式传objective_id时从KR继承
|
||||||
|
assert data["objective_id"] == obj.id
|
||||||
|
|
||||||
|
def test_create_plan_kr_not_found(self, client: TestClient, db: Session):
|
||||||
|
"""kr_id不存在 → 404"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cma/action-plans",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"title": "行动", "kpi_id": kpi.id, "kr_id": 99999},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_list_includes_kr_title(self, client: TestClient, db: Session):
|
||||||
|
"""列表返回kr_title"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
obj, kr = create_test_objective_kr(db)
|
||||||
|
create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="挂KR的行动")
|
||||||
|
|
||||||
|
resp = client.get("/api/cma/action-plans", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
item = data[0]
|
||||||
|
assert item["kr_id"] == kr.id
|
||||||
|
assert item["kr_title"] == "测试KR"
|
||||||
|
|
||||||
|
def test_update_plan_kr_id(self, client: TestClient, db: Session):
|
||||||
|
"""更新行动可改挂KR"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
obj1, kr1 = create_test_objective_kr(db, kr_title="KR一")
|
||||||
|
obj2, kr2 = create_test_objective_kr(db, kr_title="KR二")
|
||||||
|
plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr1.id, title="行动")
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/cma/action-plans/{plan.id}",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"kr_id": kr2.id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["kr_id"] == kr2.id
|
||||||
|
|
||||||
|
def test_update_plan_kr_clear(self, client: TestClient, db: Session):
|
||||||
|
"""更新行动可置空kr_id"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
obj, kr = create_test_objective_kr(db)
|
||||||
|
plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="行动")
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/cma/action-plans/{plan.id}",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"kr_id": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["kr_id"] is None
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
"""
|
||||||
|
路线图R1:AI建议→一键落地 测试
|
||||||
|
建议CRUD + 应用到KPI/预算/行动方案 + OperationLog留痕 + 已应用/未应用状态
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||||
|
from app.models import AISuggestion, KPIDefinition, BudgetPlan, ActionPlan, OperationLog, KPIValue
|
||||||
|
|
||||||
|
|
||||||
|
def _create_suggestion(client, token, kpi_id, **kw):
|
||||||
|
body = {
|
||||||
|
"suggestion_type": "kpi_target",
|
||||||
|
"target_type": "kpi",
|
||||||
|
"target_id": kpi_id,
|
||||||
|
"title": "上调测试KPI目标",
|
||||||
|
"content": "达成率超预期",
|
||||||
|
"suggestion_data": {"kpi_id": kpi_id, "target_value": 150.0},
|
||||||
|
}
|
||||||
|
body.update(kw)
|
||||||
|
return client.post("/api/cma/ai/suggestions", json=body, headers=auth_header(token))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuggestionCRUD:
|
||||||
|
def test_create_and_list(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
|
||||||
|
r = _create_suggestion(client, token, kpi.id)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()["data"]
|
||||||
|
assert data["status"] == "unapplied"
|
||||||
|
assert data["suggestion_type"] == "kpi_target"
|
||||||
|
|
||||||
|
# 列表含未应用
|
||||||
|
lst = client.get("/api/cma/ai/suggestions", headers=auth_header(token)).json()
|
||||||
|
assert lst["total"] == 1
|
||||||
|
assert lst["data"][0]["id"] == data["id"]
|
||||||
|
|
||||||
|
# 详情
|
||||||
|
det = client.get(f"/api/cma/ai/suggestions/{data['id']}", headers=auth_header(token)).json()
|
||||||
|
assert det["data"]["title"] == "上调测试KPI目标"
|
||||||
|
|
||||||
|
def test_create_missing_fields(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.post("/api/cma/ai/suggestions", json={"title": "无类型"}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
r2 = client.post("/api/cma/ai/suggestions", json={"suggestion_type": "kpi_target"}, headers=auth_header(token))
|
||||||
|
assert r2.status_code == 400
|
||||||
|
|
||||||
|
def test_apply_kpi_target(self, client, db):
|
||||||
|
"""应用建议→改KPI目标→操作日志可查"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
|
||||||
|
r = _create_suggestion(client, token, kpi.id)
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
# 应用:改KPI目标为150
|
||||||
|
app = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "kpi_target", "target_value": 150.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app.status_code == 200, app.text
|
||||||
|
app_data = app.json()["data"]
|
||||||
|
assert app_data["status"] == "applied"
|
||||||
|
assert app_data["applied_by"] == "测试管理员"
|
||||||
|
assert app_data["apply_detail"][0]["before"] == 100.0
|
||||||
|
assert app_data["apply_detail"][0]["after"] == 150.0
|
||||||
|
|
||||||
|
# KPI目标已变更
|
||||||
|
db.refresh(kpi)
|
||||||
|
assert kpi.target_value == 150.0
|
||||||
|
|
||||||
|
# OperationLog留痕
|
||||||
|
logs = db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply").all()
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0].target_type == "kpi"
|
||||||
|
assert logs[0].target_id == kpi.id
|
||||||
|
assert logs[0].detail["suggestion_id"] == sug_id
|
||||||
|
assert logs[0].detail["before"] == 100.0
|
||||||
|
assert logs[0].detail["after"] == 150.0
|
||||||
|
|
||||||
|
# 重复应用被拒绝
|
||||||
|
app2 = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "kpi_target", "target_value": 200.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app2.status_code == 400
|
||||||
|
|
||||||
|
def test_apply_budget_adjust(self, client, db):
|
||||||
|
"""应用建议→调预算(新建/更新BudgetPlan)→操作日志"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
|
||||||
|
title="调整预算", suggestion_data={"kpi_id": kpi.id})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
app = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 8888.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app.status_code == 200, app.text
|
||||||
|
plan = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == "2026-09").first()
|
||||||
|
assert plan is not None
|
||||||
|
assert plan.budget_value == 8888.0
|
||||||
|
assert plan.source_type == "ai_suggestion"
|
||||||
|
|
||||||
|
# 同期间再应用→更新而非新增
|
||||||
|
app2 = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 9999.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
# 已applied被拒;用新建议验证upsert
|
||||||
|
r2 = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
|
||||||
|
title="调整预算2", suggestion_data={"kpi_id": kpi.id})
|
||||||
|
sug_id2 = r2.json()["data"]["id"]
|
||||||
|
app3 = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id2}/apply",
|
||||||
|
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 9999.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app3.status_code == 200
|
||||||
|
plans = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == "2026-09").all()
|
||||||
|
assert len(plans) == 1
|
||||||
|
assert plans[0].budget_value == 9999.0
|
||||||
|
assert app3.json()["data"]["apply_detail"][0]["before"] == 8888.0
|
||||||
|
|
||||||
|
def test_apply_action_plan(self, client, db):
|
||||||
|
"""应用建议→建行动方案→操作日志"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_type="action_plan",
|
||||||
|
title="建行动方案", suggestion_data={"kpi_id": kpi.id})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
app = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "action_plan", "title": "营收提升专项", "assignee": "张三",
|
||||||
|
"priority": "high", "due_date": "2026-09-30"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app.status_code == 200, app.text
|
||||||
|
plan = db.query(ActionPlan).filter(ActionPlan.kpi_id == kpi.id, ActionPlan.title == "营收提升专项").first()
|
||||||
|
assert plan is not None
|
||||||
|
assert plan.assignee == "张三"
|
||||||
|
assert plan.priority == "high"
|
||||||
|
assert plan.created_by == "测试管理员"
|
||||||
|
|
||||||
|
logs = db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply",
|
||||||
|
OperationLog.target_type == "action_plan").all()
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0].target_id == plan.id
|
||||||
|
|
||||||
|
def test_dismiss(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
r = _create_suggestion(client, token, kpi.id)
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
d = client.post(f"/api/cma/ai/suggestions/{sug_id}/dismiss", headers=auth_header(token))
|
||||||
|
assert d.status_code == 200
|
||||||
|
det = client.get(f"/api/cma/ai/suggestions/{sug_id}", headers=auth_header(token)).json()
|
||||||
|
assert det["data"]["status"] == "dismissed"
|
||||||
|
# 忽略后应用被拒
|
||||||
|
app = client.post(f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "kpi_target", "target_value": 1}, headers=auth_header(token))
|
||||||
|
assert app.status_code == 400
|
||||||
|
|
||||||
|
def test_apply_not_found(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
app = client.post("/api/cma/ai/suggestions/9999/apply", json={}, headers=auth_header(token))
|
||||||
|
assert app.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuleSuggestions:
|
||||||
|
"""dashboard-analysis 自动生成建议(规则驱动)"""
|
||||||
|
|
||||||
|
def test_generate_low_ratio_action(self, client, db):
|
||||||
|
"""执行率<70% → 生成建行动方案建议"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 直接调规则生成
|
||||||
|
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
s = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
|
||||||
|
assert len(s) >= 1
|
||||||
|
assert any(x.suggestion_type == "action_plan" for x in s)
|
||||||
|
|
||||||
|
# 幂等:再调一次不重复建
|
||||||
|
resp2 = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
s2 = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
|
||||||
|
assert len(s2) == len(s)
|
||||||
|
|
||||||
|
def test_generate_high_ratio_target(self, client, db):
|
||||||
|
"""执行率>110% → 生成上调目标建议"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
s = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
|
||||||
|
assert any(x.suggestion_type == "kpi_target" for x in s)
|
||||||
|
assert "suggestions" in resp.json()
|
||||||
|
|
||||||
|
def test_generate_budget_overrun(self, client, db):
|
||||||
|
"""预算执行率>110% → 生成调预算建议"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=200.0))
|
||||||
|
db.add(BudgetPlan(entity_id=1, kpi_id=kpi.id, period="2026-08", budget_value=100.0,
|
||||||
|
budget_year=2026, budget_month=8, status="active"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
s = db.query(AISuggestion).filter(AISuggestion.suggestion_type == "budget_adjust").all()
|
||||||
|
assert len(s) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuggestionCategoryPreview:
|
||||||
|
"""R1触达修复(2026-08-31):建议分级(alert/decision) + 列表过滤 + 应用前预览 + 推送开关"""
|
||||||
|
|
||||||
|
def test_create_marks_category(self, client, db):
|
||||||
|
"""手动创建:target_type=alert → category=alert;其余 → decision"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
|
||||||
|
r_alert = _create_suggestion(client, token, kpi.id, target_type="alert",
|
||||||
|
suggestion_type="action_plan", title="预警类建议")
|
||||||
|
assert r_alert.json()["data"]["category"] == "alert"
|
||||||
|
|
||||||
|
r_decision = _create_suggestion(client, token, kpi.id, title="决策类建议")
|
||||||
|
assert r_decision.json()["data"]["category"] == "decision"
|
||||||
|
|
||||||
|
def test_category_filter(self, client, db):
|
||||||
|
"""列表接口 category 过滤"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
_create_suggestion(client, token, kpi.id, target_type="alert",
|
||||||
|
suggestion_type="action_plan", title="预警A")
|
||||||
|
_create_suggestion(client, token, kpi.id, title="决策B")
|
||||||
|
|
||||||
|
lst_alert = client.get("/api/cma/ai/suggestions", params={"category": "alert"},
|
||||||
|
headers=auth_header(token)).json()
|
||||||
|
assert lst_alert["total"] == 1
|
||||||
|
assert all(x["category"] == "alert" for x in lst_alert["data"])
|
||||||
|
|
||||||
|
lst_decision = client.get("/api/cma/ai/suggestions", params={"category": "decision"},
|
||||||
|
headers=auth_header(token)).json()
|
||||||
|
assert lst_decision["total"] == 1
|
||||||
|
assert all(x["category"] == "decision" for x in lst_decision["data"])
|
||||||
|
|
||||||
|
def test_generate_marks_decision(self, client, db):
|
||||||
|
"""规则生成:执行率<70%建议(target_type=kpi)→ category=decision"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
sug = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).first()
|
||||||
|
assert sug is not None
|
||||||
|
assert sug.category == "decision"
|
||||||
|
|
||||||
|
def test_push_disabled_in_test_env(self, client, db):
|
||||||
|
"""conftest no-op 推送(monkeypatch)→ 生成决策建议不真推企微,pushed 标记置 1"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
sug = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).first()
|
||||||
|
assert sug is not None
|
||||||
|
assert sug.pushed == 1
|
||||||
|
|
||||||
|
def test_preview_kpi_target(self, client, db):
|
||||||
|
"""preview:kpi_target 返回 当前目标 → 新目标"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
r = _create_suggestion(client, token, kpi.id,
|
||||||
|
suggestion_data={"kpi_id": kpi.id, "target_value": 150.0})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token))
|
||||||
|
assert pv.status_code == 200, pv.text
|
||||||
|
data = pv.json()["data"]
|
||||||
|
assert data["type"] == "kpi_target"
|
||||||
|
assert data["kpi_name"] == "测试KPI"
|
||||||
|
assert data["current_target"] == 100.0
|
||||||
|
assert data["new_target"] == 150.0
|
||||||
|
|
||||||
|
def test_preview_budget_adjust(self, client, db):
|
||||||
|
"""preview:budget_adjust 返回 当前预算 → 新预算"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
db.add(BudgetPlan(entity_id=1, kpi_id=kpi.id, period="2026-09", budget_value=8000.0,
|
||||||
|
budget_year=2026, budget_month=9, status="active"))
|
||||||
|
db.commit()
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
|
||||||
|
title="调预算预览", suggestion_data={"kpi_id": kpi.id, "period": "2026-09",
|
||||||
|
"budget_value": 9999.0})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token))
|
||||||
|
assert pv.status_code == 200, pv.text
|
||||||
|
data = pv.json()["data"]
|
||||||
|
assert data["type"] == "budget_adjust"
|
||||||
|
assert data["period"] == "2026-09"
|
||||||
|
assert data["current_budget"] == 8000.0
|
||||||
|
assert data["new_budget"] == 9999.0
|
||||||
|
|
||||||
|
def test_preview_action_plan(self, client, db):
|
||||||
|
"""preview:action_plan 返回计划信息"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_type="action_plan",
|
||||||
|
title="建行动方案预览", suggestion_data={"kpi_id": kpi.id,
|
||||||
|
"title": "专项改善", "priority": "high",
|
||||||
|
"due_date": "2026-09-30"})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token))
|
||||||
|
assert pv.status_code == 200, pv.text
|
||||||
|
data = pv.json()["data"]
|
||||||
|
assert data["type"] == "action_plan"
|
||||||
|
assert data["plan_title"] == "专项改善"
|
||||||
|
assert data["priority"] == "high"
|
||||||
|
assert data["due_date"] == "2026-09-30"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCategoryAndPreview:
|
||||||
|
"""R1触达修复(2026-08-31):建议分级 + 应用前预览"""
|
||||||
|
|
||||||
|
def _generate(self, client, db, kpi_id, actual, target=100.0):
|
||||||
|
"""造一条KPI数据并触发 dashboard-analysis 规则生成(避开缓存)"""
|
||||||
|
db.add(KPIValue(kpi_id=kpi_id, period="2026-07", actual_value=actual))
|
||||||
|
db.commit()
|
||||||
|
from app.utils.cache import delete as cache_delete
|
||||||
|
cache_delete("ai", f"dashboard_analysis:ceo:{kpi_id}")
|
||||||
|
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(get_token_for_user(client)))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def test_generate_marks_category(self, client, db, monkeypatch):
|
||||||
|
"""生成建议时: target_type=alert → category=alert;其余 → decision"""
|
||||||
|
from app.api import ai_analysis
|
||||||
|
pushed = []
|
||||||
|
ai_analysis._push_decision_suggestion = lambda s: pushed.append(s) or True
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
|
||||||
|
kpi_sugs = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
|
||||||
|
assert len(kpi_sugs) >= 1
|
||||||
|
for s in kpi_sugs:
|
||||||
|
assert s.category == "decision", f"KPI建议应决策类: {s.title}"
|
||||||
|
|
||||||
|
# 建一条预警 → 规则4生成 alert 类建议
|
||||||
|
from app.models import KPIAlert
|
||||||
|
db.add(KPIAlert(kpi_id=kpi.id, alert_level="yellow", alert_message="测试预警",
|
||||||
|
alert_type="threshold", status="pending"))
|
||||||
|
db.commit()
|
||||||
|
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
alert_sugs = db.query(AISuggestion).filter(AISuggestion.target_type == "alert").all()
|
||||||
|
assert len(alert_sugs) >= 1
|
||||||
|
for s in alert_sugs:
|
||||||
|
assert s.category == "alert", f"预警建议应alert类: {s.title}"
|
||||||
|
|
||||||
|
def test_alert_not_pushed_decision_pushed(self, client, db, monkeypatch):
|
||||||
|
"""推送只发决策类:预警类不推,决策类推且只推一次(pushed=1)"""
|
||||||
|
from app.api import ai_analysis
|
||||||
|
pushed = []
|
||||||
|
ai_analysis._push_decision_suggestion = lambda s: pushed.append(s) or True
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
|
||||||
|
db.commit()
|
||||||
|
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
|
||||||
|
kpi_sugs = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
|
||||||
|
assert len(pushed) >= 1
|
||||||
|
assert all(s.category == "decision" for s in pushed)
|
||||||
|
for s in pushed:
|
||||||
|
assert s.pushed == 1
|
||||||
|
|
||||||
|
# 预警类建议不在推送流
|
||||||
|
from app.models import KPIAlert
|
||||||
|
db.add(KPIAlert(kpi_id=kpi.id, alert_level="red", alert_message="测试预警2",
|
||||||
|
alert_type="threshold", status="pending"))
|
||||||
|
db.commit()
|
||||||
|
before = len(pushed)
|
||||||
|
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
alert_sugs = db.query(AISuggestion).filter(AISuggestion.target_type == "alert").all()
|
||||||
|
assert len(alert_sugs) >= 1
|
||||||
|
assert len(pushed) == before, "预警类不应触发推送"
|
||||||
|
|
||||||
|
# 幂等:重复生成不重推(同title建议不重建)
|
||||||
|
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
assert len(pushed) == before
|
||||||
|
|
||||||
|
def test_list_category_filter(self, client, db):
|
||||||
|
"""列表接口 category 过滤"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
_create_suggestion(client, token, kpi.id, title="决策类A")
|
||||||
|
_create_suggestion(client, token, kpi.id, title="决策类B")
|
||||||
|
_create_suggestion(client, token, kpi.id, title="预警类C", target_type="alert")
|
||||||
|
|
||||||
|
lst = client.get("/api/cma/ai/suggestions?category=decision", headers=auth_header(token)).json()
|
||||||
|
assert lst["total"] == 2
|
||||||
|
assert all(x["category"] == "decision" for x in lst["data"])
|
||||||
|
lst2 = client.get("/api/cma/ai/suggestions?category=alert", headers=auth_header(token)).json()
|
||||||
|
assert lst2["total"] == 1
|
||||||
|
assert lst2["data"][0]["category"] == "alert"
|
||||||
|
|
||||||
|
def test_preview_kpi_target(self, client, db):
|
||||||
|
"""preview: kpi_target 返回 current_target → new_target"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_data={"kpi_id": kpi.id, "target_value": 150.0})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token)).json()["data"]
|
||||||
|
assert pv["type"] == "kpi_target"
|
||||||
|
assert pv["kpi_name"] == kpi.kpi_name
|
||||||
|
assert pv["current_target"] == 100.0
|
||||||
|
assert pv["new_target"] == 150.0
|
||||||
|
|
||||||
|
def test_preview_budget_adjust(self, client, db):
|
||||||
|
"""preview: budget_adjust 返回 current_budget → new_budget"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
db.add(BudgetPlan(entity_id=1, kpi_id=kpi.id, period="2026-09", budget_value=5000.0,
|
||||||
|
budget_year=2026, budget_month=9, status="active"))
|
||||||
|
db.commit()
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
|
||||||
|
title="调预算", suggestion_data={"kpi_id": kpi.id, "period": "2026-09", "budget_value": 8888.0})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token)).json()["data"]
|
||||||
|
assert pv["type"] == "budget_adjust"
|
||||||
|
assert pv["current_budget"] == 5000.0
|
||||||
|
assert pv["new_budget"] == 8888.0
|
||||||
|
assert pv["period"] == "2026-09"
|
||||||
|
|
||||||
|
def test_preview_action_plan(self, client, db):
|
||||||
|
"""preview: action_plan 返回计划参数"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_type="action_plan",
|
||||||
|
title="建行动方案", suggestion_data={"kpi_id": kpi.id, "title": "改善专项",
|
||||||
|
"assignee": "李四", "priority": "high", "due_date": "2026-10-01"})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token)).json()["data"]
|
||||||
|
assert pv["type"] == "action_plan"
|
||||||
|
assert pv["plan_title"] == "改善专项"
|
||||||
|
assert pv["assignee"] == "李四"
|
||||||
|
assert pv["priority"] == "high"
|
||||||
|
assert pv["due_date"] == "2026-10-01"
|
||||||
|
|
||||||
|
def test_preview_not_found(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/ai/suggestions/99999/preview", headers=auth_header(token))
|
||||||
|
assert r.status_code == 404
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""auto-verify 验证引擎修复测试 (2026-08-30 P1)
|
||||||
|
|
||||||
|
覆盖 verify.py 4 项缺陷修复:
|
||||||
|
1. OKR progress 防重复累加(同一 plan 重复 verify 不再 +15%)
|
||||||
|
2. KPIValue 回填 entity_id 多租户隔离
|
||||||
|
3. 取 KPI 最新值按 period <= 当前月 过滤(跨月验证不取未来/历史期间)
|
||||||
|
4. status "done" → "completed"(枚举外值修正)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import ActionPlan, KPIValue, Objective
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||||
|
|
||||||
|
BASE = "/api/cma/verify"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_objective(db: Session, progress: int = 10) -> Objective:
|
||||||
|
obj = Objective(
|
||||||
|
entity_id=1,
|
||||||
|
title="测试目标",
|
||||||
|
quarter="2026Q3",
|
||||||
|
owner="任富海",
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
db.add(obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_plan(db: Session, kpi_id: int, objective_id: int, rule: dict, status: str = "pending") -> ActionPlan:
|
||||||
|
plan = ActionPlan(
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
objective_id=objective_id,
|
||||||
|
title="测试行动计划",
|
||||||
|
status=status,
|
||||||
|
priority="high",
|
||||||
|
auto_verify_rule=rule,
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _make_rule(kpi_code: str) -> dict:
|
||||||
|
return {
|
||||||
|
"kpi_code": kpi_code,
|
||||||
|
"condition": "LESS_THAN", # actual < target → passed
|
||||||
|
"target_value": 80,
|
||||||
|
"notify": False, # 测试不发企微
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestOkrIdempotent:
|
||||||
|
def test_repeat_verify_does_not_accumulate(self, client: TestClient, db: Session):
|
||||||
|
"""缺陷1修复:同一 plan 重复 verify,OKR progress 只累加一次"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="IDEM_001", kpi_name="幂等KPI")
|
||||||
|
obj = _seed_objective(db, progress=10)
|
||||||
|
plan = _seed_plan(db, kpi.id, obj.id, _make_rule("IDEM_001"))
|
||||||
|
|
||||||
|
# 第一次验证通过 → +15%
|
||||||
|
r1 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r1.json()["passed"] is True
|
||||||
|
assert r1.json()["okr_progress"]["updated"] is True
|
||||||
|
assert r1.json()["okr_progress"]["after"] == 25 # 10 + 15
|
||||||
|
|
||||||
|
# 第二次验证通过 → 不再累加(保持 25)
|
||||||
|
r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 40})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["okr_progress"]["updated"] is False
|
||||||
|
assert r2.json()["okr_progress"]["reason"] == "already_verified"
|
||||||
|
assert r2.json()["okr_progress"]["after"] == 25 # 保持原值
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.query(Objective).filter(Objective.id == obj.id).first().progress == 25
|
||||||
|
|
||||||
|
def test_force_recalc_accumulates(self, client: TestClient, db: Session):
|
||||||
|
"""缺陷1修复:force_recalc=True 保留强制重新累加入口"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="IDEM_002", kpi_name="幂等KPI2")
|
||||||
|
obj = _seed_objective(db, progress=10)
|
||||||
|
plan = _seed_plan(db, kpi.id, obj.id, _make_rule("IDEM_002"))
|
||||||
|
|
||||||
|
client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50})
|
||||||
|
r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token),
|
||||||
|
json={"actual_value": 40, "force_recalc": True})
|
||||||
|
assert r2.json()["okr_progress"]["updated"] is True
|
||||||
|
assert r2.json()["okr_progress"]["after"] == 40 # 25 + 15
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntityBackfill:
|
||||||
|
def test_kpi_value_gets_entity_id(self, client: TestClient, db: Session):
|
||||||
|
"""缺陷2修复:回填的 KPIValue 带 entity_id(与 KPI 定义一致,非默认1)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
# entity_id=2 的 KPI(模拟第二个账套)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="ENT_001", kpi_name="多租户KPI", entity_id=2)
|
||||||
|
obj = _seed_objective(db)
|
||||||
|
plan = _seed_plan(db, kpi.id, obj.id, _make_rule("ENT_001"))
|
||||||
|
|
||||||
|
r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 60})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
val = db.query(KPIValue).filter(KPIValue.source_batch == f"verify-plan-{plan.id}").first()
|
||||||
|
assert val is not None
|
||||||
|
assert val.entity_id == 2 # 从 KPI 定义继承,而非默认 1
|
||||||
|
assert val.kpi_id == kpi.id
|
||||||
|
|
||||||
|
|
||||||
|
class TestPeriodFilter:
|
||||||
|
def test_latest_value_respects_period_limit(self, client: TestClient, db: Session):
|
||||||
|
"""缺陷3修复:缺省 actual 时只取 period <= 当前月的值,不取未来期间"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="PER_001", kpi_name="期间KPI")
|
||||||
|
|
||||||
|
# 未来月(2026-09)有值 200 → GREATER_THAN 100 会通过;但当前月前(2026-07)值为 50 → 应取到 50
|
||||||
|
from datetime import datetime
|
||||||
|
db.add_all([
|
||||||
|
KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-07", actual_value=50,
|
||||||
|
source_type="manual", data_status="verified"),
|
||||||
|
KPIValue(kpi_id=kpi.id, entity_id=1, period="2099-12", actual_value=200,
|
||||||
|
source_type="manual", data_status="verified"),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
obj = _seed_objective(db)
|
||||||
|
plan = _seed_plan(db, kpi.id, obj.id, {
|
||||||
|
"kpi_code": "PER_001",
|
||||||
|
"condition": "GREATER_THAN", # actual > target
|
||||||
|
"target_value": 100,
|
||||||
|
"notify": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 缺省 actual → 应取 2026-07 的 50 → 不通过(若错误取到 2099-12 的 200 则会通过)
|
||||||
|
r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["passed"] is False
|
||||||
|
assert r.json()["kpi_current_after"] == 50
|
||||||
|
|
||||||
|
def test_explicit_period_override(self, client: TestClient, db: Session):
|
||||||
|
"""缺陷3修复:调用方显式传 period 覆盖默认当前月"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="PER_002", kpi_name="期间KPI2")
|
||||||
|
db.add_all([
|
||||||
|
KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-06", actual_value=30,
|
||||||
|
source_type="manual", data_status="verified"),
|
||||||
|
KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-07", actual_value=90,
|
||||||
|
source_type="manual", data_status="verified"),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
obj = _seed_objective(db)
|
||||||
|
plan = _seed_plan(db, kpi.id, obj.id, {
|
||||||
|
"kpi_code": "PER_002",
|
||||||
|
"condition": "GREATER_THAN",
|
||||||
|
"target_value": 50,
|
||||||
|
"notify": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 显式 period=2026-06 → 取 30 → 不通过
|
||||||
|
r1 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"period": "2026-06"})
|
||||||
|
assert r1.json()["kpi_current_after"] == 30
|
||||||
|
assert r1.json()["passed"] is False
|
||||||
|
# 显式 period=2026-07 → 取 90 → 通过
|
||||||
|
r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"period": "2026-07"})
|
||||||
|
assert r2.json()["kpi_current_after"] == 90
|
||||||
|
assert r2.json()["passed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusEnum:
|
||||||
|
def test_passed_plan_status_is_completed(self, client: TestClient, db: Session):
|
||||||
|
"""缺陷4修复:验证通过后 plan.status 写入枚举内值 completed,而非 done"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="ST_001", kpi_name="状态KPI")
|
||||||
|
obj = _seed_objective(db)
|
||||||
|
plan = _seed_plan(db, kpi.id, obj.id, _make_rule("ST_001"))
|
||||||
|
|
||||||
|
r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["passed"] is True
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
refreshed = db.query(ActionPlan).filter(ActionPlan.id == plan.id).first()
|
||||||
|
assert refreshed.status == "completed"
|
||||||
|
assert refreshed.status != "done"
|
||||||
|
assert refreshed.progress == 100
|
||||||
@@ -245,7 +245,11 @@ class TestActionsOrgSourcesUsers:
|
|||||||
|
|
||||||
def test_users(self, client: TestClient, db: Session):
|
def test_users(self, client: TestClient, db: Session):
|
||||||
"""用户列表(不返回密码等敏感字段)"""
|
"""用户列表(不返回密码等敏感字段)"""
|
||||||
create_test_user(db)
|
user = create_test_user(db)
|
||||||
|
# 多租户隔离(2026-08-31):bot_users 按 user_entities 授权表过滤,需先授权
|
||||||
|
from app.models import UserEntity
|
||||||
|
db.add(UserEntity(user_id=user.id, entity_id=1))
|
||||||
|
db.commit()
|
||||||
resp = client.get("/api/cma/bot/users", headers=BOT_KEY)
|
resp = client.get("/api/cma/bot/users", headers=BOT_KEY)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["total"] >= 1
|
assert resp.json()["total"] >= 1
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from tests.conftest import (
|
|||||||
create_test_user, get_token_for_user, auth_header,
|
create_test_user, get_token_for_user, auth_header,
|
||||||
create_test_kpi, create_test_map,
|
create_test_kpi, create_test_map,
|
||||||
)
|
)
|
||||||
from app.models import Objective, ActionPlan, KPIDefinition, BscLayerConfig
|
from app.models import Objective, ActionPlan, KPIDefinition, BscLayerConfig, KR
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -333,13 +333,21 @@ class TestOKRFullLifecycle:
|
|||||||
assert kr_data["status"] == "pending"
|
assert kr_data["status"] == "pending"
|
||||||
|
|
||||||
def test_get_objective_with_krs(self, client: TestClient, db: Session):
|
def test_get_objective_with_krs(self, client: TestClient, db: Session):
|
||||||
"""TC11: 查看OKR详情包含关联KR"""
|
"""TC11: 查看OKR详情包含关联KR(KR完整修复2026-08-27: 从krs表读取)"""
|
||||||
create_test_user(db)
|
create_test_user(db)
|
||||||
token = get_token_for_user(client)
|
token = get_token_for_user(client)
|
||||||
kpi = create_test_kpi(db, kpi_code="C_CHANNEL_REBATE")
|
kpi = create_test_kpi(db, kpi_code="C_CHANNEL_REBATE")
|
||||||
obj = create_test_objective(db, title="优化成本结构")
|
obj = create_test_objective(db, title="优化成本结构")
|
||||||
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
# KR落krs表(KR完整修复后,ActionPlan不再是KR存储)
|
||||||
title="渠补率降到75%")
|
kr = db.query(KR).filter(KR.objective_id == obj.id).first()
|
||||||
|
if not kr:
|
||||||
|
from app.models import KR as KRModel
|
||||||
|
kr = KRModel(entity_id=1, objective_id=obj.id, title="渠补率降到75%",
|
||||||
|
metric_kpi_id=kpi.id, operator="<=", target_value=75,
|
||||||
|
weight=33, status="pending", progress=0)
|
||||||
|
db.add(kr)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kr)
|
||||||
|
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
f"/api/cma/okr/{obj.id}",
|
f"/api/cma/okr/{obj.id}",
|
||||||
@@ -353,15 +361,18 @@ class TestOKRFullLifecycle:
|
|||||||
assert kr_found, "KR应出现在OKR详情中"
|
assert kr_found, "KR应出现在OKR详情中"
|
||||||
|
|
||||||
def test_objective_progress_from_krs(self, client: TestClient, db: Session):
|
def test_objective_progress_from_krs(self, client: TestClient, db: Session):
|
||||||
"""TC12: OKR进度随KR进度自动计算"""
|
"""TC12: OKR进度随KR进度自动计算(KR完整修复2026-08-27: krs表)"""
|
||||||
create_test_user(db)
|
create_test_user(db)
|
||||||
token = get_token_for_user(client)
|
token = get_token_for_user(client)
|
||||||
kpi = create_test_kpi(db)
|
kpi = create_test_kpi(db)
|
||||||
obj = create_test_objective(db)
|
obj = create_test_objective(db)
|
||||||
kr1 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
from app.models import KR as KRModel
|
||||||
title="KR1", progress=80)
|
kr1 = KRModel(entity_id=1, objective_id=obj.id, title="KR1",
|
||||||
kr2 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
metric_kpi_id=kpi.id, progress=80, weight=50)
|
||||||
title="KR2", progress=40)
|
kr2 = KRModel(entity_id=1, objective_id=obj.id, title="KR2",
|
||||||
|
metric_kpi_id=kpi.id, progress=40, weight=50)
|
||||||
|
db.add_all([kr1, kr2])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
# 触发progress重算
|
# 触发progress重算
|
||||||
resp = client.patch(
|
resp = client.patch(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi.testclient import TestClient
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||||
from app.models.budget_plan import BudgetPlan
|
from app.models.budget_plan import BudgetPlan
|
||||||
|
from app.models import KPIValue, SystemConfig, CashPlan
|
||||||
|
|
||||||
|
|
||||||
class TestBudgetPlans:
|
class TestBudgetPlans:
|
||||||
@@ -662,9 +663,9 @@ class TestBudgetContract20260825:
|
|||||||
token = get_token_for_user(client)
|
token = get_token_for_user(client)
|
||||||
kpi = create_test_kpi(db, kpi_code="CONTRACT_DECOMP")
|
kpi = create_test_kpi(db, kpi_code="CONTRACT_DECOMP")
|
||||||
|
|
||||||
# 先创建年度预算(period=2026-00 或任意月份记录,让批量分解能聚合到)
|
# 先创建年度预算(period=YYYY-00 年度行,批量分解只取年度行 — 幂等契约)
|
||||||
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
json={"kpi_id": kpi.id, "period": "2026-01", "budget_value": 12000.0, "budget_year": 2026, "budget_month": 1})
|
json={"kpi_id": kpi.id, "period": "2026-00", "budget_value": 12000.0, "budget_year": 2026, "budget_month": 0})
|
||||||
|
|
||||||
# 第一次批量分解
|
# 第一次批量分解
|
||||||
resp1 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
|
resp1 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
|
||||||
@@ -683,3 +684,532 @@ class TestBudgetContract20260825:
|
|||||||
BudgetPlan.kpi_id, BudgetPlan.period, BudgetPlan.version).having(
|
BudgetPlan.kpi_id, BudgetPlan.period, BudgetPlan.version).having(
|
||||||
func.count() > 1).all()
|
func.count() > 1).all()
|
||||||
assert len(rows) == 0, f"存在重复预算记录: {rows}"
|
assert len(rows) == 0, f"存在重复预算记录: {rows}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetPlansEdges:
|
||||||
|
"""预算计划CRUD异常路径与列表过滤(补齐未覆盖端点)"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_create_plan_kpi_not_found(self, client: TestClient, db: Session):
|
||||||
|
"""KPI不存在 → 404"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": 99999, "period": "2026-06", "budget_value": 100.0})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_update_plan_not_found(self, client: TestClient, db: Session):
|
||||||
|
"""更新不存在的计划 → 404"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.put(f"{self.BASE}/plans/99999", headers=auth_header(token),
|
||||||
|
json={"budget_value": 100.0})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_plan_not_found(self, client: TestClient, db: Session):
|
||||||
|
"""删除不存在的计划 → 404"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.delete(f"{self.BASE}/plans/99999", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_list_plans_filters(self, client: TestClient, db: Session):
|
||||||
|
"""列表按 year/period/version 过滤"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="PLAN_FILTER_KPI")
|
||||||
|
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0})
|
||||||
|
|
||||||
|
assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
params={"year": 2026}).json()["total"] == 1
|
||||||
|
assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
params={"year": 2025}).json()["total"] == 0
|
||||||
|
assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
params={"period": "2026-06"}).json()["total"] == 1
|
||||||
|
assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
params={"version": "v1.0"}).json()["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetAutoDecomposeEdges:
|
||||||
|
"""自动分解异常路径与加权模式"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_auto_decompose_single_missing_budget(self, client: TestClient, db: Session):
|
||||||
|
"""单KPI模式缺 annual_budget → 400"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMP_MISSING_BUDGET")
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "year": 2026})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_auto_decompose_kpi_not_found(self, client: TestClient, db: Session):
|
||||||
|
"""单KPI模式KPI不存在 → 404"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
|
||||||
|
json={"kpi_id": 99999, "annual_budget": 120000, "year": 2026})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_auto_decompose_single_weighted(self, client: TestClient, db: Session):
|
||||||
|
"""单KPI加权分解(去年各月实际值作为权重)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMP_WEIGHTED")
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2025-01", actual_value=10.0))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2025-02", actual_value=20.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "annual_budget": 120000, "year": 2026,
|
||||||
|
"method": "weighted"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["method"] == "weighted"
|
||||||
|
assert len(data["monthly_budgets"]) == 12
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetVersionSubmitDiff:
|
||||||
|
"""版本提交 + 版本差异对比(补齐未覆盖端点)"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_submit_version(self, client: TestClient, db: Session):
|
||||||
|
"""提交版本审批:active → submitted"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="VER_SUBMIT_KPI")
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0})
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/versions/submit", headers=auth_header(token),
|
||||||
|
json={"version": "v1.0"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["status"] == "submitted"
|
||||||
|
assert data["count"] == 1
|
||||||
|
|
||||||
|
def test_submit_version_not_found(self, client: TestClient, db: Session):
|
||||||
|
"""提交不存在的版本 → 404"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/versions/submit", headers=auth_header(token),
|
||||||
|
json={"version": "v9.9"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_approve_version_not_found(self, client: TestClient, db: Session):
|
||||||
|
"""审批不存在的版本 → 404"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/versions/approve", headers=auth_header(token),
|
||||||
|
json={"version": "v9.9", "action": "approved"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_diff_versions(self, client: TestClient, db: Session):
|
||||||
|
"""版本差异对比:v1.0 vs v2.0 逐KPI差异"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="VER_DIFF_KPI")
|
||||||
|
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0,
|
||||||
|
"version": "v1.0"})
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 150.0,
|
||||||
|
"version": "v2.0"})
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/versions/diff", headers=auth_header(token),
|
||||||
|
json={"version_a": "v1.0", "version_b": "v2.0", "year": 2026})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["summary"]["changed_count"] == 1
|
||||||
|
assert data["summary"]["total_a"] == 100.0
|
||||||
|
assert data["summary"]["total_b"] == 150.0
|
||||||
|
assert data["diffs"][0]["version_a"] == 100.0
|
||||||
|
assert data["diffs"][0]["version_b"] == 150.0
|
||||||
|
|
||||||
|
def test_diff_versions_missing_params(self, client: TestClient, db: Session):
|
||||||
|
"""缺少 version_a/version_b → 400"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/versions/diff", headers=auth_header(token),
|
||||||
|
json={"year": 2026})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_versions_year_filter(self, client: TestClient, db: Session):
|
||||||
|
"""版本列表按年份过滤"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="VER_YEAR_KPI")
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0})
|
||||||
|
|
||||||
|
assert len(client.get(f"{self.BASE}/versions", headers=auth_header(token),
|
||||||
|
params={"year": 2026}).json()) >= 1
|
||||||
|
assert client.get(f"{self.BASE}/versions", headers=auth_header(token),
|
||||||
|
params={"year": 2030}).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetApplyMethod:
|
||||||
|
"""预算方法落地 apply-method(补齐未覆盖端点)"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def _seed_core_kpis(self, db: Session):
|
||||||
|
for code in ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"]:
|
||||||
|
create_test_kpi(db, kpi_code=code, kpi_name=code)
|
||||||
|
|
||||||
|
def test_apply_method_zero_based(self, client: TestClient, db: Session):
|
||||||
|
"""零基预算方法落地到年度预算"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
self._seed_core_kpis(db)
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
|
||||||
|
json={"method": "zero_based", "year": 2026})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["method"] == "zero_based"
|
||||||
|
assert data["total_budget"] == 71.4
|
||||||
|
assert len(data["applied"]) == 4
|
||||||
|
codes = {a["kpi_code"] for a in data["applied"]}
|
||||||
|
assert codes == {"F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"}
|
||||||
|
revenue = [a for a in data["applied"] if a["kpi_code"] == "F_REVENUE"][0]
|
||||||
|
assert revenue["budget_value"] == 71.4
|
||||||
|
|
||||||
|
def test_apply_method_unknown(self, client: TestClient, db: Session):
|
||||||
|
"""未知预算方法 → 400"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
|
||||||
|
json={"method": "bogus"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_apply_method_no_kpis(self, client: TestClient, db: Session):
|
||||||
|
"""无核心KPI可应用 → 400"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
|
||||||
|
json={"method": "zero_based", "year": 2026})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "未找到" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetSyncCashPlans:
|
||||||
|
"""预算→现金流联动(补齐未覆盖端点)"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_sync_cash_plans_create_and_update(self, client: TestClient, db: Session):
|
||||||
|
"""按预算KPI生成/更新收付款计划(upsert幂等)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi_rev = create_test_kpi(db, kpi_code="SYNC_REV_KPI", kpi_name="营业收入")
|
||||||
|
kpi_pay = create_test_kpi(db, kpi_code="SYNC_PAY_KPI", kpi_name="费用总额")
|
||||||
|
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi_rev.id, "period": "2026-06", "budget_value": 1000.0})
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi_pay.id, "period": "2026-06", "budget_value": 500.0})
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["created"] == 2
|
||||||
|
assert data["updated"] == 0
|
||||||
|
|
||||||
|
# 再次执行 → 更新而非新增
|
||||||
|
resp2 = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["created"] == 0
|
||||||
|
assert resp2.json()["updated"] == 2
|
||||||
|
|
||||||
|
plans = db.query(CashPlan).all()
|
||||||
|
types = {p.plan_type for p in plans}
|
||||||
|
assert types == {"receive", "pay"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetGenerateCandidates:
|
||||||
|
"""KPI→预算候选列表(补齐未覆盖端点)"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_kpi_budget_candidates(self, client: TestClient, db: Session):
|
||||||
|
"""按类型分类返回预算建议(降本/增收/能力/系统)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
kpi_cost = create_test_kpi(db, kpi_code="CAND_COST", category="cost_control", target_value=50.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi_cost.id, period="2026-06", actual_value=80.0))
|
||||||
|
create_test_kpi(db, kpi_code="CAND_REV", category="revenue_growth", target_value=100.0)
|
||||||
|
create_test_kpi(db, kpi_code="CAND_CAP", category="talent_pipeline", target_value=100.0)
|
||||||
|
create_test_kpi(db, kpi_code="CAND_SYS", category="supply_chain", target_value=100.0)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.get(f"{self.BASE}/kpi-budget-candidates", headers=auth_header(token),
|
||||||
|
params={"year": 2026})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
assert set(data.keys()) == {"cost_reduction", "revenue_growth", "capability", "system"}
|
||||||
|
|
||||||
|
assert len(data["cost_reduction"]) == 1
|
||||||
|
assert data["cost_reduction"][0]["suggested_budget"] == 9.0 # (80-50)*0.3
|
||||||
|
assert data["revenue_growth"][0]["suggested_budget"] == 20.0 # 100*0.2
|
||||||
|
assert data["capability"][0]["suggested_budget"] == 20000.0 # 2000*10
|
||||||
|
assert data["system"][0]["suggested_budget"] == 15.0 # 100*0.15
|
||||||
|
|
||||||
|
def test_generate_from_kpis(self, client: TestClient, db: Session):
|
||||||
|
"""从选中KPI生成预算科目(修复source_type/source_kpi_id/calc_logic字段后完整流程)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="GEN_KPI_01")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{self.BASE}/generate-from-kpis",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={
|
||||||
|
"year": 2026,
|
||||||
|
"month": 7,
|
||||||
|
"version": "v1.0",
|
||||||
|
"items": [
|
||||||
|
{"kpi_id": kpi.id, "budget_amount": 30000.0,
|
||||||
|
"calc_logic": "目标值100000×0.3=30000", "calc_type": "增收类"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total_amount"] == 30000.0
|
||||||
|
assert body["items"][0]["kpi_code"] == "GEN_KPI_01"
|
||||||
|
|
||||||
|
# 验证落库字段
|
||||||
|
plan = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).first()
|
||||||
|
assert plan is not None
|
||||||
|
assert plan.source_type == "kpi_generated"
|
||||||
|
assert plan.source_kpi_id == kpi.id
|
||||||
|
assert plan.calc_logic == "目标值100000×0.3=30000"
|
||||||
|
assert "增收类" in plan.remark
|
||||||
|
|
||||||
|
def test_generate_from_kpis_empty_items(self, client: TestClient, db: Session):
|
||||||
|
"""未选择KPI → 400"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
resp = client.post(
|
||||||
|
f"{self.BASE}/generate-from-kpis",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"year": 2026, "month": 7, "items": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_generate_from_kpis_updates_existing(self, client: TestClient, db: Session):
|
||||||
|
"""同一KPI+期间+版本已存在 → 更新预算值而非新增"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="GEN_KPI_02")
|
||||||
|
|
||||||
|
# 先创建一条预算
|
||||||
|
resp1 = client.post(
|
||||||
|
f"{self.BASE}/generate-from-kpis",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"year": 2026, "month": 7, "version": "v1.0",
|
||||||
|
"items": [{"kpi_id": kpi.id, "budget_amount": 10000.0}]},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
first_id = resp1.json()["items"][0]["plan_id"]
|
||||||
|
|
||||||
|
# 再次生成 → 更新而非新增
|
||||||
|
resp2 = client.post(
|
||||||
|
f"{self.BASE}/generate-from-kpis",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"year": 2026, "month": 7, "version": "v1.0",
|
||||||
|
"items": [{"kpi_id": kpi.id, "budget_amount": 25000.0}]},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["items"][0]["plan_id"] == first_id
|
||||||
|
|
||||||
|
plans = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).all()
|
||||||
|
assert len(plans) == 1
|
||||||
|
assert plans[0].budget_value == 25000.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetDeviationReportEdges:
|
||||||
|
"""偏差报告边界:under_budget / 维度过滤"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_deviation_report_under_budget(self, client: TestClient, db: Session):
|
||||||
|
"""实际低于预算 → 结余统计"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_UNDER")
|
||||||
|
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||||
|
budget_year=2026, budget_month=6, status="active"))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=80.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6",
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
summary = resp.json()["summary"]
|
||||||
|
assert summary["over_budget"] == 0
|
||||||
|
assert summary["under_budget"] == 1
|
||||||
|
|
||||||
|
def test_deviation_report_dimension_filter(self, client: TestClient, db: Session):
|
||||||
|
"""按维度过滤KPI"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_DIM", dimension="customer")
|
||||||
|
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||||
|
budget_year=2026, budget_month=6, status="active"))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=120.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&dimension=customer",
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert resp.json()["summary"]["total_kpis"] == 1
|
||||||
|
|
||||||
|
resp2 = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&dimension=finance",
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert resp2.json()["summary"]["total_kpis"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetRollForwardEdges:
|
||||||
|
"""滚动延展边界:固定模式 / 配置异常"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_roll_forward_fixed_mode(self, client: TestClient, db: Session):
|
||||||
|
"""已配置固定预算模式 → 400"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
client.post(f"{self.BASE}/config", headers=auth_header(token),
|
||||||
|
json={"mode": "fixed"})
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "固定预算模式" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_roll_forward_bad_config(self, client: TestClient, db: Session):
|
||||||
|
"""预算模式配置为非法JSON → 400"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
db.add(SystemConfig(config_key="budget_mode", config_value="not-json"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "配置异常" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetComparisonRolling:
|
||||||
|
"""滚动预算下的实际vs预测对比"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_comparison_rolling_mode(self, client: TestClient, db: Session):
|
||||||
|
"""滚动模式下只返回 rolling_months 个月份"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
client.post(f"{self.BASE}/config", headers=auth_header(token),
|
||||||
|
json={"mode": "rolling", "rolling_months": 3})
|
||||||
|
|
||||||
|
resp = client.get(f"{self.BASE}/comparison", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["budget_mode"] == "rolling"
|
||||||
|
assert len(data["periods"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetDeviationCheckEdges:
|
||||||
|
"""偏差预警边界:critical / 幂等 / 零预算跳过"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_deviation_check_critical(self, client: TestClient, db: Session):
|
||||||
|
"""偏差超过50% → critical"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_CRIT")
|
||||||
|
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||||
|
budget_year=2026, budget_month=6, status="active"))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0)) # +100%
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||||
|
json={"period": "2026-06", "threshold": 20})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["alerts_generated"] == 1
|
||||||
|
assert resp.json()["alerts"][0]["alert_level"] == "critical"
|
||||||
|
|
||||||
|
def test_deviation_check_idempotent(self, client: TestClient, db: Session):
|
||||||
|
"""重复检查不重复生成预警"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_IDEM")
|
||||||
|
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||||
|
budget_year=2026, budget_month=6, status="active"))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp1 = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||||
|
json={"period": "2026-06", "threshold": 20})
|
||||||
|
assert resp1.json()["alerts_generated"] == 1
|
||||||
|
|
||||||
|
resp2 = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||||
|
json={"period": "2026-06", "threshold": 20})
|
||||||
|
assert resp2.json()["alerts_generated"] == 0
|
||||||
|
|
||||||
|
def test_deviation_check_zero_budget_skip(self, client: TestClient, db: Session):
|
||||||
|
"""预算为0 → 跳过不预警"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_ZERO")
|
||||||
|
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=0.0,
|
||||||
|
budget_year=2026, budget_month=6, status="active"))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||||
|
json={"period": "2026-06", "threshold": 20})
|
||||||
|
assert resp.json()["alerts_generated"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetDeviationAlertsEdges:
|
||||||
|
"""偏差预警列表按 kpi_id/period 过滤"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_list_alerts_kpi_period_filter(self, client: TestClient, db: Session):
|
||||||
|
"""按KPI与期间过滤预警"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_ALERT_FLT")
|
||||||
|
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||||
|
budget_year=2026, budget_month=6, status="active"))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0))
|
||||||
|
db.commit()
|
||||||
|
client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||||
|
json={"period": "2026-06"})
|
||||||
|
|
||||||
|
resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token),
|
||||||
|
params={"kpi_id": kpi.id, "period": "2026-06"})
|
||||||
|
assert resp.json()["total"] == 1
|
||||||
|
|
||||||
|
resp2 = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token),
|
||||||
|
params={"kpi_id": kpi.id, "period": "2026-01"})
|
||||||
|
assert resp2.json()["total"] == 0
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""预算系统技术改进测试 (2026-08-28 yanxue-budget-tech-improve)
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
P1-③ 告警归因(alert_type/attribution/scenario_id + 详情接口)
|
||||||
|
P1-④ 实际值自动归集(映射CRUD/采集器/覆盖率)
|
||||||
|
P2-① 真零基逐项论证(CRUD/generate/method-comparison is_demo)
|
||||||
|
P2-② 派生规则可配置(规则CRUD/apply-method rule_source)
|
||||||
|
P2-⑤ 双路径合并(两出口级别一致, 无第二套阈值逻辑)
|
||||||
|
P2-⑥ 现金流分类规则(待分类队列/一键归类)
|
||||||
|
"""
|
||||||
|
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, create_test_kpi
|
||||||
|
from app.models import (
|
||||||
|
BudgetPlan, KPIValue, BudgetDeviationAlert, KPIAlert,
|
||||||
|
KPIValueSource, KPIValueCollectLog,
|
||||||
|
BudgetZeroBasedItem, BudgetDerivationRule,
|
||||||
|
CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
|
||||||
|
ScenarioSuggestion, KPIDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestP1AlertAttribution:
|
||||||
|
"""P1-③ 告警归因: 告警从'差多少'到'差在哪+怎么办'"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def _setup_alert(self, client, db, kpi_code="ATTRIB_KPI", kpi_name="销售费用", actual=150.0, budget=100.0):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code=kpi_code, kpi_name=kpi_name)
|
||||||
|
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=budget,
|
||||||
|
budget_year=2026, budget_month=6, status="active"))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=actual))
|
||||||
|
db.commit()
|
||||||
|
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||||
|
json={"period": "2026-06", "threshold": 20})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return token, kpi, resp
|
||||||
|
|
||||||
|
def test_deviation_check_writes_attribution(self, client: TestClient, db: Session):
|
||||||
|
"""生成告警时同步写 alert_type/attribution/scenario_id"""
|
||||||
|
token, kpi, resp = self._setup_alert(client, db, actual=150.0, budget=100.0)
|
||||||
|
assert resp.json()["alerts_generated"] == 1
|
||||||
|
|
||||||
|
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first()
|
||||||
|
assert alert is not None
|
||||||
|
assert alert.alert_level == "warning"
|
||||||
|
# 归因JSON结构
|
||||||
|
assert alert.attribution is not None
|
||||||
|
attr = alert.attribution
|
||||||
|
assert "dimensions" in attr and "subjects" in attr
|
||||||
|
assert "variance_type" in attr and "trend" in attr
|
||||||
|
assert attr["variance_type"] in ("quantity_diff", "price_diff", "mixed")
|
||||||
|
assert "anomaly" in attr["trend"]
|
||||||
|
# 场景建议关联(费用类KPI → cost_high 模板)
|
||||||
|
if alert.scenario_id:
|
||||||
|
s = db.query(ScenarioSuggestion).filter(ScenarioSuggestion.id == alert.scenario_id).first()
|
||||||
|
assert s is not None
|
||||||
|
assert s.alert_type in ("cash_low", "cash_critical", "cost_high", "revenue_drop")
|
||||||
|
|
||||||
|
def test_attribution_detail_endpoint(self, client: TestClient, db: Session):
|
||||||
|
"""GET /deviation-alerts/{id}/attribution 返回归因+场景建议"""
|
||||||
|
token, kpi, _ = self._setup_alert(client, db, actual=200.0, budget=100.0)
|
||||||
|
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first()
|
||||||
|
|
||||||
|
resp = client.get(f"{self.BASE}/deviation-alerts/{alert.id}/attribution",
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["attribution"] != {}
|
||||||
|
assert "dimensions" in data["attribution"]
|
||||||
|
# scenario 建议联查(无匹配时可空, 有模板时必须带文本)
|
||||||
|
if data["scenario"]:
|
||||||
|
assert data["scenario"]["title"]
|
||||||
|
|
||||||
|
def test_list_alerts_has_attribution_fields(self, client: TestClient, db: Session):
|
||||||
|
"""列表响应新增 alert_type/attribution/scenario_id 字段(可空)"""
|
||||||
|
token, kpi, _ = self._setup_alert(client, db)
|
||||||
|
resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token))
|
||||||
|
row = resp.json()["data"][0]
|
||||||
|
assert "alert_type" in row
|
||||||
|
assert "attribution" in row
|
||||||
|
assert "scenario_id" in row
|
||||||
|
|
||||||
|
def test_alert_direction_config(self, client: TestClient, db: Session):
|
||||||
|
"""P2-⑤ 方向配置 GET/PUT system_configs"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
resp = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "SALES_TOTAL" in resp.json()["codes"]
|
||||||
|
|
||||||
|
resp2 = client.put(f"{self.BASE}/alert-direction", headers=auth_header(token),
|
||||||
|
json={"codes": ["SALES_TOTAL", "CUSTOM_COUNT"]})
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"]
|
||||||
|
|
||||||
|
resp3 = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token))
|
||||||
|
assert resp3.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"]
|
||||||
|
assert resp3.json()["is_configured"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestP1ValueCollect:
|
||||||
|
"""P1-④ 实际值自动归集"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_value_source_crud_and_collect(self, client: TestClient, db: Session):
|
||||||
|
"""映射CRUD → 采集器 → kpi_values 出现 auto_collect"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="COLLECT_KPI")
|
||||||
|
|
||||||
|
# 建映射
|
||||||
|
resp = client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
|
||||||
|
"kpi_id": kpi.id,
|
||||||
|
"source_table": "voucher_details",
|
||||||
|
"source_field": "credit_amount",
|
||||||
|
"aggregate": "sum",
|
||||||
|
"filter_rule": {"direction": "credit"},
|
||||||
|
"period_field": "period",
|
||||||
|
"unit_conversion": 1,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# 采集器试跑(不写库)
|
||||||
|
test_resp = client.post(f"{self.BASE}/value-sources/test", headers=auth_header(token), json={
|
||||||
|
"kpi_id": kpi.id,
|
||||||
|
"source_table": "voucher_details",
|
||||||
|
"source_field": "credit_amount",
|
||||||
|
"aggregate": "sum",
|
||||||
|
"filter_rule": {"direction": "credit"},
|
||||||
|
"period_field": "period",
|
||||||
|
})
|
||||||
|
assert test_resp.status_code == 200
|
||||||
|
assert test_resp.json()["value"] is not None
|
||||||
|
|
||||||
|
# 手动触发采集
|
||||||
|
run_resp = client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token),
|
||||||
|
json={"period": "2026-06"})
|
||||||
|
assert run_resp.status_code == 200
|
||||||
|
assert run_resp.json()["collected"] >= 1
|
||||||
|
|
||||||
|
# 验证 kpi_values 落库
|
||||||
|
val = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi.id,
|
||||||
|
KPIValue.period == "2026-06",
|
||||||
|
KPIValue.source_type == "auto_collect",
|
||||||
|
).first()
|
||||||
|
assert val is not None
|
||||||
|
assert val.actual_value is not None
|
||||||
|
assert val.remark and "自动归集" in val.remark
|
||||||
|
|
||||||
|
# 采集日志
|
||||||
|
logs = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.kpi_id == kpi.id).all()
|
||||||
|
assert len(logs) >= 1
|
||||||
|
|
||||||
|
# 覆盖率
|
||||||
|
cov = client.get(f"{self.BASE}/value-sources/coverage", headers=auth_header(token))
|
||||||
|
assert cov.status_code == 200
|
||||||
|
assert cov.json()["mapped_count"] >= 1
|
||||||
|
|
||||||
|
def test_collector_idempotent(self, client: TestClient, db: Session):
|
||||||
|
"""同kpi+period 重复采集 → 更新不新增"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="COLLECT_IDEMP")
|
||||||
|
client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
|
||||||
|
"kpi_id": kpi.id, "source_table": "voucher_details",
|
||||||
|
"source_field": "credit_amount", "aggregate": "sum",
|
||||||
|
})
|
||||||
|
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
|
||||||
|
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
|
||||||
|
|
||||||
|
rows = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi.id,
|
||||||
|
KPIValue.period == "2026-06",
|
||||||
|
KPIValue.source_type == "auto_collect",
|
||||||
|
).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
def test_collector_logs_filter(self, client: TestClient, db: Session):
|
||||||
|
"""采集日志 status 过滤"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="COLLECT_LOG")
|
||||||
|
client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
|
||||||
|
"kpi_id": kpi.id, "source_table": "voucher_details",
|
||||||
|
"source_field": "credit_amount", "aggregate": "sum",
|
||||||
|
})
|
||||||
|
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
|
||||||
|
resp = client.get(f"{self.BASE}/value-collect/logs", headers=auth_header(token),
|
||||||
|
params={"status": "success"})
|
||||||
|
assert resp.json()["total"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestP2ZeroBased:
|
||||||
|
"""P2-① 真零基逐项论证"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def _setup_kpi_with_plans(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
# 核心4KPI(apply-method 需要)
|
||||||
|
for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"):
|
||||||
|
create_test_kpi(db, kpi_code=code, kpi_name=code)
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first()
|
||||||
|
return token, kpi
|
||||||
|
|
||||||
|
def test_zero_based_items_crud_and_generate(self, client: TestClient, db: Session):
|
||||||
|
"""录入3个科目 → 逐项论证 → generate → budget_plans 出现且金额=Σ建议值"""
|
||||||
|
token, kpi = self._setup_kpi_with_plans(client, db)
|
||||||
|
|
||||||
|
# 录入3个论证项
|
||||||
|
items = [
|
||||||
|
{"item_name": "房租", "item_category": "fixed", "base_value": 15, "proposed_value": 15, "justification": "合同锁定"},
|
||||||
|
{"item_name": "招待费", "item_category": "discretionary", "base_value": 16, "proposed_value": 8, "justification": "压缩50%"},
|
||||||
|
{"item_name": "杂项", "item_category": "discretionary", "base_value": 12, "proposed_value": 8, "justification": "压缩30%"},
|
||||||
|
]
|
||||||
|
for it in items:
|
||||||
|
r = client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
|
||||||
|
"kpi_id": kpi.id, "period": "2026-06", **it,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# 列表+合计
|
||||||
|
lst = client.get(f"{self.BASE}/zero-based/items", headers=auth_header(token),
|
||||||
|
params={"kpi_id": kpi.id, "period": "2026-06"})
|
||||||
|
assert lst.json()["total"] == 3
|
||||||
|
assert lst.json()["total_proposed"] == 31.0
|
||||||
|
|
||||||
|
# generate → budget_plans
|
||||||
|
gen = client.post(f"{self.BASE}/zero-based/generate", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "period": "2026-06"})
|
||||||
|
assert gen.status_code == 200
|
||||||
|
assert gen.json()["total"] == 31.0
|
||||||
|
|
||||||
|
plan = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.kpi_id == kpi.id,
|
||||||
|
BudgetPlan.period == "2026-06",
|
||||||
|
BudgetPlan.version.like("zbb-%"),
|
||||||
|
).first()
|
||||||
|
assert plan is not None
|
||||||
|
assert plan.budget_value == 31.0
|
||||||
|
assert plan.calc_logic == "zero_based_itemized"
|
||||||
|
|
||||||
|
def test_method_comparison_zero_based_is_demo_false(self, client: TestClient, db: Session):
|
||||||
|
"""method-comparison 传论证KPI → is_demo=false; 不传 → is_demo=true"""
|
||||||
|
token, kpi = self._setup_kpi_with_plans(client, db)
|
||||||
|
client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
|
||||||
|
"kpi_id": kpi.id, "period": "2026-06",
|
||||||
|
"item_name": "房租", "item_category": "fixed",
|
||||||
|
"base_value": 15, "proposed_value": 15,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 有论证项 → 真零基
|
||||||
|
r1 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={
|
||||||
|
"zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06",
|
||||||
|
})
|
||||||
|
zbb1 = [m for m in r1.json()["methods"] if m["id"] == "zero_based"][0]
|
||||||
|
assert zbb1["is_demo"] is False
|
||||||
|
assert zbb1["item_count"] == 1
|
||||||
|
|
||||||
|
# 无论证项 → demo fallback
|
||||||
|
r2 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={})
|
||||||
|
zbb2 = [m for m in r2.json()["methods"] if m["id"] == "zero_based"][0]
|
||||||
|
assert zbb2["is_demo"] is True
|
||||||
|
|
||||||
|
def test_apply_method_zero_based_writes_plan(self, client: TestClient, db: Session):
|
||||||
|
"""apply-method zero_based → 落库 zbb 版本"""
|
||||||
|
token, kpi = self._setup_kpi_with_plans(client, db)
|
||||||
|
client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
|
||||||
|
"kpi_id": kpi.id, "period": "2026-06",
|
||||||
|
"item_name": "房租", "item_category": "fixed",
|
||||||
|
"base_value": 15, "proposed_value": 15,
|
||||||
|
})
|
||||||
|
r = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), json={
|
||||||
|
"method": "zero_based", "year": 2026,
|
||||||
|
"zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestP2DerivationRules:
|
||||||
|
"""P2-② 派生规则可配置"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def _setup(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"):
|
||||||
|
create_test_kpi(db, kpi_code=code, kpi_name=code)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def test_rule_crud_and_apply(self, client: TestClient, db: Session):
|
||||||
|
"""配置 F_NET_PROFIT 派生率 5% → apply-method → rule_source=configured 且结果变化"""
|
||||||
|
token = self._setup(client, db)
|
||||||
|
rev = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first()
|
||||||
|
np_kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_NET_PROFIT").first()
|
||||||
|
|
||||||
|
# 无规则时 apply → default 比例(2%)
|
||||||
|
r_default = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
|
||||||
|
json={"method": "incremental", "year": 2026})
|
||||||
|
np_default = [a for a in r_default.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0]
|
||||||
|
assert np_default["rule_source"] == "default"
|
||||||
|
assert r_default.json()["rule_source"] == "default"
|
||||||
|
|
||||||
|
# 建规则: percentage_of → 来源F_REVENUE × 5%
|
||||||
|
# 先给来源KPI实际值(真实链路: base_kpi实际值 × rate)
|
||||||
|
db.add(KPIValue(kpi_id=rev.id, period="2026-05", actual_value=2000.0))
|
||||||
|
db.commit()
|
||||||
|
r_rule = client.post(f"{self.BASE}/derivation-rules", headers=auth_header(token), json={
|
||||||
|
"kpi_id": np_kpi.id,
|
||||||
|
"rule_type": "percentage_of",
|
||||||
|
"base_kpi_id": rev.id,
|
||||||
|
"params": {"rate": 0.05},
|
||||||
|
"formula_text": "净利润 = 营业收入 × 5%",
|
||||||
|
})
|
||||||
|
assert r_rule.status_code == 200
|
||||||
|
|
||||||
|
# 配置后 apply → rule_source=configured, 金额=2000×5%=100
|
||||||
|
r2 = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
|
||||||
|
json={"method": "incremental", "year": 2026})
|
||||||
|
assert r2.json()["rule_source"] == "configured"
|
||||||
|
np_after = [a for a in r2.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0]
|
||||||
|
assert np_after["rule_source"] == "configured"
|
||||||
|
assert np_after["budget_value"] == 100.0
|
||||||
|
|
||||||
|
# 规则列表
|
||||||
|
lst = client.get(f"{self.BASE}/derivation-rules", headers=auth_header(token))
|
||||||
|
assert lst.json()["total"] == 1
|
||||||
|
assert lst.json()["data"][0]["rule_type"] == "percentage_of"
|
||||||
|
|
||||||
|
|
||||||
|
class TestP2SingleAlertPath:
|
||||||
|
"""P2-⑤ 双路径合并: 单一告警逻辑, 两出口级别一致"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_single_build_function_two_exits(self, client: TestClient, db: Session):
|
||||||
|
"""run_deviation_check 走统一逻辑写 KPIAlert; deviation-check 写 budget_deviation_alerts"""
|
||||||
|
from app.utils.deviation_engine import build_deviation_alert, run_deviation_check
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="SINGLE_PATH_KPI", kpi_name="测试成本")
|
||||||
|
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||||
|
budget_year=2026, budget_month=6, status="active"))
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=160.0)) # 60% 超支
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# KPIAlert 出口: 级别 red(≥30)
|
||||||
|
r = build_deviation_alert(db, kpi, "2026-06")
|
||||||
|
assert r["triggered"] is True
|
||||||
|
assert r["kpi_alert_level"] == "red"
|
||||||
|
assert r["level"] == "critical" # >50
|
||||||
|
|
||||||
|
# budget 出口 API: deviation-check
|
||||||
|
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||||
|
json={"period": "2026-06", "threshold": 20})
|
||||||
|
assert resp.json()["alerts_generated"] == 1
|
||||||
|
|
||||||
|
# run_deviation_check 写 KPIAlert
|
||||||
|
n = run_deviation_check(db, "2026-06")
|
||||||
|
assert n >= 1
|
||||||
|
kpi_alert = db.query(KPIAlert).filter(
|
||||||
|
KPIAlert.kpi_id == kpi.id,
|
||||||
|
KPIAlert.alert_message.contains("[差异预警]"),
|
||||||
|
).first()
|
||||||
|
assert kpi_alert is not None
|
||||||
|
assert kpi_alert.alert_level == "red"
|
||||||
|
assert kpi_alert.suggestion # 非模板空文案
|
||||||
|
|
||||||
|
def test_no_second_threshold_logic(self, client: TestClient, db: Session):
|
||||||
|
"""deviation_engine 中不应再有独立阈值/方向列表(grep 验证在代码review, 此处测函数可用)"""
|
||||||
|
from app.utils.deviation_engine import build_deviation_alert, get_higher_better_codes
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="HB_KPI", kpi_name="营业收入")
|
||||||
|
assert "SALES_TOTAL" in get_higher_better_codes(db)
|
||||||
|
|
||||||
|
|
||||||
|
class TestP2CashClassify:
|
||||||
|
"""P2-⑥ 现金流分类规则表"""
|
||||||
|
|
||||||
|
BASE = "/api/cma/budget"
|
||||||
|
|
||||||
|
def test_unclassified_queue_and_classify(self, client: TestClient, db: Session):
|
||||||
|
"""无关键词KPI → sync-cash-plans → 待分类队列(不静默跳过) → 一键归类 → CashPlan"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
# 无任何关键词的KPI(不会命中默认关键词)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="MYSTERY_KPI", kpi_name="部门专项投入待定")
|
||||||
|
# 移除'投入'关键词冲突: 名称改无关键词
|
||||||
|
kpi.kpi_name = "神秘专项"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 300.0})
|
||||||
|
|
||||||
|
# sync → 进待分类队列
|
||||||
|
resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["unclassified_count"] >= 1
|
||||||
|
|
||||||
|
item = db.query(CashPlanUnclassified).filter(
|
||||||
|
CashPlanUnclassified.kpi_id == kpi.id,
|
||||||
|
CashPlanUnclassified.status == "pending",
|
||||||
|
).first()
|
||||||
|
assert item is not None
|
||||||
|
assert item.reason == "未匹配任何分类规则"
|
||||||
|
|
||||||
|
# 队列列表
|
||||||
|
lst = client.get(f"{self.BASE}/cash-unclassified", headers=auth_header(token),
|
||||||
|
params={"status": "pending"})
|
||||||
|
assert any(r["kpi_id"] == kpi.id for r in lst.json()["data"])
|
||||||
|
|
||||||
|
# 一键归类 receive
|
||||||
|
cls = client.post(f"{self.BASE}/cash-unclassified/{item.id}/classify", headers=auth_header(token),
|
||||||
|
json={"plan_type": "receive"})
|
||||||
|
assert cls.status_code == 200
|
||||||
|
assert cls.json()["rule_created"] is True
|
||||||
|
|
||||||
|
# 规则自动补建 + CashPlan 生成
|
||||||
|
rule = db.query(CashPlanClassifyRule).filter(
|
||||||
|
CashPlanClassifyRule.entity_id == 1,
|
||||||
|
CashPlanClassifyRule.kpi_id == kpi.id,
|
||||||
|
).first()
|
||||||
|
assert rule is not None and rule.plan_type == "receive"
|
||||||
|
|
||||||
|
plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first()
|
||||||
|
assert plan is not None and plan.plan_type == "receive"
|
||||||
|
|
||||||
|
# 队列状态 → classified
|
||||||
|
db.refresh(item)
|
||||||
|
assert item.status == "classified"
|
||||||
|
|
||||||
|
def test_rule_priority_over_keyword(self, client: TestClient, db: Session):
|
||||||
|
"""规则表精确匹配优先于默认关键词"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="OVERRIDE_KPI", kpi_name="营业收入") # 默认会命中 receive
|
||||||
|
|
||||||
|
# 规则表强制 pay
|
||||||
|
r = client.post(f"{self.BASE}/cash-classify-rules", headers=auth_header(token), json={
|
||||||
|
"kpi_id": kpi.id, "plan_type": "pay", "priority": 1,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
|
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 500.0})
|
||||||
|
client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
|
||||||
|
|
||||||
|
plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first()
|
||||||
|
assert plan is not None
|
||||||
|
assert plan.plan_type == "pay" # 规则覆盖关键词
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
"""现金流模块测试 — 收付款计划 + 资金缺口预测 + 看板
|
"""现金流模块测试 — 收付款计划 + 资金缺口预测 + 看板 + 网银流水导入
|
||||||
|
|
||||||
覆盖 cash.py 核心端点:
|
覆盖 cash.py 核心端点:
|
||||||
gap-forecast / balance GET+POST / plans CRUD / plans{id}/complete /
|
gap-forecast / balance GET+POST / plans CRUD / plans{id}/complete /
|
||||||
upcoming / dashboard / check-alerts / alerts/status
|
upcoming / dashboard / check-alerts / alerts/status / import/vouchers
|
||||||
"""
|
"""
|
||||||
|
import io
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -218,3 +220,98 @@ class TestAlerts:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "entity_id" in data and "critical_line" in data
|
assert "entity_id" in data and "critical_line" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestVoucherImport:
|
||||||
|
"""网银流水导入 — 三校验规则(借贷平衡/期间合计/结转行)+ 入库 + 现金流联动"""
|
||||||
|
|
||||||
|
COLS = ["凭证日期", "凭证号", "科目编码", "科目名称", "借方金额", "贷方金额", "摘要"]
|
||||||
|
|
||||||
|
def _xlsx(self, rows: list, cols: list = None) -> io.BytesIO:
|
||||||
|
df = pd.DataFrame(rows, columns=cols or self.COLS)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
df.to_excel(buf, index=False)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def _upload(self, client, token, buf, fname="test_vouchers.xlsx"):
|
||||||
|
return client.post(
|
||||||
|
f"{BASE}/import/vouchers", headers=auth_header(token),
|
||||||
|
files={"file": (fname, buf,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_import_ok_with_rules(self, client: TestClient, db: Session):
|
||||||
|
"""正常导入:借贷平衡+结转行识别+期间合计,全部成功"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
buf = self._xlsx([
|
||||||
|
["2026-08-01", "记-001", "1002", "银行存款-工行", 50000, 0, "收到客户回款"],
|
||||||
|
["2026-08-02", "记-001", "1001", "库存现金", 0, 50000, "提现备用"],
|
||||||
|
["2026-08-31", "记-099", "4103", "本年利润", 2000, 0, "结转利润"],
|
||||||
|
["2026-08-31", "记-099", "6001", "主营业务收入", 0, 2000, "结转收入"],
|
||||||
|
])
|
||||||
|
resp = self._upload(client, token, buf)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["total"] == 4
|
||||||
|
assert data["success_rows"] == 4
|
||||||
|
assert data["failed_rows"] == 0
|
||||||
|
assert data["balance_check"]["passed"] is True
|
||||||
|
assert data["balance_check"]["debit_total"] == 52000
|
||||||
|
assert data["carry_forward_count"] == 2 # 结转行识别(本年利润+结转摘要)
|
||||||
|
assert "2026-08" in data["period_totals"]
|
||||||
|
assert data["cash_balance"] == 0.0 # 货币资金联动(50000-50000=0万元)
|
||||||
|
# 入库验证
|
||||||
|
from app.models import VoucherDetail, ImportLog
|
||||||
|
details = db.query(VoucherDetail).all()
|
||||||
|
assert len(details) == 4
|
||||||
|
assert all(d.entity_id == 1 for d in details)
|
||||||
|
cf = [d for d in details if d.carry_forward == 1]
|
||||||
|
assert len(cf) == 2 and all("结转" in (d.summary or "") for d in cf)
|
||||||
|
log = db.query(ImportLog).order_by(ImportLog.id.desc()).first()
|
||||||
|
assert log is not None and log.import_type == "vouchers"
|
||||||
|
assert log.success_rows == 4 and log.total_rows == 4
|
||||||
|
|
||||||
|
def test_import_unbalanced(self, client: TestClient, db: Session):
|
||||||
|
"""借贷不平衡:balance_check.passed=False + 差额报告,行仍入库"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
buf = self._xlsx([
|
||||||
|
["2026-08-01", "记-001", "1002", "银行存款-工行", 10000, 0, "回款"],
|
||||||
|
["2026-08-01", "记-002", "1002", "银行存款-工行", 0, 3000, "付款"],
|
||||||
|
])
|
||||||
|
resp = self._upload(client, token, buf)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["balance_check"]["passed"] is False
|
||||||
|
assert data["balance_check"]["diff"] == 7000
|
||||||
|
|
||||||
|
def test_import_partial_fail(self, client: TestClient, db: Session):
|
||||||
|
"""部分失败模式:坏行进errors,好行入库"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
buf = self._xlsx([
|
||||||
|
["2026-08-01", "记-001", "1002", "银行存款-工行", 8000, 0, "回款"],
|
||||||
|
["bad-date", "记-002", "1001", "库存现金", 0, 8000, "提现"],
|
||||||
|
["2026-08-01", "", "1001", "库存现金", 100, 0, "缺凭证号"],
|
||||||
|
])
|
||||||
|
resp = self._upload(client, token, buf)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success_rows"] == 1
|
||||||
|
assert data["failed_rows"] == 2
|
||||||
|
# 行级错误2条 + 借贷平衡全局错误1条(仅8000借无贷)
|
||||||
|
assert len(data["errors"]) == 3
|
||||||
|
assert any(e["field"] == "balance" for e in data["errors"])
|
||||||
|
from app.models import VoucherDetail
|
||||||
|
assert db.query(VoucherDetail).count() == 1
|
||||||
|
|
||||||
|
def test_import_missing_cols(self, client: TestClient, db: Session):
|
||||||
|
"""缺必要列 → 400"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
buf = self._xlsx([["2026-08-01", "记-001", 100, 0]], cols=["日期", "凭证号", "借方金额", "贷方金额"])
|
||||||
|
resp = self._upload(client, token, buf)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""因果链验证机制测试 — 数据验证核心 + 状态机 + API (2026-08-27 P2)
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. 服务层: parse_period / pearson / align_series(滞后) / evaluate_chain / apply_state_machine
|
||||||
|
2. API: create(source_type) / verify-status / verify(人工确认) / entity隔离 / 权限
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import KPIDefinition, KPICausality, KPIValue, Entity, User
|
||||||
|
from app.services.causality_verification import (
|
||||||
|
STATUS_DATA_VERIFIED,
|
||||||
|
STATUS_DISPUTED,
|
||||||
|
STATUS_HUMAN_VERIFIED,
|
||||||
|
STATUS_PENDING,
|
||||||
|
align_series,
|
||||||
|
apply_state_machine,
|
||||||
|
evaluate_chain,
|
||||||
|
parse_period,
|
||||||
|
pearson,
|
||||||
|
summarize,
|
||||||
|
)
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||||
|
|
||||||
|
BASE = "/api/cma/kpi-causality"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_kpi(db: Session, code: str, name: str = None, dimension: str = "finance",
|
||||||
|
entity_id: int = 1) -> KPIDefinition:
|
||||||
|
kpi = KPIDefinition(
|
||||||
|
kpi_code=code, kpi_name=name or code, dimension=dimension,
|
||||||
|
entity_id=entity_id, status="active", target_value=100.0,
|
||||||
|
)
|
||||||
|
db.add(kpi)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kpi)
|
||||||
|
return kpi
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_chain(db: Session, source_type: str = "AI_suggested"):
|
||||||
|
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||||
|
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
|
||||||
|
c = KPICausality(entity_id=src.entity_id, source_kpi_id=src.id, target_kpi_id=tgt.id,
|
||||||
|
strength=0.5, lag_months=0, direction="positive",
|
||||||
|
source_type=source_type, verify_status=STATUS_PENDING)
|
||||||
|
db.add(c)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(c)
|
||||||
|
return src, tgt, c
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsePeriod:
|
||||||
|
def test_month(self):
|
||||||
|
assert parse_period("2026-07") == ("month", 2026 * 12 + 6)
|
||||||
|
|
||||||
|
def test_half(self):
|
||||||
|
assert parse_period("2026-H1") == ("half", 2026 * 12 + 5)
|
||||||
|
assert parse_period("2026-H2") == ("half", 2026 * 12 + 11)
|
||||||
|
|
||||||
|
def test_year(self):
|
||||||
|
assert parse_period("2026") == ("year", 2026 * 12 + 5)
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
assert parse_period("abc") is None
|
||||||
|
assert parse_period("") is None
|
||||||
|
assert parse_period(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestPearson:
|
||||||
|
def test_perfect_positive(self):
|
||||||
|
r, n = pearson([1, 2, 3, 4], [2, 4, 6, 8])
|
||||||
|
assert n == 4
|
||||||
|
assert abs(r - 1.0) < 1e-9
|
||||||
|
|
||||||
|
def test_perfect_negative(self):
|
||||||
|
r, n = pearson([1, 2, 3, 4], [8, 6, 4, 2])
|
||||||
|
assert abs(r + 1.0) < 1e-9
|
||||||
|
|
||||||
|
def test_known_value(self):
|
||||||
|
# 与 numpy 核对过的样例 (F_REVENUE / F_NET_PROFIT 7点)
|
||||||
|
xs = [180.87, 132.33, 120.15, 60.5, 90.09, 129.32, 81.08]
|
||||||
|
ys = [94.31, -85.06, -21.66, -3.74, -21.91, -45.04, -17.63]
|
||||||
|
r, n = pearson(xs, ys)
|
||||||
|
assert n == 7
|
||||||
|
assert abs(r - 0.394012) < 1e-4
|
||||||
|
|
||||||
|
def test_insufficient(self):
|
||||||
|
r, n = pearson([1], [2])
|
||||||
|
assert r is None and n == 1
|
||||||
|
|
||||||
|
def test_constant_series(self):
|
||||||
|
r, n = pearson([3, 3, 3], [1, 2, 3])
|
||||||
|
assert r is None and n == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlignSeries:
|
||||||
|
def test_no_lag(self):
|
||||||
|
src = [("2026-01", 1), ("2026-02", 2), ("2026-03", 3)]
|
||||||
|
tgt = [("2026-01", 10), ("2026-02", 20), ("2026-03", 30)]
|
||||||
|
g, pairs = align_series(src, tgt, lag_months=0)
|
||||||
|
assert g == "month"
|
||||||
|
assert pairs == [(1, 10), (2, 20), (3, 30)]
|
||||||
|
|
||||||
|
def test_lag_alignment(self):
|
||||||
|
"""source t 与 target t+lag 配对"""
|
||||||
|
src = [("2026-01", 1), ("2026-02", 2), ("2026-03", 3)]
|
||||||
|
tgt = [("2026-02", 10), ("2026-03", 20), ("2026-04", 30)]
|
||||||
|
g, pairs = align_series(src, tgt, lag_months=1)
|
||||||
|
assert pairs == [(1, 10), (2, 20), (3, 30)]
|
||||||
|
|
||||||
|
def test_granularity_filter(self):
|
||||||
|
"""月度/半年度混用时只取同粒度(优先月)"""
|
||||||
|
src = [("2026-01", 1), ("2026-02", 2), ("2026-H1", 3)]
|
||||||
|
tgt = [("2026-01", 10), ("2026-02", 20), ("2026-H1", 30)]
|
||||||
|
g, pairs = align_series(src, tgt, lag_months=0)
|
||||||
|
assert g == "month"
|
||||||
|
assert pairs == [(1, 10), (2, 20)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvaluateChain:
|
||||||
|
def test_data_verified_positive(self):
|
||||||
|
src = [(f"2026-{m:02d}", m) for m in range(1, 9)]
|
||||||
|
tgt = [(f"2026-{m:02d}", m * 2) for m in range(1, 9)]
|
||||||
|
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
|
||||||
|
assert ev["status"] == STATUS_DATA_VERIFIED
|
||||||
|
assert ev["direction_consistent"] is True
|
||||||
|
assert ev["n"] == 8
|
||||||
|
|
||||||
|
def test_data_verified_negative(self):
|
||||||
|
src = [(f"2026-{m:02d}", m) for m in range(1, 9)]
|
||||||
|
tgt = [(f"2026-{m:02d}", -m * 2) for m in range(1, 9)]
|
||||||
|
ev = evaluate_chain(src, tgt, lag_months=0, direction="negative")
|
||||||
|
assert ev["status"] == STATUS_DATA_VERIFIED
|
||||||
|
|
||||||
|
def test_direction_conflict(self):
|
||||||
|
"""声明 positive 但实际负相关 → disputed"""
|
||||||
|
src = [(f"2026-{m:02d}", m) for m in range(1, 9)]
|
||||||
|
tgt = [(f"2026-{m:02d}", -m) for m in range(1, 9)]
|
||||||
|
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
|
||||||
|
assert ev["status"] == STATUS_DISPUTED
|
||||||
|
assert "方向矛盾" in ev["reason"]
|
||||||
|
|
||||||
|
def test_weak_correlation(self):
|
||||||
|
"""弱相关(方向一致但|r|<阈值)→ disputed"""
|
||||||
|
# numpy seed=1: x=[1..8], y=x+N(0,6) → r≈0.119 (弱正相关)
|
||||||
|
src = [(f"2026-{m:02d}", m) for m in range(1, 9)]
|
||||||
|
tgt = [(f"2026-{m:02d}", y) for m, y in enumerate(
|
||||||
|
[10.75, -1.67, -0.17, -2.44, 10.19, -7.81, 17.47, 3.43], start=1)]
|
||||||
|
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
|
||||||
|
assert ev["status"] == STATUS_DISPUTED
|
||||||
|
assert "弱相关" in ev["reason"]
|
||||||
|
|
||||||
|
def test_insufficient_points(self):
|
||||||
|
"""数据点不足 → pending"""
|
||||||
|
src = [("2026-01", 1), ("2026-02", 2)]
|
||||||
|
tgt = [("2026-01", 10), ("2026-02", 20)]
|
||||||
|
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
|
||||||
|
assert ev["status"] == STATUS_PENDING
|
||||||
|
|
||||||
|
def test_no_shared_periods(self):
|
||||||
|
src = [("2026-01", 1)]
|
||||||
|
tgt = [("2026-02", 10)]
|
||||||
|
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
|
||||||
|
assert ev["status"] == STATUS_PENDING
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateMachine:
|
||||||
|
def test_pending_to_verified(self):
|
||||||
|
st, note = apply_state_machine(STATUS_PENDING, STATUS_DATA_VERIFIED)
|
||||||
|
assert st == STATUS_DATA_VERIFIED and note is None
|
||||||
|
|
||||||
|
def test_pending_to_disputed(self):
|
||||||
|
st, _ = apply_state_machine(STATUS_PENDING, STATUS_DISPUTED)
|
||||||
|
assert st == STATUS_DISPUTED
|
||||||
|
|
||||||
|
def test_human_verified_not_overridden(self):
|
||||||
|
st, note = apply_state_machine(STATUS_HUMAN_VERIFIED, STATUS_DISPUTED)
|
||||||
|
assert st == STATUS_HUMAN_VERIFIED
|
||||||
|
assert note is not None # 数据矛盾警示
|
||||||
|
|
||||||
|
def test_human_verified_positive_note_none(self):
|
||||||
|
st, note = apply_state_machine(STATUS_HUMAN_VERIFIED, STATUS_DATA_VERIFIED)
|
||||||
|
assert st == STATUS_HUMAN_VERIFIED and note is None
|
||||||
|
|
||||||
|
def test_insufficient_keeps_status(self):
|
||||||
|
st, _ = apply_state_machine(STATUS_PENDING, STATUS_PENDING)
|
||||||
|
assert st == STATUS_PENDING
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummarize:
|
||||||
|
def test_counts(self):
|
||||||
|
s = summarize([{"status": STATUS_DATA_VERIFIED}, {"status": STATUS_DISPUTED},
|
||||||
|
{"status": STATUS_PENDING}, {"status": STATUS_HUMAN_VERIFIED}])
|
||||||
|
assert s["total"] == 4
|
||||||
|
assert s["by_status"][STATUS_DATA_VERIFIED] == 1
|
||||||
|
assert s["by_status"][STATUS_DISPUTED] == 1
|
||||||
|
assert s["by_status"][STATUS_PENDING] == 1
|
||||||
|
assert s["by_status"][STATUS_HUMAN_VERIFIED] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCausalityVerificationAPI:
|
||||||
|
def test_create_with_source_type(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||||
|
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
|
||||||
|
|
||||||
|
resp = client.post(BASE, headers=auth_header(token), json={
|
||||||
|
"source_kpi_id": src.id, "target_kpi_id": tgt.id,
|
||||||
|
"source_type": "AI_suggested",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["source_type"] == "AI_suggested"
|
||||||
|
assert body["verify_status"] == STATUS_PENDING
|
||||||
|
|
||||||
|
def test_create_invalid_source_type(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||||
|
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
|
||||||
|
resp = client.post(BASE, headers=auth_header(token), json={
|
||||||
|
"source_kpi_id": src.id, "target_kpi_id": tgt.id, "source_type": "unknown",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_update_resets_verification(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
# 先人工确认
|
||||||
|
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
|
||||||
|
json={"verify_status": "human_verified", "verified_by": "任富海"})
|
||||||
|
assert resp.json()["verify_status"] == STATUS_HUMAN_VERIFIED
|
||||||
|
# 修改链定义 → 状态回到 pending
|
||||||
|
resp2 = client.put(f"{BASE}/{c.id}", headers=auth_header(token), json={"strength": 0.9})
|
||||||
|
assert resp2.json()["verify_status"] == STATUS_PENDING
|
||||||
|
assert resp2.json()["verified_by"] is None
|
||||||
|
|
||||||
|
def test_verify_status_endpoint(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
|
||||||
|
resp = client.get(f"{BASE}/verify-status", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["summary"]["total"] == 1
|
||||||
|
assert body["summary"]["by_status"][STATUS_PENDING] == 1
|
||||||
|
assert body["data"][0]["id"] == c.id
|
||||||
|
assert body["data"][0]["verify_status"] == STATUS_PENDING
|
||||||
|
|
||||||
|
def test_verify_status_filter(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
|
||||||
|
json={"verify_status": "human_verified"})
|
||||||
|
|
||||||
|
resp = client.get(f"{BASE}/verify-status?verify_status=human_verified",
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert resp.json()["summary"]["total"] == 1
|
||||||
|
resp2 = client.get(f"{BASE}/verify-status?verify_status=pending", headers=auth_header(token))
|
||||||
|
assert resp2.json()["summary"]["total"] == 0
|
||||||
|
|
||||||
|
def test_human_verify(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
|
||||||
|
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
|
||||||
|
json={"verify_status": "human_verified", "verified_by": "任富海"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["verify_status"] == STATUS_HUMAN_VERIFIED
|
||||||
|
assert body["verified_by"] == "任富海"
|
||||||
|
assert body["verified_at"] is not None
|
||||||
|
|
||||||
|
def test_human_verify_default(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token), json={})
|
||||||
|
assert resp.json()["verify_status"] == STATUS_HUMAN_VERIFIED
|
||||||
|
assert resp.json()["verified_by"] is not None # 默认取用户名
|
||||||
|
|
||||||
|
def test_verify_disputed(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
|
||||||
|
json={"verify_status": "disputed"})
|
||||||
|
assert resp.json()["verify_status"] == STATUS_DISPUTED
|
||||||
|
|
||||||
|
def test_verify_invalid_status(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
|
||||||
|
json={"verify_status": "bogus"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_verify_not_found(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
resp = client.put(f"{BASE}/99999/verify", headers=auth_header(token),
|
||||||
|
json={"verify_status": "human_verified"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_business_cannot_verify(self, client: TestClient, db: Session):
|
||||||
|
"""business 角色无写权限 → 403"""
|
||||||
|
business = User(
|
||||||
|
username="business_verify", password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
|
||||||
|
name="业务员", role="business",
|
||||||
|
)
|
||||||
|
db.add(business)
|
||||||
|
db.commit()
|
||||||
|
token = get_token_for_user(client, username="business_verify", password="pass123")
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
|
||||||
|
json={"verify_status": "human_verified"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_entity_isolation(self, client: TestClient, db: Session):
|
||||||
|
"""企业B看不到企业A的链,也不能verify企业A的链"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client) # entity_id=1
|
||||||
|
_seed_entity2(db)
|
||||||
|
src2 = _seed_kpi(db, "BH2_REVENUE", "博海收入", entity_id=2)
|
||||||
|
tgt2 = _seed_kpi(db, "BH2_PROFIT", "博海利润", entity_id=2)
|
||||||
|
c2 = KPICausality(entity_id=2, source_kpi_id=src2.id, target_kpi_id=tgt2.id,
|
||||||
|
strength=0.5, lag_months=0, direction="positive",
|
||||||
|
source_type="manual", verify_status=STATUS_PENDING)
|
||||||
|
db.add(c2)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# entity1 的 verify-status 看不到 entity2 的链
|
||||||
|
resp = client.get(f"{BASE}/verify-status", headers=auth_header(token))
|
||||||
|
assert resp.json()["summary"]["total"] == 0
|
||||||
|
# entity1 的 token verify entity2 的链 → 404
|
||||||
|
resp2 = client.put(f"{BASE}/{c2.id}/verify", headers=auth_header(token),
|
||||||
|
json={"verify_status": "human_verified"})
|
||||||
|
assert resp2.status_code == 404
|
||||||
|
|
||||||
|
def test_network_includes_verify_status(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
src, tgt, c = _seed_chain(db)
|
||||||
|
resp = client.get(f"{BASE}/kpi/{src.id}/network", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
downstream = resp.json()["downstream"]
|
||||||
|
assert downstream[0]["verify_status"] == STATUS_PENDING
|
||||||
|
assert downstream[0]["source_type"] == "AI_suggested"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_entity2(db: Session) -> None:
|
||||||
|
ent = db.query(Entity).filter(Entity.id == 2).first()
|
||||||
|
if not ent:
|
||||||
|
db.add(Entity(id=2, name="博海网络科技", short_name="博海", status="active"))
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""数据分类分级 API 测试 — 重要数据标记 + 资产清单 + 导出"""
|
||||||
|
import hashlib
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Subject, KPIDefinition, Entity
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_subject(db: Session, **kwargs) -> Subject:
|
||||||
|
"""创建测试科目"""
|
||||||
|
defaults = {
|
||||||
|
"subject_code": "1001",
|
||||||
|
"subject_name": "库存现金",
|
||||||
|
"level": 1,
|
||||||
|
"is_active": 1,
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
s = Subject(**defaults)
|
||||||
|
db.add(s)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_kpi(db: Session, **kwargs) -> KPIDefinition:
|
||||||
|
"""创建测试KPI(带entity_id)"""
|
||||||
|
defaults = {
|
||||||
|
"entity_id": 1,
|
||||||
|
"kpi_code": "F_TEST_REVENUE",
|
||||||
|
"kpi_name": "营业收入(万元)",
|
||||||
|
"dimension": "finance",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
k = KPIDefinition(**defaults)
|
||||||
|
db.add(k)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(k)
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
class TestInventory:
|
||||||
|
def test_inventory_empty(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["stats"]["marked"] == 0
|
||||||
|
|
||||||
|
def test_inventory_contains_subject_and_kpi(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
types = {i["type"] for i in data["items"]}
|
||||||
|
assert types == {"subject", "kpi"}
|
||||||
|
assert data["total"] == 2
|
||||||
|
# 未标记时级别默认 general
|
||||||
|
assert data["stats"]["by_level"]["general"] == 2
|
||||||
|
|
||||||
|
def test_filter_by_data_type(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"data_type": "kpi"}, headers=auth_header(token))
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["type"] == "kpi"
|
||||||
|
|
||||||
|
def test_filter_by_level(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"data_level": "important"}, headers=auth_header(token))
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["name"] == "库存现金"
|
||||||
|
|
||||||
|
def test_filter_important_only(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"important": 1}, headers=auth_header(token))
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
|
||||||
|
def test_invalid_level_400(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"data_level": "secret"}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkSubject:
|
||||||
|
def test_mark_subject(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
s = create_test_subject(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put(f"/api/cma/data-classification/subjects/{s.id}",
|
||||||
|
params={"data_level": "important", "data_category": "财务数据"},
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# 重要级别自动视为重要数据
|
||||||
|
assert body["important_flag"] == 1
|
||||||
|
assert body["data_level"] == "important"
|
||||||
|
assert body["data_category"] == "财务数据"
|
||||||
|
|
||||||
|
def test_mark_subject_general_clears(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
s = create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put(f"/api/cma/data-classification/subjects/{s.id}",
|
||||||
|
params={"data_level": "general", "important_flag": 0},
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["data_level"] == "general"
|
||||||
|
assert r.json()["important_flag"] == 0
|
||||||
|
|
||||||
|
def test_mark_subject_not_found(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put("/api/cma/data-classification/subjects/9999",
|
||||||
|
params={"data_level": "important"}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkKpi:
|
||||||
|
def test_mark_kpi(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
k = create_test_kpi(db, entity_id=1)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put(f"/api/cma/data-classification/kpis/{k.id}",
|
||||||
|
params={"data_level": "core", "data_category": "财务数据"},
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["important_flag"] == 1
|
||||||
|
assert r.json()["data_level"] == "core"
|
||||||
|
|
||||||
|
def test_kpi_entity_isolation(self, client: TestClient, db: Session):
|
||||||
|
"""多租户隔离:entity 2 的KPI,entity 1 的token不可见、不可标记"""
|
||||||
|
create_test_user(db)
|
||||||
|
k2 = create_test_kpi(db, entity_id=2, kpi_code="F_OTHER_ENTITY")
|
||||||
|
token = get_token_for_user(client) # token 绑定 entity 1
|
||||||
|
# 不可标记
|
||||||
|
r = client.put(f"/api/cma/data-classification/kpis/{k2.id}",
|
||||||
|
params={"data_level": "important"}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 404
|
||||||
|
# 清单中不可见
|
||||||
|
r2 = client.get("/api/cma/data-classification/inventory",
|
||||||
|
params={"data_type": "kpi"}, headers=auth_header(token))
|
||||||
|
data = r2.json()
|
||||||
|
assert all(i["code"] != "F_OTHER_ENTITY" for i in data["items"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatch:
|
||||||
|
def test_batch_mark_subjects(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
s1 = create_test_subject(db, subject_code="1001", subject_name="库存现金")
|
||||||
|
s2 = create_test_subject(db, subject_code="1002", subject_name="银行存款")
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put("/api/cma/data-classification/batch",
|
||||||
|
params={"data_type": "subject", "ids": [s1.id, s2.id],
|
||||||
|
"data_level": "important", "data_category": "财务数据"},
|
||||||
|
headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["updated_count"] == 2
|
||||||
|
# 回查确认生效
|
||||||
|
db.expire_all()
|
||||||
|
assert db.query(Subject).get(s1.id).important_flag == 1
|
||||||
|
assert db.query(Subject).get(s2.id).data_level == "important"
|
||||||
|
|
||||||
|
def test_batch_invalid_type(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.put("/api/cma/data-classification/batch",
|
||||||
|
params={"data_type": "bad", "ids": [1]}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestExport:
|
||||||
|
def test_export_csv(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/export", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "text/csv" in r.headers["content-type"]
|
||||||
|
text = r.text
|
||||||
|
# 表头 + 类型 + 中文标签
|
||||||
|
assert "类型" in text and "数据级别" in text
|
||||||
|
assert "库存现金" in text
|
||||||
|
assert "科目" in text and "KPI" in text
|
||||||
|
assert "重要数据" in text
|
||||||
|
|
||||||
|
def test_export_filtered(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/export",
|
||||||
|
params={"important": 1}, headers=auth_header(token))
|
||||||
|
text = r.text
|
||||||
|
assert "库存现金" in text
|
||||||
|
assert "营业收入(万元)" not in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestStats:
|
||||||
|
def test_stats(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
create_test_subject(db, data_level="important", important_flag=1)
|
||||||
|
create_test_subject(db, subject_code="1002", subject_name="银行存款")
|
||||||
|
create_test_kpi(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/stats", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["subjects"]["total"] == 2
|
||||||
|
assert data["subjects"]["marked"] == 1
|
||||||
|
assert data["kpis"]["total"] == 1
|
||||||
|
assert data["marked_total"] == 1
|
||||||
|
assert "policy_note" in data
|
||||||
|
|
||||||
|
def test_industry_reference(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.get("/api/cma/data-classification/industry-reference", headers=auth_header(token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert len(items) >= 5
|
||||||
|
industries = {i["industry"] for i in items}
|
||||||
|
assert "金融" in industries and "医疗" in industries
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""DAMA数据治理规则检查(财务七规则)测试 — 2026-08-30
|
||||||
|
|
||||||
|
覆盖 data_quality.py 的 _run_governance_checks(governance-check 端点核心逻辑):
|
||||||
|
7条规则:unit_check/dup_alert/orphan_check/virtual_pollution/entity_check/kpi_completeness/reconciliation
|
||||||
|
+ 评分规则(error 扣 min(15,count*3),warning 扣 min(10,count*1),规则4 manual 附加扣)
|
||||||
|
|
||||||
|
验收活数据对照(生产环境实测):reconciliation=3、kpi_completeness=53、其余0。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
KPIDefinition, KPIValue, KPIAlert, CashPlan, BudgetPlan,
|
||||||
|
)
|
||||||
|
from app.api.data_quality import _run_governance_checks
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_kpi(db, **kw):
|
||||||
|
defaults = {
|
||||||
|
"entity_id": 1, "kpi_code": "TEST_001", "kpi_name": "测试KPI",
|
||||||
|
"status": "active", "target_value": 100.0, "target_yearly": 100.0,
|
||||||
|
"formula": "x", "data_source": "test", "data_owner": "财务部",
|
||||||
|
"unit": "元", "kpi_level": "operational",
|
||||||
|
}
|
||||||
|
defaults.update(kw)
|
||||||
|
k = KPIDefinition(**defaults)
|
||||||
|
db.add(k)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(k)
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_plan(db, **kw):
|
||||||
|
defaults = {
|
||||||
|
"entity_id": 1, "plan_type": "receive", "amount": 10.0,
|
||||||
|
"plan_date": datetime(2026, 8, 1), "source": "manual", "status": "pending",
|
||||||
|
}
|
||||||
|
defaults.update(kw)
|
||||||
|
p = CashPlan(**defaults)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_alert(db, kpi_id, plan_id, **kw):
|
||||||
|
defaults = {
|
||||||
|
"kpi_id": kpi_id, "alert_type": "cash_plan", "status": "pending",
|
||||||
|
"alert_message": "应收预警",
|
||||||
|
"suggestion": json.dumps({"plan_id": plan_id}),
|
||||||
|
}
|
||||||
|
defaults.update(kw)
|
||||||
|
a = KPIAlert(**defaults)
|
||||||
|
db.add(a)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(a)
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
class TestGovernanceSevenRules:
|
||||||
|
def test_clean_db_all_pass(self, db):
|
||||||
|
"""空库:7条规则全部通过,score=100"""
|
||||||
|
r = _run_governance_checks(db, 0)
|
||||||
|
assert r["total_rules"] == 7
|
||||||
|
assert r["score"] == 100
|
||||||
|
assert r["total_deduct"] == 0
|
||||||
|
assert len(r["passed"]) == 7
|
||||||
|
for item in r["issues"]:
|
||||||
|
assert item["count"] == 0
|
||||||
|
|
||||||
|
def test_all_rules_hit(self, db):
|
||||||
|
"""构造数据触发全部7条规则"""
|
||||||
|
# KPI-1: active + 有值 + 年度目标100 但预算月度合计200(勾稽差异100%)
|
||||||
|
k1 = _mk_kpi(db, kpi_code="KPI_001", kpi_name="勾稽KPI", target_yearly=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=k1.id, entity_id=1, period="2026-06", actual_value=50.0))
|
||||||
|
for m in range(1, 13):
|
||||||
|
db.add(BudgetPlan(kpi_id=k1.id, entity_id=1, period=f"2026-{m:02d}",
|
||||||
|
budget_value=200.0 / 12, budget_year=2026, budget_month=m,
|
||||||
|
version="v1.0", status="active"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# KPI-2: active 无任何值(KPI完整性命中)
|
||||||
|
_mk_kpi(db, kpi_code="KPI_002", kpi_name="无值KPI", target_yearly=10.0)
|
||||||
|
|
||||||
|
# KPI-3: 值实体=2 ≠ 定义实体=1(实体归属命中)
|
||||||
|
k3 = _mk_kpi(db, kpi_code="KPI_003", kpi_name="实体错乱KPI", target_yearly=10.0)
|
||||||
|
db.add(KPIValue(kpi_id=k3.id, entity_id=2, period="2026-06", actual_value=5.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 单位校验:amount=50000 > 10000(单位错乱命中)
|
||||||
|
_mk_plan(db, id=1, amount=50000.0)
|
||||||
|
|
||||||
|
# 虚拟污染:source=test_import(error)+ source=manual(待人工确认)
|
||||||
|
_mk_plan(db, id=2, amount=100.0, source="test_import")
|
||||||
|
_mk_plan(db, id=3, amount=100.0, source="manual")
|
||||||
|
|
||||||
|
# 孤儿预警:plan_id=999 不存在(孤儿命中)
|
||||||
|
_mk_alert(db, k1.id, 999)
|
||||||
|
|
||||||
|
# 重复预警:plan_id=5 两条 pending(重复命中)
|
||||||
|
_mk_plan(db, id=5, amount=100.0)
|
||||||
|
_mk_alert(db, k1.id, 5)
|
||||||
|
_mk_alert(db, k1.id, 5)
|
||||||
|
|
||||||
|
r = _run_governance_checks(db, 0)
|
||||||
|
by_rule = {i["rule"]: i for i in r["issues"]}
|
||||||
|
|
||||||
|
assert by_rule["unit_check"]["count"] == 1
|
||||||
|
assert by_rule["dup_alert"]["count"] == 1
|
||||||
|
assert by_rule["orphan_check"]["count"] == 1
|
||||||
|
assert by_rule["virtual_pollution"]["count"] == 1
|
||||||
|
assert by_rule["virtual_pollution"]["manual_count"] == 3 # plan#1/#3/#5 默认manual
|
||||||
|
assert by_rule["entity_check"]["count"] == 1
|
||||||
|
assert by_rule["kpi_completeness"]["count"] == 1
|
||||||
|
assert by_rule["reconciliation"]["count"] == 1
|
||||||
|
|
||||||
|
# 评分:5条error × min(15,3)=3 → 15;2条warning × 1 → 2;manual附加 min(10,3)=3 → 总扣20
|
||||||
|
assert r["total_deduct"] == 20
|
||||||
|
assert r["score"] == 80
|
||||||
|
assert len(r["passed"]) == 0
|
||||||
|
|
||||||
|
def test_entity_scoped(self, db):
|
||||||
|
"""entity_id 限定:只检查该实体数据"""
|
||||||
|
k1 = _mk_kpi(db, kpi_code="KPI_001", kpi_name="实体1KPI", entity_id=1, target_yearly=10.0)
|
||||||
|
k2 = _mk_kpi(db, kpi_code="KPI_002", kpi_name="实体2KPI", entity_id=2, target_yearly=10.0)
|
||||||
|
db.add(KPIValue(kpi_id=k2.id, entity_id=2, period="2026-06", actual_value=5.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r1 = _run_governance_checks(db, 1)
|
||||||
|
comp1 = {i["rule"]: i["count"] for i in r1["issues"]}
|
||||||
|
assert comp1["kpi_completeness"] == 1 # 实体1下KPI_001无值
|
||||||
|
assert comp1["entity_check"] == 0 # 实体1下无实体错乱
|
||||||
|
|
||||||
|
r2 = _run_governance_checks(db, 2)
|
||||||
|
comp2 = {i["rule"]: i["count"] for i in r2["issues"]}
|
||||||
|
assert comp2["kpi_completeness"] == 0 # KPI_002有值
|
||||||
|
assert comp2["entity_check"] == 0 # 值实体=2与定义实体=2一致
|
||||||
|
assert comp2["reconciliation"] == 0 # 无预算行不参与勾稽
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
基线测试:KPI创建接口缺少必填元数据字段时返回 HTTP 422。
|
||||||
|
|
||||||
|
场景:POST /api/cma/kpis 请求体不传 formula 字段(数据治理规则2: 元数据必填),
|
||||||
|
期望返回 HTTP 422,且 errors 中包含 formula 相关提示。
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||||
|
|
||||||
|
|
||||||
|
class TestKpi422Baseline:
|
||||||
|
"""KPI创建缺少必填元数据字段 → 422 基线测试"""
|
||||||
|
|
||||||
|
def test_create_kpi_missing_formula_returns_422(self, client: TestClient, db: Session):
|
||||||
|
"""不传 formula 字段时,创建KPI返回 422"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
# 构造请求体:其余必填字段齐全,唯独不传 formula
|
||||||
|
payload = {
|
||||||
|
"kpi_code": "F_BASELINE_001",
|
||||||
|
"kpi_name": "基线测试收入指标",
|
||||||
|
"dimension": "finance",
|
||||||
|
"target_value": 1000000,
|
||||||
|
"unit": "元",
|
||||||
|
# 注意:故意不传 formula(必填元数据字段)
|
||||||
|
"data_source": "测试系统",
|
||||||
|
"data_owner": "测试管理员",
|
||||||
|
}
|
||||||
|
resp = client.post("/api/cma/kpis", headers=auth_header(token), json=payload)
|
||||||
|
assert resp.status_code == 422, f"期望422,实际 {resp.status_code}: {resp.text}"
|
||||||
|
|
||||||
|
# 校验错误信息中包含 formula 字段
|
||||||
|
# 注意:FastAPI HTTPException(detail=dict) 时响应体为 {"detail": {...}}
|
||||||
|
body = resp.json()
|
||||||
|
detail = body.get("detail", {})
|
||||||
|
errors = detail.get("errors", []) if isinstance(detail, dict) else []
|
||||||
|
assert any("formula" in e for e in errors), f"errors 应提及 formula: {body}"
|
||||||
@@ -348,6 +348,6 @@ class TestPermissions:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
def test_no_token_denied(self, client: TestClient):
|
def test_no_token_denied(self, client: TestClient):
|
||||||
"""无token → 403"""
|
"""无token → 403(router级 require_role 直接拒绝,与 test_reports 约定一致)"""
|
||||||
resp = client.get(BASE)
|
resp = client.get(BASE)
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
KR完整修复(2026-08-27) — krs表打通 + 方向符号operator + 权重输入
|
||||||
|
覆盖: krs CRUD / operator方向感知progress / 权重校验 / 多租户隔离 / KPI方向继承
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
create_test_user, get_token_for_user, auth_header, create_test_kpi,
|
||||||
|
)
|
||||||
|
from app.models import Objective, KR, KPIDefinition, Entity
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_objective(db: Session, **kwargs) -> Objective:
|
||||||
|
defaults = {
|
||||||
|
"title": "测试OKR目标",
|
||||||
|
"quarter": "2026Q3",
|
||||||
|
"dimension": "finance",
|
||||||
|
"owner": "测试管理员",
|
||||||
|
"status": "active",
|
||||||
|
"progress": 0,
|
||||||
|
"entity_id": 1,
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
obj = Objective(**defaults)
|
||||||
|
db.add(obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# KR CRUD — 落krs表
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestKrCrud:
|
||||||
|
def test_create_kr_persists_to_krs_table(self, client: TestClient, db: Session):
|
||||||
|
"""创建目标+KR后,krs表 COUNT(*)>0,且OKR API返回krs表的KR"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/cma/okr/{obj.id}/krs",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={
|
||||||
|
"title": "营收增长至800万",
|
||||||
|
"operator": ">=",
|
||||||
|
"target_value": 800,
|
||||||
|
"weight": 40,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["id"] > 0
|
||||||
|
|
||||||
|
# krs表有数据
|
||||||
|
count = db.query(KR).filter(KR.objective_id == obj.id).count()
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
# OKR API返回krs表KR(operator/weight都在)
|
||||||
|
resp2 = client.get(f"/api/cma/okr/{obj.id}", headers=auth_header(token))
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
krs = resp2.json()["key_results"]
|
||||||
|
assert len(krs) == 1
|
||||||
|
assert krs[0]["title"] == "营收增长至800万"
|
||||||
|
assert krs[0]["operator"] == ">="
|
||||||
|
assert krs[0]["target_value"] == 800.0
|
||||||
|
assert krs[0]["weight"] == 40.0
|
||||||
|
|
||||||
|
def test_update_kr_and_delete_kr(self, client: TestClient, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
|
||||||
|
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||||
|
json={"title": "KR1", "target_value": 100, "weight": 50})
|
||||||
|
kr_id = r.json()["id"]
|
||||||
|
|
||||||
|
# 更新
|
||||||
|
r2 = client.put(f"/api/cma/okr/{obj.id}/krs/{kr_id}", headers=auth_header(token),
|
||||||
|
json={"title": "KR1改", "target_value": 120, "weight": 60})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["kr"]["title"] == "KR1改"
|
||||||
|
assert r2.json()["kr"]["target_value"] == 120.0
|
||||||
|
|
||||||
|
# 删除
|
||||||
|
r3 = client.delete(f"/api/cma/okr/{obj.id}/krs/{kr_id}", headers=auth_header(token))
|
||||||
|
assert r3.status_code == 200
|
||||||
|
assert db.query(KR).filter(KR.id == kr_id).first() is None
|
||||||
|
|
||||||
|
def test_sync_krs_batch(self, client: TestClient, db: Session):
|
||||||
|
"""批量同步: 40+35+25=100 权重自由组合可通过"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
|
||||||
|
resp = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token),
|
||||||
|
json={"krs": [
|
||||||
|
{"title": "KR-A", "operator": ">=", "target_value": 100, "weight": 40},
|
||||||
|
{"title": "KR-B", "operator": ">=", "target_value": 200, "weight": 35},
|
||||||
|
{"title": "KR-C", "operator": "<=", "target_value": 50, "weight": 25},
|
||||||
|
]})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
krs = resp.json()["krs"]
|
||||||
|
assert len(krs) == 3
|
||||||
|
weights = [float(k["weight"]) for k in krs]
|
||||||
|
assert sum(weights) == 100.0, f"权重应可自由组合为100: {weights}"
|
||||||
|
|
||||||
|
# 再次同步(少一条)→ 应删除
|
||||||
|
resp2 = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token),
|
||||||
|
json={"krs": [
|
||||||
|
{"title": "KR-A", "target_value": 100, "weight": 50},
|
||||||
|
{"title": "KR-B", "target_value": 200, "weight": 50},
|
||||||
|
]})
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert len(resp2.json()["krs"]) == 2
|
||||||
|
assert db.query(KR).filter(KR.objective_id == obj.id).count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# operator 方向感知 progress + 达成判断
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestOperatorDirection:
|
||||||
|
def test_gte_direction_progress(self, client: TestClient, db: Session):
|
||||||
|
""">= 场景: current/target*100; 达到目标 → achieved"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
|
||||||
|
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||||
|
json={"title": "营收>=800", "operator": ">=", "target_value": 800,
|
||||||
|
"current_value": 600})
|
||||||
|
kr = r.json()["kr"]
|
||||||
|
assert kr["progress"] == 75, f"600/800=75%: {kr}"
|
||||||
|
assert kr["status"] == "in_progress"
|
||||||
|
|
||||||
|
r2 = client.put(f"/api/cma/okr/{obj.id}/krs/{kr['id']}", headers=auth_header(token),
|
||||||
|
json={"current_value": 800})
|
||||||
|
kr2 = r2.json()["kr"]
|
||||||
|
assert kr2["progress"] == 100
|
||||||
|
assert kr2["status"] == "achieved", f"达成应置achieved: {kr2}"
|
||||||
|
|
||||||
|
def test_lte_direction_progress(self, client: TestClient, db: Session):
|
||||||
|
"""<= 场景(费用率): 值越小progress越高; 达标→achieved"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
|
||||||
|
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||||
|
json={"title": "费用率<=18", "operator": "<=", "target_value": 18,
|
||||||
|
"current_value": 24})
|
||||||
|
kr = r.json()["kr"]
|
||||||
|
# 24 > 18 → 未达标: 18/24=75
|
||||||
|
assert kr["progress"] == 75, f"方向感知错误: {kr}"
|
||||||
|
assert kr["status"] == "in_progress"
|
||||||
|
|
||||||
|
r2 = client.put(f"/api/cma/okr/{obj.id}/krs/{kr['id']}", headers=auth_header(token),
|
||||||
|
json={"current_value": 15})
|
||||||
|
kr2 = r2.json()["kr"]
|
||||||
|
assert kr2["progress"] == 100, f"15<=18 应100%: {kr2}"
|
||||||
|
assert kr2["status"] == "achieved"
|
||||||
|
|
||||||
|
def test_eq_with_tolerance(self, client: TestClient, db: Session):
|
||||||
|
"""= 场景: 容差内100%"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
|
||||||
|
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||||
|
json={"title": "库存=50", "operator": "=", "target_value": 50,
|
||||||
|
"tolerance": 1, "current_value": 50.5})
|
||||||
|
kr = r.json()["kr"]
|
||||||
|
assert kr["progress"] == 100
|
||||||
|
assert kr["status"] == "achieved"
|
||||||
|
|
||||||
|
def test_kpi_direction_inherit(self, client: TestClient, db: Session):
|
||||||
|
"""关联KPI自动继承方向(F_COST_RATIO threshold_green '<=18')"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, kpi_code="F_COST_RATIO", kpi_name="成本费用率",
|
||||||
|
threshold_green="<=18")
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
|
||||||
|
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||||
|
json={"title": "压降成本费用率", "metric_kpi_id": kpi.id})
|
||||||
|
kr = r.json()["kr"]
|
||||||
|
assert kr["operator"] == "<=", f"应继承KPI方向<=: {kr}"
|
||||||
|
assert kr["target_value"] == 18.0, f"应继承KPI目标18: {kr}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 权重校验
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestWeightValidation:
|
||||||
|
def test_fractional_weight_33_33_34(self, client: TestClient, db: Session):
|
||||||
|
"""33.33+33.33+33.34 = 100 可输入(小数权重)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
|
||||||
|
resp = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token),
|
||||||
|
json={"krs": [
|
||||||
|
{"title": "A", "target_value": 10, "weight": 33.33},
|
||||||
|
{"title": "B", "target_value": 10, "weight": 33.33},
|
||||||
|
{"title": "C", "target_value": 10, "weight": 33.34},
|
||||||
|
]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
weights = [float(k["weight"]) for k in resp.json()["krs"]]
|
||||||
|
assert abs(sum(weights) - 100.0) < 0.01, weights
|
||||||
|
|
||||||
|
def test_auto_divide_3_krs_33_33_34(self, db: Session):
|
||||||
|
"""3个KR自动平分 → 33/33/34(后端sync不做校验,前端提供;此处验证权重存储)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = None # 直接走模型层验证
|
||||||
|
from app.api.okr import _calc_progress # noqa
|
||||||
|
|
||||||
|
obj = create_test_objective(db)
|
||||||
|
# 前端自动平分逻辑:base=33, remainder=1 → 33/33/34
|
||||||
|
n = 3
|
||||||
|
base = 100 // n
|
||||||
|
remainder = 100 - base * n
|
||||||
|
weights = [base] * (n - 1) + [base + remainder]
|
||||||
|
assert weights == [33, 33, 34]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 多租户隔离
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestMultiTenant:
|
||||||
|
def test_kr_entity_isolation(self, client: TestClient, db: Session):
|
||||||
|
"""账套隔离: entity_id=2 的用户看不到 entity_id=1 的KR"""
|
||||||
|
# entity 2 存在
|
||||||
|
ent2 = db.query(Entity).filter(Entity.id == 2).first()
|
||||||
|
if not ent2:
|
||||||
|
db.add(Entity(id=2, name="另一企业", short_name="B", status="active"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
obj1 = create_test_objective(db, title="企业1目标") # entity 1
|
||||||
|
|
||||||
|
r = client.post(f"/api/cma/okr/{obj1.id}/krs", headers=auth_header(token),
|
||||||
|
json={"title": "企业1的KR", "target_value": 100})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# 用 entity 2 登录
|
||||||
|
ent2_user = create_test_user(db, username="user2", role="ceo")
|
||||||
|
resp2 = client.post("/api/cma/auth/login", json={
|
||||||
|
"username": "user2", "password": "admin123", "entity_id": 2})
|
||||||
|
token2 = resp2.json().get("token")
|
||||||
|
assert token2
|
||||||
|
|
||||||
|
# entity2 访问 entity1 的目标 → 404
|
||||||
|
resp3 = client.get(f"/api/cma/okr/{obj1.id}", headers=auth_header(token2))
|
||||||
|
assert resp3.status_code == 404, "跨企业应404"
|
||||||
@@ -4,9 +4,30 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models import Entity, User, UserEntity
|
||||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||||
|
|
||||||
|
|
||||||
|
def _create_second_entity(db: Session, user: User) -> int:
|
||||||
|
"""创建第二个企业账套并授权测试用户,返回 entity_id"""
|
||||||
|
db.add(Entity(id=2, name="第二企业", short_name="企业2", status="active"))
|
||||||
|
db.add(UserEntity(user_id=user.id, entity_id=2, granted_by=1))
|
||||||
|
db.commit()
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _token_for_entity(client: TestClient, entity_id: int) -> str:
|
||||||
|
"""以指定账套登录获取token"""
|
||||||
|
resp = client.post("/api/cma/auth/login", json={
|
||||||
|
"username": "testadmin",
|
||||||
|
"password": "admin123",
|
||||||
|
"entity_id": entity_id,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("token") or data.get("access_token")
|
||||||
|
|
||||||
|
|
||||||
class TestMaps:
|
class TestMaps:
|
||||||
"""战略地图CRUD测试"""
|
"""战略地图CRUD测试"""
|
||||||
|
|
||||||
@@ -124,3 +145,142 @@ class TestMaps:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# 同维度连线现在被允许了,不再是旧的拒绝逻辑
|
# 同维度连线现在被允许了,不再是旧的拒绝逻辑
|
||||||
# assert "不能" in resp.json()["detail"]
|
# assert "不能" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_create_objective(self, client: TestClient, db: Session):
|
||||||
|
"""在地图上新增目标并返回目标对象"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
create_resp = client.post(
|
||||||
|
"/api/cma/maps/create-with-template",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"title": "新增目标测试"},
|
||||||
|
)
|
||||||
|
map_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/cma/maps/{map_id}/objectives",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"dimension_key": "finance", "name": "新增营收目标", "icon": "target"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "新增营收目标"
|
||||||
|
assert data["dimension_key"] == "finance"
|
||||||
|
assert data["map_id"] == map_id
|
||||||
|
assert data["entity_id"] == create_resp.json()["entity_id"]
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
def test_create_objective_wrong_entity_404(self, client: TestClient, db: Session):
|
||||||
|
"""跨账套新增目标返回404(地图不属于当前企业)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = get_token_for_user(client)
|
||||||
|
|
||||||
|
create_resp = client.post(
|
||||||
|
"/api/cma/maps/create-with-template",
|
||||||
|
headers=auth_header(token1),
|
||||||
|
json={"title": "账户A地图"},
|
||||||
|
)
|
||||||
|
map_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# 第二个企业账套
|
||||||
|
user = db.query(User).filter(User.username == "testadmin").first()
|
||||||
|
_create_second_entity(db, user)
|
||||||
|
token2 = _token_for_entity(client, 2)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/cma/maps/{map_id}/objectives",
|
||||||
|
headers=auth_header(token2),
|
||||||
|
json={"dimension_key": "finance", "name": "越权目标"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_create_snapshot_inherits_entity(self, client: TestClient, db: Session):
|
||||||
|
"""手动快照创建版本并继承地图企业"""
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = get_token_for_user(client)
|
||||||
|
|
||||||
|
create_resp = client.post(
|
||||||
|
"/api/cma/maps/create-with-template",
|
||||||
|
headers=auth_header(token1),
|
||||||
|
json={"title": "快照继承测试"},
|
||||||
|
)
|
||||||
|
map_id = create_resp.json()["id"]
|
||||||
|
map_entity = create_resp.json()["entity_id"]
|
||||||
|
|
||||||
|
snap_resp = client.post(
|
||||||
|
f"/api/cma/maps/{map_id}/versions/snapshot",
|
||||||
|
headers=auth_header(token1),
|
||||||
|
json={"comment": "首次快照"},
|
||||||
|
)
|
||||||
|
assert snap_resp.status_code == 200
|
||||||
|
snap = snap_resp.json()
|
||||||
|
assert snap["map_id"] == map_id
|
||||||
|
assert snap["version"] == "v1.0"
|
||||||
|
assert snap["entity_id"] == map_entity
|
||||||
|
|
||||||
|
def test_update_objective_rejects_entity_id_injection(self, client: TestClient, db: Session):
|
||||||
|
"""更新目标时注入entity_id被忽略,企业归属保持不变"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
|
||||||
|
create_resp = client.post(
|
||||||
|
"/api/cma/maps/create-with-template",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"title": "注入防护测试"},
|
||||||
|
)
|
||||||
|
map_id = create_resp.json()["id"]
|
||||||
|
orig_entity = create_resp.json()["entity_id"]
|
||||||
|
|
||||||
|
obj_resp = client.post(
|
||||||
|
f"/api/cma/maps/{map_id}/objectives",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"dimension_key": "finance", "name": "原目标"},
|
||||||
|
)
|
||||||
|
obj_id = obj_resp.json()["id"]
|
||||||
|
|
||||||
|
update_resp = client.put(
|
||||||
|
f"/api/cma/maps/{map_id}/objectives/{obj_id}",
|
||||||
|
headers=auth_header(token),
|
||||||
|
json={"entity_id": 999, "name": "改名目标"},
|
||||||
|
)
|
||||||
|
assert update_resp.status_code == 200
|
||||||
|
data = update_resp.json()
|
||||||
|
# entity_id 不在白名单字段中,注入被忽略 → 归属不变
|
||||||
|
assert data["entity_id"] == orig_entity
|
||||||
|
assert data["entity_id"] != 999
|
||||||
|
assert data["name"] == "改名目标"
|
||||||
|
|
||||||
|
def test_cross_entity_list_objectives_empty(self, client: TestClient, db: Session):
|
||||||
|
"""跨账套查询目标列表返回空(目标不属于当前企业)"""
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = get_token_for_user(client)
|
||||||
|
|
||||||
|
# 企业1创建空白地图(无任何目标)
|
||||||
|
create_resp = client.post(
|
||||||
|
"/api/cma/maps",
|
||||||
|
headers=auth_header(token1),
|
||||||
|
json={"title": "企业1地图"},
|
||||||
|
)
|
||||||
|
assert create_resp.status_code == 200
|
||||||
|
map_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# 企业1自己能看到空列表
|
||||||
|
own_resp = client.get(
|
||||||
|
f"/api/cma/maps/{map_id}/objectives",
|
||||||
|
headers=auth_header(token1),
|
||||||
|
)
|
||||||
|
assert own_resp.status_code == 200
|
||||||
|
assert own_resp.json()["data"] == []
|
||||||
|
|
||||||
|
# 第二个企业账套 → 跨账套查询同样返回空
|
||||||
|
user = db.query(User).filter(User.username == "testadmin").first()
|
||||||
|
_create_second_entity(db, user)
|
||||||
|
token2 = _token_for_entity(client, 2)
|
||||||
|
|
||||||
|
cross_resp = client.get(
|
||||||
|
f"/api/cma/maps/{map_id}/objectives",
|
||||||
|
headers=auth_header(token2),
|
||||||
|
)
|
||||||
|
assert cross_resp.status_code == 200
|
||||||
|
assert cross_resp.json()["data"] == []
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
预编报表(预算版三张报表)测试 — P2 2026-08-30
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. 三张预算版报表接口 200
|
||||||
|
2. has_budget 标注正确(有预算行 true / 无预算映射行 false)
|
||||||
|
3. 差异计算与 budget-execution 一致(同 KPI 同 period 对比)
|
||||||
|
4. 无预算行显式标注(budget_source=none)
|
||||||
|
5. 比率型KPI单独标注(ratio_kpi=true,不计算金额差异)
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||||
|
from app.models import KPIDefinition, KPIValue, BudgetPlan
|
||||||
|
|
||||||
|
|
||||||
|
def _create_kpi(db: Session, code: str, name: str, entity_id: int = 1, frequency: str = "monthly", target_value=None) -> KPIDefinition:
|
||||||
|
kpi = KPIDefinition(
|
||||||
|
entity_id=entity_id,
|
||||||
|
kpi_code=code,
|
||||||
|
kpi_name=name,
|
||||||
|
dimension="finance",
|
||||||
|
category="financial_report",
|
||||||
|
formula="-",
|
||||||
|
data_source="测试",
|
||||||
|
data_owner="财务部",
|
||||||
|
frequency=frequency,
|
||||||
|
unit="元",
|
||||||
|
target_value=target_value,
|
||||||
|
kpi_level="operational",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(kpi)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kpi)
|
||||||
|
return kpi
|
||||||
|
|
||||||
|
|
||||||
|
def _create_budget(db: Session, kpi_id: int, period: str, value: float, version: str = "v1.0") -> BudgetPlan:
|
||||||
|
plan = BudgetPlan(
|
||||||
|
entity_id=1,
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
period=period,
|
||||||
|
budget_value=value,
|
||||||
|
budget_year=int(period.split("-")[0]),
|
||||||
|
budget_month=int(period.split("-")[1]),
|
||||||
|
version=version,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(plan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plan)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _create_actual(db: Session, kpi_id: int, period: str, value: float) -> KPIValue:
|
||||||
|
v = KPIValue(kpi_id=kpi_id, period=period, actual_value=value, source_type="manual")
|
||||||
|
db.add(v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TestProformaProfitStatement:
|
||||||
|
BASE = "/api/cma/reports/proforma/profit-statement"
|
||||||
|
|
||||||
|
def _setup(self, db: Session):
|
||||||
|
"""F_REVENUE: 预算150 / 实际123.45;F_OP_CFLOW: 预算20"""
|
||||||
|
create_test_user(db)
|
||||||
|
rev = _create_kpi(db, "F_REVENUE", "营业收入", frequency="quarterly", target_value=1200)
|
||||||
|
op = _create_kpi(db, "F_OP_CFLOW", "经营性现金流")
|
||||||
|
_create_budget(db, rev.id, "2026-08", 150.0)
|
||||||
|
_create_budget(db, op.id, "2026-08", 20.0)
|
||||||
|
_create_actual(db, rev.id, "2026-08", 123.45)
|
||||||
|
|
||||||
|
def test_returns_200_and_budget_mapping(self, client: TestClient, db: Session):
|
||||||
|
self._setup(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
resp = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["period"] == "2026-08"
|
||||||
|
assert data["budget_version"] == "v1.0"
|
||||||
|
|
||||||
|
# 营业收入(6001) → F_REVENUE:预算150 实际123.45 差异-26.55/-17.7%
|
||||||
|
rev_line = None
|
||||||
|
for block in data["blocks"]:
|
||||||
|
for item in block["items"]:
|
||||||
|
if item["code"] == "6001":
|
||||||
|
rev_line = item
|
||||||
|
assert rev_line is not None, "利润表应含营业收入(6001)行"
|
||||||
|
assert rev_line["has_budget"] is True
|
||||||
|
assert rev_line["mapped_kpi_code"] == "F_REVENUE"
|
||||||
|
assert rev_line["budget_source"] == "budget_plan"
|
||||||
|
assert rev_line["budget_value"] == 150.0
|
||||||
|
assert rev_line["actual_value"] == 123.45
|
||||||
|
assert rev_line["deviation_amount"] == -26.55
|
||||||
|
assert rev_line["deviation_rate"] == -17.7
|
||||||
|
|
||||||
|
def test_no_budget_line_explicit(self, client: TestClient, db: Session):
|
||||||
|
"""无预算映射的行(如 6402 其他业务成本)显式 has_budget=false"""
|
||||||
|
self._setup(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
resp = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
line = None
|
||||||
|
for block in data["blocks"]:
|
||||||
|
for item in block["items"]:
|
||||||
|
if item["code"] == "6402":
|
||||||
|
line = item
|
||||||
|
assert line is not None
|
||||||
|
assert line["has_budget"] is False
|
||||||
|
assert line["budget_source"] == "none"
|
||||||
|
assert line["budget_value"] is None
|
||||||
|
|
||||||
|
def test_deviation_matches_budget_execution(self, client: TestClient, db: Session):
|
||||||
|
"""同 KPI 同 period:proforma 差异与 budget-execution 一致"""
|
||||||
|
self._setup(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
# budget-execution 里的 F_REVENUE
|
||||||
|
be = client.get("/api/cma/reports/budget-execution?period=2026-08", headers=auth_header(token))
|
||||||
|
assert be.status_code == 200
|
||||||
|
be_item = next(i for i in be.json()["items"] if i["kpi_code"] == "F_REVENUE")
|
||||||
|
# proforma 利润表 6001 行
|
||||||
|
pf = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token))
|
||||||
|
pf_item = None
|
||||||
|
for block in pf.json()["blocks"]:
|
||||||
|
for item in block["items"]:
|
||||||
|
if item["code"] == "6001":
|
||||||
|
pf_item = item
|
||||||
|
assert pf_item is not None
|
||||||
|
assert pf_item["actual_value"] == be_item["actual_value"]
|
||||||
|
assert pf_item["budget_value"] == be_item["budget_value"]
|
||||||
|
assert pf_item["deviation_amount"] == be_item["deviation_amount"]
|
||||||
|
assert pf_item["deviation_rate"] == be_item["deviation_rate"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestProformaBalanceSheet:
|
||||||
|
BASE = "/api/cma/reports/proforma/balance-sheet"
|
||||||
|
|
||||||
|
def _setup(self, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
op = _create_kpi(db, "F_OP_CFLOW", "经营性现金流")
|
||||||
|
ar = _create_kpi(db, "F_AR_DAYS", "应收账款周转天数", frequency="monthly", target_value=5)
|
||||||
|
_create_budget(db, op.id, "2026-08", 20.0)
|
||||||
|
_create_budget(db, ar.id, "2026-08", 5.0)
|
||||||
|
|
||||||
|
def test_returns_200_and_mappings(self, client: TestClient, db: Session):
|
||||||
|
self._setup(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
resp = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["budget_version"] == "v1.0"
|
||||||
|
|
||||||
|
lines = {}
|
||||||
|
for sec in data["sections"]:
|
||||||
|
for ln in sec["lines"]:
|
||||||
|
lines[ln["name"]] = ln
|
||||||
|
|
||||||
|
# 货币资金 → F_OP_CFLOW(有预算)
|
||||||
|
assert lines["货币资金"]["has_budget"] is True
|
||||||
|
assert lines["货币资金"]["mapped_kpi_code"] == "F_OP_CFLOW"
|
||||||
|
assert lines["货币资金"]["budget_value"] == 20.0
|
||||||
|
# 应收账款 → F_AR_DAYS(比率型,单独标注,不计算金额差异)
|
||||||
|
assert lines["应收账款"]["has_budget"] is True
|
||||||
|
assert lines["应收账款"]["ratio_kpi"] is True
|
||||||
|
assert lines["应收账款"]["mapped_kpi_code"] == "F_AR_DAYS"
|
||||||
|
assert lines["应收账款"]["deviation_amount"] is None
|
||||||
|
assert lines["应收账款"]["note"] is not None
|
||||||
|
# 无映射行(存货 1405)显式无预算
|
||||||
|
assert lines["存货"]["has_budget"] is False
|
||||||
|
assert lines["存货"]["budget_source"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProformaCashFlow:
|
||||||
|
BASE = "/api/cma/reports/proforma/cash-flow"
|
||||||
|
|
||||||
|
def _setup(self, db: Session):
|
||||||
|
create_test_user(db)
|
||||||
|
rev = _create_kpi(db, "F_REVENUE", "营业收入", frequency="quarterly")
|
||||||
|
op = _create_kpi(db, "F_OP_CFLOW", "经营性现金流")
|
||||||
|
_create_budget(db, rev.id, "2026-08", 150.0)
|
||||||
|
_create_budget(db, op.id, "2026-08", 20.0)
|
||||||
|
_create_actual(db, rev.id, "2026-08", 123.45)
|
||||||
|
|
||||||
|
def test_returns_200_and_mappings(self, client: TestClient, db: Session):
|
||||||
|
self._setup(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
resp = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["budget_version"] == "v1.0"
|
||||||
|
|
||||||
|
lines = {}
|
||||||
|
sections = {s["key"]: s for s in data["sections"]}
|
||||||
|
for sc in data["sections"]:
|
||||||
|
for ln in sc["lines"]:
|
||||||
|
lines[ln["code"]] = ln
|
||||||
|
|
||||||
|
# CF01 → F_REVENUE(有预算)
|
||||||
|
assert lines["CF01"]["has_budget"] is True
|
||||||
|
assert lines["CF01"]["mapped_kpi_code"] == "F_REVENUE"
|
||||||
|
assert lines["CF01"]["budget_value"] == 150.0
|
||||||
|
# CF02 无映射 → 显式无预算
|
||||||
|
assert lines["CF02"]["has_budget"] is False
|
||||||
|
assert lines["CF02"]["budget_source"] == "none"
|
||||||
|
# 经营净额 → F_OP_CFLOW 预算
|
||||||
|
assert sections["operating"]["net_budget"] == 20.0
|
||||||
|
assert sections["operating"]["has_budget"] is True
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Bot API 风险分级(L1-L4)标注 + 操作审计日志 测试
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. API_RISK_MAP 覆盖所有 /api/cma/bot* 路由(app.routes 遍历核对)
|
||||||
|
2. GET /api/cma/bot/risk-levels 返回200且含L1-L4定义
|
||||||
|
3. Bot API面不存在L4端点(无 drop/truncate/delete 批量端点,安全底线)
|
||||||
|
4. 审计日志在调用Bot API后写入(monkeypatch + 真实日志文件双验证)
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models import KPIDefinition
|
||||||
|
from app.risk_levels import API_RISK_MAP, RISK_LEVELS
|
||||||
|
import app.api.audit_log as audit_log_module
|
||||||
|
|
||||||
|
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
|
||||||
|
_HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH")
|
||||||
|
|
||||||
|
|
||||||
|
def _bot_routes():
|
||||||
|
"""遍历 app.routes,返回所有 /api/cma/bot* 路由 (route, methods列表)"""
|
||||||
|
routes = []
|
||||||
|
for r in app.routes:
|
||||||
|
path = getattr(r, "path", "")
|
||||||
|
if path.startswith("/api/cma/bot"):
|
||||||
|
methods = sorted(m for m in (getattr(r, "methods", set()) or set())
|
||||||
|
if m in _HTTP_METHODS)
|
||||||
|
routes.append((r, methods))
|
||||||
|
return routes
|
||||||
|
|
||||||
|
|
||||||
|
class TestRiskMapCoverage:
|
||||||
|
def test_api_risk_map_covers_all_bot_routes(self):
|
||||||
|
"""每个 /api/cma/bot* 路由都在 API_RISK_MAP 有级别标注,且函数有@risk_level装饰器"""
|
||||||
|
missing = []
|
||||||
|
unlabeled = []
|
||||||
|
for r, methods in _bot_routes():
|
||||||
|
for m in methods:
|
||||||
|
key = f"{m} {r.path}"
|
||||||
|
if key not in API_RISK_MAP:
|
||||||
|
missing.append(key)
|
||||||
|
if not getattr(r.endpoint, "risk_level", None):
|
||||||
|
unlabeled.append(f"{sorted(methods)} {r.path}")
|
||||||
|
assert not missing, f"API_RISK_MAP 缺少以下路由标注: {missing}"
|
||||||
|
assert not unlabeled, f"以下路由函数缺少 @risk_level 装饰器: {unlabeled}"
|
||||||
|
|
||||||
|
def test_risk_level_counts(self):
|
||||||
|
"""分级统计与方案一致:L1=21(20项清单+risk-levels端点)、L2=5(4项清单+okr/create)、L3=3、L4=0"""
|
||||||
|
from collections import Counter
|
||||||
|
counts = Counter(API_RISK_MAP.values())
|
||||||
|
assert counts["L1"] == 21, counts
|
||||||
|
assert counts["L2"] == 5, counts
|
||||||
|
assert counts["L3"] == 3, counts
|
||||||
|
assert counts["L4"] == 0, "Bot API面不得存在L4端点(安全底线)"
|
||||||
|
|
||||||
|
def test_no_l4_bot_endpoints(self):
|
||||||
|
"""Bot API面不存在L4端点:无DELETE方法、无drop/truncate/delete危险路径"""
|
||||||
|
danger_keywords = ("drop", "truncate", "delete")
|
||||||
|
for r, methods in _bot_routes():
|
||||||
|
assert getattr(r.endpoint, "risk_level", None) != "L4", \
|
||||||
|
f"{r.path} 不应被标注为L4"
|
||||||
|
assert "DELETE" not in methods, f"Bot路由不应有DELETE方法: {r.path}"
|
||||||
|
low = r.path.lower()
|
||||||
|
for kw in danger_keywords:
|
||||||
|
assert kw not in low, f"Bot路由不应含危险路径片段: {r.path}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRiskLevelsEndpoint:
|
||||||
|
def test_risk_levels_requires_bot_key(self, client):
|
||||||
|
"""无X-BOT-KEY → 401"""
|
||||||
|
resp = client.get("/api/cma/bot/risk-levels")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_risk_levels_returns_200_with_definitions(self, client):
|
||||||
|
"""X-BOT-KEY → 200,含L1-L4定义与API→级别→处理方式清单"""
|
||||||
|
resp = client.get("/api/cma/bot/risk-levels", headers=BOT_KEY)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# L1-L4 定义齐全
|
||||||
|
for level, label in RISK_LEVELS.items():
|
||||||
|
assert data["risk_levels"][level] == label, f"缺少 {level} 定义"
|
||||||
|
|
||||||
|
# API→级别→处理方式 清单
|
||||||
|
apis = {f"{a['method']} {a['path']}": a for a in data["apis"]}
|
||||||
|
assert apis["GET /api/cma/bot/ping"]["risk_level"] == "L1"
|
||||||
|
assert apis["POST /api/cma/bot/kpi-value-with-check"]["risk_level"] == "L2"
|
||||||
|
assert apis["POST /api/cma/bot/import"]["risk_level"] == "L3"
|
||||||
|
assert apis["POST /api/cma/bot/import"]["handling"] # 处理方式非空
|
||||||
|
|
||||||
|
# 分级统计
|
||||||
|
assert data["summary"]["L1"] == 21
|
||||||
|
assert data["summary"]["L2"] == 5
|
||||||
|
assert data["summary"]["L3"] == 3
|
||||||
|
assert data["summary"]["L4"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditLog:
|
||||||
|
def test_audit_record_after_bot_call(self, client, monkeypatch):
|
||||||
|
"""调用Bot API后写出审计记录(monkeypatch捕获)"""
|
||||||
|
records = []
|
||||||
|
monkeypatch.setattr(audit_log_module, "write_audit_line",
|
||||||
|
lambda rec: records.append(rec))
|
||||||
|
resp = client.get("/api/cma/bot/ping")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(records) >= 1
|
||||||
|
rec = records[-1]
|
||||||
|
assert rec["method"] == "GET"
|
||||||
|
assert rec["endpoint"] == "/api/cma/bot/ping"
|
||||||
|
assert rec["risk_level"] == "L1"
|
||||||
|
assert rec["status"] == 200
|
||||||
|
# 字段齐全
|
||||||
|
for field in ("timestamp", "bot_name", "endpoint", "method",
|
||||||
|
"risk_level", "entity_id", "status"):
|
||||||
|
assert field in rec, f"审计记录缺少字段: {field}"
|
||||||
|
|
||||||
|
def test_audit_file_written_json_lines(self, client, tmp_path, monkeypatch):
|
||||||
|
"""真实日志文件:调用Bot API后 bot_audit.log 追加JSON行"""
|
||||||
|
log_file = tmp_path / "bot_audit.log"
|
||||||
|
monkeypatch.setenv("CMA_BOT_AUDIT_LOG", str(log_file))
|
||||||
|
monkeypatch.setattr(audit_log_module, "_audit_logger", None)
|
||||||
|
|
||||||
|
resp = client.get("/api/cma/bot/ping")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert log_file.exists()
|
||||||
|
|
||||||
|
lines = log_file.read_text(encoding="utf-8").strip().splitlines()
|
||||||
|
assert lines, "审计日志文件为空"
|
||||||
|
rec = json.loads(lines[-1])
|
||||||
|
assert rec["endpoint"] == "/api/cma/bot/ping"
|
||||||
|
assert rec["risk_level"] == "L1"
|
||||||
|
assert rec["status"] == 200
|
||||||
|
|
||||||
|
def test_audit_entity_id_and_bot_name(self, client, db, monkeypatch):
|
||||||
|
"""审计记录含 bot_name(X-BOT-KEY映射)与 entity_id"""
|
||||||
|
records = []
|
||||||
|
monkeypatch.setattr(audit_log_module, "write_audit_line",
|
||||||
|
lambda rec: records.append(rec))
|
||||||
|
kpi = KPIDefinition(kpi_code="AUDIT_EID", kpi_name="审计实体", dimension="finance",
|
||||||
|
status="active", target_value=1.0, entity_id=1)
|
||||||
|
db.add(kpi)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.post("/api/cma/bot/kpi-value-with-check",
|
||||||
|
json={"kpi_id": kpi.id, "actual_value": 66.0,
|
||||||
|
"period": "2026-08", "entity_id": 1},
|
||||||
|
headers=BOT_KEY)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert records, "应有审计记录"
|
||||||
|
rec = records[-1]
|
||||||
|
assert rec["bot_name"] == "财务BOT"
|
||||||
|
assert rec["entity_id"] == 1
|
||||||
|
assert rec["risk_level"] == "L2"
|
||||||
|
assert rec["status"] == 200
|
||||||
|
# JSON body 读取未破坏业务
|
||||||
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
def test_l3_batch_write_records_rows(self, client, db, monkeypatch):
|
||||||
|
"""L3批量写(/import):审计记录额外含 rows 行数"""
|
||||||
|
kpi = KPIDefinition(kpi_code="AUDIT_ROWS", kpi_name="审计行数", dimension="finance",
|
||||||
|
status="active", target_value=1.0)
|
||||||
|
db.add(kpi)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(["kpi_code", "period", "actual_value"])
|
||||||
|
ws.append(["AUDIT_ROWS", "2026-08", 88.0])
|
||||||
|
ws.append(["AUDIT_ROWS", "2026-07", 77.0])
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
|
||||||
|
records = []
|
||||||
|
monkeypatch.setattr(audit_log_module, "write_audit_line",
|
||||||
|
lambda rec: records.append(rec))
|
||||||
|
files = {"file": ("kpi.xlsx", buf.getvalue(),
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||||
|
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["imported"] == 2
|
||||||
|
|
||||||
|
assert records, "应有审计记录"
|
||||||
|
rec = records[-1]
|
||||||
|
assert rec["risk_level"] == "L3"
|
||||||
|
assert rec["rows"] == 2
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
路线图R2/R5 测试(2026-08-30)
|
||||||
|
R2: 机会检测(KPI向好/预算余量/预测上行)
|
||||||
|
R5: 预算↔现金流↔行动 闭环自检
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from tests.conftest import create_test_kpi
|
||||||
|
from app.models import KPIDefinition, KPIValue, BudgetPlan, CashPlan, ActionPlan, KpiForecastLog
|
||||||
|
|
||||||
|
from scripts.opportunity_detector import (
|
||||||
|
detect_kpi_improving, detect_budget_headroom, detect_rolling_up, detect_all, flatten,
|
||||||
|
)
|
||||||
|
from scripts.closed_loop_check import check_entity, build_report
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi(db, code, target=100.0, **kw):
|
||||||
|
return create_test_kpi(db, kpi_code=code, target_value=target, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _value(db, kpi_id, period, actual, entity_id=1):
|
||||||
|
v = KPIValue(kpi_id=kpi_id, period=period, actual_value=actual, entity_id=entity_id)
|
||||||
|
db.add(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _budget(db, kpi_id, period, value, year=None, month=None, entity_id=1):
|
||||||
|
if year is None:
|
||||||
|
year = int(period.split("-")[0])
|
||||||
|
month = int(period.split("-")[1])
|
||||||
|
b = BudgetPlan(entity_id=entity_id, kpi_id=kpi_id, period=period, budget_value=value,
|
||||||
|
budget_year=year, budget_month=month, version="v1.0", status="active")
|
||||||
|
db.add(b)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpportunityR2:
|
||||||
|
def test_kpi_improving(self, db):
|
||||||
|
"""连续3期执行率>110% → KPI向好机会"""
|
||||||
|
kpi = _kpi(db, "OPP_01", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-04", 120.0)
|
||||||
|
_value(db, kpi.id, "2026-05", 130.0)
|
||||||
|
_value(db, kpi.id, "2026-06", 140.0)
|
||||||
|
db.commit()
|
||||||
|
out = detect_kpi_improving(db, 1)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["type"] == "kpi_improving"
|
||||||
|
assert out[0]["kpi_id"] == kpi.id
|
||||||
|
|
||||||
|
def test_kpi_improving_not_enough_data(self, db):
|
||||||
|
"""不足3期不判定"""
|
||||||
|
kpi = _kpi(db, "OPP_02", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-05", 130.0)
|
||||||
|
_value(db, kpi.id, "2026-06", 140.0)
|
||||||
|
db.commit()
|
||||||
|
assert detect_kpi_improving(db, 1) == []
|
||||||
|
|
||||||
|
def test_kpi_improving_low_ratio_skip(self, db):
|
||||||
|
"""执行率未超110%不判定"""
|
||||||
|
kpi = _kpi(db, "OPP_03", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-04", 90.0)
|
||||||
|
_value(db, kpi.id, "2026-05", 95.0)
|
||||||
|
_value(db, kpi.id, "2026-06", 100.0)
|
||||||
|
db.commit()
|
||||||
|
assert detect_kpi_improving(db, 1) == []
|
||||||
|
|
||||||
|
def test_budget_headroom(self, db):
|
||||||
|
"""当月预算执行率<70% → 预算余量机会"""
|
||||||
|
kpi = _kpi(db, "OPP_04", target=1000.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 300.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 1000.0)
|
||||||
|
db.commit()
|
||||||
|
out = detect_budget_headroom(db, 1)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["type"] == "budget_headroom"
|
||||||
|
|
||||||
|
def test_budget_headroom_negative_skip(self, db):
|
||||||
|
"""实际值为负(现金流异常)不误判为余量"""
|
||||||
|
kpi = _kpi(db, "OPP_05", target=1000.0)
|
||||||
|
_value(db, kpi.id, "2026-08", -500.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 1000.0)
|
||||||
|
db.commit()
|
||||||
|
assert detect_budget_headroom(db, 1) == []
|
||||||
|
|
||||||
|
def test_budget_headroom_dedup(self, db):
|
||||||
|
"""同KPI同期间多版本预算只取一条"""
|
||||||
|
kpi = _kpi(db, "OPP_06", target=1000.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 300.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 1000.0)
|
||||||
|
b2 = _budget(db, kpi.id, "2026-08", 2000.0)
|
||||||
|
b2.version = "v2.0"
|
||||||
|
db.commit()
|
||||||
|
assert len(detect_budget_headroom(db, 1)) == 1
|
||||||
|
|
||||||
|
def test_rolling_up(self, db):
|
||||||
|
"""预测值上升 → 滚动机会"""
|
||||||
|
kpi = _kpi(db, "OPP_07", target=100.0)
|
||||||
|
now = datetime.now()
|
||||||
|
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
|
||||||
|
period="2026-07", forecast_value=100.0, model="linear",
|
||||||
|
created_at=now))
|
||||||
|
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
|
||||||
|
period="2026-08", forecast_value=130.0, model="linear",
|
||||||
|
created_at=now))
|
||||||
|
db.commit()
|
||||||
|
out = detect_rolling_up(db, 1)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["type"] == "rolling_up"
|
||||||
|
|
||||||
|
def test_rolling_down_skip(self, db):
|
||||||
|
"""预测下降不判定为机会"""
|
||||||
|
kpi = _kpi(db, "OPP_08", target=100.0)
|
||||||
|
now = datetime.now()
|
||||||
|
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
|
||||||
|
period="2026-07", forecast_value=130.0, model="linear",
|
||||||
|
created_at=now))
|
||||||
|
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
|
||||||
|
period="2026-08", forecast_value=100.0, model="linear",
|
||||||
|
created_at=now))
|
||||||
|
db.commit()
|
||||||
|
assert detect_rolling_up(db, 1) == []
|
||||||
|
|
||||||
|
def test_flatten(self):
|
||||||
|
d = {"kpi_improving": [1], "budget_headroom": [2, 3], "rolling_up": []}
|
||||||
|
assert flatten(d) == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
class TestClosedLoopR5:
|
||||||
|
def test_overrun_missing_both(self, db):
|
||||||
|
"""超预算且缺现金流/行动 → 提示同步"""
|
||||||
|
kpi = _kpi(db, "CL_01", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 200.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 100.0)
|
||||||
|
db.commit()
|
||||||
|
r = check_entity(db, 1, "2026-08")
|
||||||
|
assert len(r["issues"]) == 1
|
||||||
|
it = r["issues"][0]
|
||||||
|
assert it["abnormal_type"] == "超预算"
|
||||||
|
assert "现金流" in it["missing"]
|
||||||
|
assert "行动方案" in it["missing"]
|
||||||
|
|
||||||
|
def test_overrun_has_cash_and_action(self, db):
|
||||||
|
"""超预算但有现金流+行动 → 三闭环同步"""
|
||||||
|
kpi = _kpi(db, "CL_02", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 200.0)
|
||||||
|
b = _budget(db, kpi.id, "2026-08", 100.0)
|
||||||
|
db.add(CashPlan(entity_id=1, plan_type="receive", related_kpi_id=kpi.id, budget_plan_id=b.id,
|
||||||
|
amount=200.0, plan_date=datetime(2026, 8, 15), status="pending"))
|
||||||
|
db.add(ActionPlan(kpi_id=kpi.id, title="改善计划", status="in_progress"))
|
||||||
|
db.commit()
|
||||||
|
r = check_entity(db, 1, "2026-08")
|
||||||
|
assert len(r["issues"]) == 1
|
||||||
|
assert r["issues"][0]["missing"] == []
|
||||||
|
|
||||||
|
def test_normal_no_issue(self, db):
|
||||||
|
"""执行率正常 → 无异常"""
|
||||||
|
kpi = _kpi(db, "CL_03", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 100.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 100.0)
|
||||||
|
db.commit()
|
||||||
|
r = check_entity(db, 1, "2026-08")
|
||||||
|
assert r["issues"] == []
|
||||||
|
|
||||||
|
def test_low_execution(self, db):
|
||||||
|
"""低执行率 → 异常(warning)"""
|
||||||
|
kpi = _kpi(db, "CL_04", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 50.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 100.0)
|
||||||
|
db.commit()
|
||||||
|
r = check_entity(db, 1, "2026-08")
|
||||||
|
assert len(r["issues"]) == 1
|
||||||
|
assert r["issues"][0]["abnormal_type"] == "低执行"
|
||||||
|
assert r["issues"][0]["level"] == "warning"
|
||||||
|
|
||||||
|
def test_build_report(self):
|
||||||
|
result = {"entity_id": 1, "period": "2026-08", "issues": [
|
||||||
|
{"kpi_id": 1, "kpi_name": "营收", "period": "2026-08", "budget_value": 100.0,
|
||||||
|
"actual_value": 200.0, "exec_ratio": 200.0, "abnormal_type": "超预算",
|
||||||
|
"level": "critical", "cash_plan_count": 0, "action_plan_count": 0,
|
||||||
|
"missing": ["现金流", "行动方案"], "suggestion": "请同步现金流、行动方案"}
|
||||||
|
]}
|
||||||
|
report = build_report([result], "2026-08-30 12:00:00")
|
||||||
|
assert "闭环自检" in report
|
||||||
|
assert "营收" in report
|
||||||
|
assert "共发现异常 1 项" in report
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""多租户隔离安全修复测试(2026-08-31 OpenCode 安全审查 P0)
|
||||||
|
|
||||||
|
覆盖 DoD 输出物8/9/10:
|
||||||
|
- bot_bridge 跨 entity 隔离(X-Entity-Id header 带不同账套返回不同数据)
|
||||||
|
- alert_rules create 写入 entity_id(跨 entity 不可见)
|
||||||
|
- reports 跨 entity 过滤(profit-summary / kpi-trends)
|
||||||
|
- _eval_threshold invert 参数(低于阈值触发红灯)
|
||||||
|
- /check-governance 在 SQLite 测试库不 500(data_quality 收敛后复用 Python 解析)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import KPIDefinition, KPIAlert, Entity, UserEntity
|
||||||
|
from app.api.alert_rules import AlertRule, _eval_threshold
|
||||||
|
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
|
||||||
|
|
||||||
|
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_entity(db: Session, eid: int, name: str = None) -> Entity:
|
||||||
|
"""确保测试库存在指定 entity(BOT 通道 get_entity_id 会校验 active)"""
|
||||||
|
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||||
|
if not ent:
|
||||||
|
ent = Entity(id=eid, name=name or f"企业{eid}", short_name=f"E{eid}", status="active")
|
||||||
|
db.add(ent)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ent)
|
||||||
|
return ent
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_entity_kpi(db: Session, entity_id: int, code: str, name: str = None) -> KPIDefinition:
|
||||||
|
"""创建指定账套的 KPI(多租户测试专用)"""
|
||||||
|
kpi = create_test_kpi(db, kpi_code=code, kpi_name=name or code, entity_id=entity_id,
|
||||||
|
target_value=100.0, unit="万元", frequency="monthly",
|
||||||
|
dimension="finance", status="active")
|
||||||
|
return kpi
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# bot_bridge 跨 entity 隔离(P0-1)
|
||||||
|
# ============================================================
|
||||||
|
class TestBotBridgeIsolation:
|
||||||
|
def test_kpis_entity_isolation(self, client: TestClient, db: Session):
|
||||||
|
"""BOT Key + X-Entity-Id=1 → 只返回 entity1 的 KPI;X-Entity-Id=2 → 只返回 entity2"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
_seed_entity_kpi(db, entity_id=1, code="E1_REVENUE")
|
||||||
|
_seed_entity_kpi(db, entity_id=2, code="E2_REVENUE")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r1 = client.get("/api/cma/bot/kpis", headers={**BOT_KEY, "X-Entity-Id": "1"})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
codes1 = {i["code"] for i in r1.json()["items"]}
|
||||||
|
assert "E1_REVENUE" in codes1
|
||||||
|
assert "E2_REVENUE" not in codes1
|
||||||
|
|
||||||
|
r2 = client.get("/api/cma/bot/kpis", headers={**BOT_KEY, "X-Entity-Id": "2"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
codes2 = {i["code"] for i in r2.json()["items"]}
|
||||||
|
assert "E2_REVENUE" in codes2
|
||||||
|
assert "E1_REVENUE" not in codes2
|
||||||
|
|
||||||
|
def test_overview_entity_isolation(self, client: TestClient, db: Session):
|
||||||
|
"""overview 统计按 entity 过滤:entity1 只统计自己的 KPI/预警"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
k1 = _seed_entity_kpi(db, entity_id=1, code="O1_KPI")
|
||||||
|
_seed_entity_kpi(db, entity_id=2, code="O2_KPI")
|
||||||
|
db.add(KPIAlert(kpi_id=k1.id, alert_level="red", alert_message="e1预警",
|
||||||
|
status="pending", entity_id=1))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r1 = client.get("/api/cma/bot/overview", headers={**BOT_KEY, "X-Entity-Id": "1"})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r1.json()["stats"]["kpis_total"] == 1
|
||||||
|
assert r1.json()["stats"]["alerts_open"] == 1
|
||||||
|
|
||||||
|
r2 = client.get("/api/cma/bot/overview", headers={**BOT_KEY, "X-Entity-Id": "2"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["stats"]["kpis_total"] == 1
|
||||||
|
assert r2.json()["stats"]["alerts_open"] == 0
|
||||||
|
|
||||||
|
def test_query_param_entity_isolation(self, client: TestClient, db: Session):
|
||||||
|
"""无 token 时 entity_id 也可通过 query 参数传入(Bot 通道)"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
_seed_entity_kpi(db, entity_id=1, code="Q1_KPI")
|
||||||
|
_seed_entity_kpi(db, entity_id=2, code="Q2_KPI")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = client.get("/api/cma/bot/kpis?entity_id=1", headers=BOT_KEY)
|
||||||
|
assert r.status_code == 200
|
||||||
|
codes = {i["code"] for i in r.json()["items"]}
|
||||||
|
assert "Q1_KPI" in codes
|
||||||
|
assert "Q2_KPI" not in codes
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# alert_rules 隔离(P0-2)
|
||||||
|
# ============================================================
|
||||||
|
class TestAlertRulesIsolation:
|
||||||
|
def test_create_alert_rule_writes_entity_id(self, client: TestClient, db: Session):
|
||||||
|
"""create 写入 entity_id:以 entity1 身份创建的规则,entity2 不可见"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
k1 = _seed_entity_kpi(db, entity_id=1, code="AR_E1")
|
||||||
|
db.commit()
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client, "testadmin", "admin123")
|
||||||
|
headers = auth_header(token)
|
||||||
|
|
||||||
|
resp = client.post("/api/cma/alert-rules", headers=headers, json={
|
||||||
|
"kpi_id": k1.id,
|
||||||
|
"rule_type": "static",
|
||||||
|
"params": {"operator": ">=", "threshold": 80.0},
|
||||||
|
"trigger_on": "actual",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# DB 回查:规则 entity_id = 1(用户 token 绑定 entity1)
|
||||||
|
rule = db.query(AlertRule).filter(AlertRule.kpi_id == k1.id).first()
|
||||||
|
assert rule is not None
|
||||||
|
assert rule.entity_id == 1
|
||||||
|
|
||||||
|
def test_get_kpi_rules_entity_scoped(self, client: TestClient, db: Session):
|
||||||
|
"""get_kpi_rules 按 entity 过滤:entity2 查不到 entity1 的规则"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
k1 = _seed_entity_kpi(db, entity_id=1, code="GR_E1")
|
||||||
|
db.add(AlertRule(kpi_id=k1.id, rule_type="static",
|
||||||
|
params={"operator": ">=", "threshold": 80.0},
|
||||||
|
entity_id=1))
|
||||||
|
db.commit()
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client, "testadmin", "admin123")
|
||||||
|
|
||||||
|
r1 = client.get(f"/api/cma/alert-rules/kpi/{k1.id}", headers=auth_header(token))
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert len(r1.json()["data"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# reports 跨 entity 过滤(P1-3)
|
||||||
|
# ============================================================
|
||||||
|
class TestReportsIsolation:
|
||||||
|
def test_profit_summary_entity_filtered(self, client: TestClient, db: Session):
|
||||||
|
"""profit-summary 按 entity 过滤:entity2 的 KPI 值对 entity1 不可见"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
from app.models import KPIValue
|
||||||
|
k1 = _seed_entity_kpi(db, entity_id=1, code="F_REVENUE")
|
||||||
|
_seed_entity_kpi(db, entity_id=2, code="F_REVENUE_2")
|
||||||
|
db.add(KPIValue(kpi_id=k1.id, period="2026-06", actual_value=888.0,
|
||||||
|
data_status="verified", entity_id=1))
|
||||||
|
db.commit()
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client, "testadmin", "admin123")
|
||||||
|
|
||||||
|
r1 = client.get("/api/cma/reports/profit-summary?period=2026-06", headers=auth_header(token))
|
||||||
|
assert r1.status_code == 200
|
||||||
|
# entity1 的 token → 读到 entity1 的收入项(F_REVENUE 命中,含888值)
|
||||||
|
items = r1.json()["items"]
|
||||||
|
assert len(items) >= 1
|
||||||
|
|
||||||
|
def test_kpi_trends_entity_filtered(self, client: TestClient, db: Session):
|
||||||
|
"""kpi-trends 按 entity 过滤:entity2 看不到 entity1 的 KPI 列表"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
_seed_entity_kpi(db, entity_id=1, code="TR_E1")
|
||||||
|
_seed_entity_kpi(db, entity_id=2, code="TR_E2")
|
||||||
|
db.commit()
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client, "testadmin", "admin123")
|
||||||
|
|
||||||
|
r1 = client.get("/api/cma/reports/kpi-trends", headers=auth_header(token))
|
||||||
|
assert r1.status_code == 200
|
||||||
|
kpis1 = r1.json().get("data", [])
|
||||||
|
codes1 = {k["kpi_code"] for k in kpis1}
|
||||||
|
assert "TR_E1" in codes1
|
||||||
|
assert "TR_E2" not in codes1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# _eval_threshold invert 参数(P1-1)
|
||||||
|
# ============================================================
|
||||||
|
class TestInvertThreshold:
|
||||||
|
def test_invert_lower_threshold_triggers_red(self):
|
||||||
|
"""invert=True:值低于阈值时取反(低于下限触发红灯场景)"""
|
||||||
|
assert _eval_threshold(500, "<600", invert=True) is False # 500 < 600,原命中 → invert 后不命中
|
||||||
|
assert _eval_threshold(700, "<600", invert=True) is True # 700 >= 600,原不命中 → invert 后命中
|
||||||
|
|
||||||
|
def test_invert_greater_threshold(self):
|
||||||
|
"""invert=True:值高于阈值时取反"""
|
||||||
|
assert _eval_threshold(30, ">25", invert=True) is False # 30 > 25,原命中 → invert 后不命中
|
||||||
|
assert _eval_threshold(10, ">25", invert=True) is True # 10 <= 25,原不命中 → invert 后命中
|
||||||
|
|
||||||
|
def test_invert_gt_eq_and_lt_eq(self):
|
||||||
|
""">= 与 <= 的 invert 取反"""
|
||||||
|
assert _eval_threshold(80, ">=90", invert=True) is True # 80 < 90 → invert 命中
|
||||||
|
assert _eval_threshold(95, ">=90", invert=True) is False
|
||||||
|
assert _eval_threshold(95, "<=90", invert=True) is True # 95 > 90 → invert 命中
|
||||||
|
assert _eval_threshold(85, "<=90", invert=True) is False
|
||||||
|
|
||||||
|
def test_red_branch_no_invert_behavior_preserved(self):
|
||||||
|
"""_check_static red 分支不传 invert:字面阈值行为不变(回归保护)"""
|
||||||
|
assert _eval_threshold(500, "<600") is True # 低于600 → 命中(默认字面)
|
||||||
|
assert _eval_threshold(700, "<600") is False
|
||||||
|
assert _eval_threshold(30, ">25") is True
|
||||||
|
assert _eval_threshold(10, ">25") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# /check-governance SQLite 兼容(P1-2)
|
||||||
|
# ============================================================
|
||||||
|
class TestCheckGovernanceSQLite:
|
||||||
|
def test_check_governance_no_500_on_sqlite(self, client: TestClient, db: Session):
|
||||||
|
"""data_quality 收敛后 /check-governance 在 SQLite 测试库不 500"""
|
||||||
|
_seed_entity_kpi(db, entity_id=1, code="GOV_KPI")
|
||||||
|
db.commit()
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client, "testadmin", "admin123")
|
||||||
|
|
||||||
|
resp = client.get("/api/cma/data-quality/check-governance", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200, f"check-governance 500: {resp.text[:300]}"
|
||||||
|
data = resp.json()
|
||||||
|
assert "score" in data
|
||||||
|
assert data["total_rules"] == 7
|
||||||
|
|
||||||
|
def test_governance_check_still_works(self, client: TestClient, db: Session):
|
||||||
|
"""governance-check 端点(新口径)在收敛后仍正常"""
|
||||||
|
_seed_entity_kpi(db, entity_id=1, code="GOV2_KPI")
|
||||||
|
db.commit()
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client, "testadmin", "admin123")
|
||||||
|
|
||||||
|
resp = client.get("/api/cma/data-quality/governance-check", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200, resp.text[:300]
|
||||||
|
assert resp.json()["total_rules"] == 7
|
||||||
@@ -7,26 +7,38 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
echo "===== 管理会计OS 生产部署 ====="
|
echo "===== 管理会计OS 生产部署 ====="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 0. 拉取最新代码
|
# 0. 提交纪律检查:本地有未提交修改则中止,防止 git pull 覆盖丢失
|
||||||
echo "[0/4] 拉取最新代码..."
|
echo "[0/5] 提交纪律检查..."
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
DIRTY=$(git status --porcelain 2>/dev/null | grep -v "__pycache__" | head -20)
|
||||||
|
if [ -n "$DIRTY" ]; then
|
||||||
|
echo "❌ 检测到本地未提交的修改,中止部署(防止 git pull 覆盖丢失代码):"
|
||||||
|
echo "$DIRTY"
|
||||||
|
echo ""
|
||||||
|
echo "请先提交代码: git add -A && git commit -m '...' && git push origin main"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ 工作区干净,无未提交修改"
|
||||||
|
|
||||||
|
# 1. 拉取最新代码
|
||||||
|
echo "[1/5] 拉取最新代码..."
|
||||||
git pull origin main
|
git pull origin main
|
||||||
echo " ✓ 代码已更新"
|
echo " ✓ 代码已更新"
|
||||||
|
|
||||||
# 1. 构建前端
|
# 2. 构建前端
|
||||||
echo "[1/4] 构建前端..."
|
echo "[2/5] 构建前端..."
|
||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$SCRIPT_DIR/frontend"
|
||||||
pnpm build
|
pnpm build
|
||||||
echo " ✓ 构建完成"
|
echo " ✓ 构建完成"
|
||||||
|
|
||||||
# 2. 部署前端到 Nginx
|
# 3. 部署前端到 Nginx
|
||||||
echo "[2/4] 部署前端..."
|
echo "[3/5] 部署前端..."
|
||||||
rm -rf /var/www/cma/assets/
|
rm -rf /var/www/cma/assets/
|
||||||
cp -r dist/* /var/www/cma/
|
cp -r dist/* /var/www/cma/
|
||||||
echo " ✓ 部署完成"
|
echo " ✓ 部署完成"
|
||||||
|
|
||||||
# 3. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署)
|
# 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署)
|
||||||
echo "[3/4] 更新后端依赖..."
|
echo "[4/5] 更新后端依赖..."
|
||||||
cd "$SCRIPT_DIR/backend"
|
cd "$SCRIPT_DIR/backend"
|
||||||
if [ -f venv/bin/activate ]; then
|
if [ -f venv/bin/activate ]; then
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
@@ -37,13 +49,56 @@ else
|
|||||||
fi
|
fi
|
||||||
echo " ✓ 依赖检查完成"
|
echo " ✓ 依赖检查完成"
|
||||||
|
|
||||||
# 4. 重启后端服务
|
# 5. 重启后端服务
|
||||||
echo "[4/4] 重启后端服务..."
|
echo "[5/5] 重启后端服务..."
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl restart cma-backend
|
systemctl restart cma-backend
|
||||||
systemctl reload nginx 2>/dev/null || nginx -s reload
|
systemctl reload nginx 2>/dev/null || nginx -s reload
|
||||||
echo " ✓ 服务重启完成"
|
echo " ✓ 服务重启完成"
|
||||||
|
|
||||||
|
# ── 部署后冒烟测试 ──
|
||||||
|
echo ""
|
||||||
|
echo "===== 冒烟测试 ====="
|
||||||
|
sleep 3
|
||||||
|
SMOKE_FAIL=0
|
||||||
|
if curl -sf http://127.0.0.1:8010/health > /dev/null 2>&1; then
|
||||||
|
echo " ✅ 后端健康检查通过"
|
||||||
|
else
|
||||||
|
echo " ❌ 后端健康检查失败"; SMOKE_FAIL=1
|
||||||
|
fi
|
||||||
|
if curl -sf http://127.0.0.1:8010/api/cma/bot/ping > /dev/null 2>&1; then
|
||||||
|
echo " ✅ BOT桥接API通过"
|
||||||
|
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
|
||||||
|
echo " ✅ Schema一致性通过"
|
||||||
|
else
|
||||||
|
echo " ❌ Schema不一致,请检查: cd /root/cma-management/backend && venv/bin/python3 scripts/schema_check.py"
|
||||||
|
SMOKE_FAIL=1
|
||||||
|
fi
|
||||||
|
TOKEN=$(curl -s -X POST http://127.0.0.1:8010/api/cma/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin123","entity_id":1}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||||
|
if [ -n "$TOKEN" ]; then
|
||||||
|
echo " ✅ 登录API通过"
|
||||||
|
if curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8010/api/cma/kpis > /dev/null 2>&1; then
|
||||||
|
echo " ✅ KPI接口通过"
|
||||||
|
else
|
||||||
|
echo " ❌ KPI接口失败"; SMOKE_FAIL=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ❌ 登录API失败"; SMOKE_FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$SMOKE_FAIL" -eq 1 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "🚨 冒烟测试未全部通过!请检查后端日志: journalctl -u cma-backend -n 50"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✅ 全部冒烟测试通过"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "===== 部署完成 ====="
|
echo "===== 部署完成 ====="
|
||||||
echo " 前端: https://cma.sxbh.ltd"
|
echo " 前端: https://cma.sxbh.ltd"
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
# CMA API验证分工表(2026-08-31)
|
||||||
|
|
||||||
|
> 生成:项目Bot | 数据源:后端 /openapi.json 实测 | 统计口径:334路径/404方法操作(openapi实测,非任务书398估算)
|
||||||
|
|
||||||
|
## 分类标准
|
||||||
|
|
||||||
|
| 分类 | 标准 | 谁验证 | 数量(操作) |
|
||||||
|
|:--|:--|:--|:--|
|
||||||
|
| L3必测 | 北极星四层决策点相关、影响拍板 | 任总抽验 | 60 |
|
||||||
|
| L2 Bot验证 | 业务流程闭环 | 项目Bot/QA Bot | 169 |
|
||||||
|
| L1 自动验证 | 数据完整性/计算正确性/接口可用 | 脚本/cron | 175 |
|
||||||
|
|
||||||
|
## 四层路径分布
|
||||||
|
|
||||||
|
- ①数据接入: 184 个操作
|
||||||
|
- ②多Bot互动: 43 个操作
|
||||||
|
- ③数据找人: 82 个操作
|
||||||
|
- ④决策闭环: 95 个操作
|
||||||
|
|
||||||
|
## 完整清单(404操作)
|
||||||
|
|
||||||
|
| 方法 | API路径 | 所属层 | 验证人 | 当前状态 |
|
||||||
|
|:--|:--|:--|:--|:--|
|
||||||
|
| GET | `/api/cma/action-plans` | ④决策闭环 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/action-plans` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/action-plans/coso-checklist` | ④决策闭环 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/action-plans/stats` | ④决策闭环 | 任总抽验 | ✅ L1自动 |
|
||||||
|
| DELETE | `/api/cma/action-plans/{plan_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/action-plans/{plan_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/admin/alerts/check` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| POST | `/api/cma/admin/cache/clear` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/admin/erp-sync` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/admin/erp-sync/dry-run` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/ai/ask` | ③数据找人 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/ai/dashboard-analysis` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/ai/dashboard-analysis-stream` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/ai/kpi-analysis/{kpi_id}` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/ai/review-plans` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/ai/suggestions` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/ai/suggestions` | ③数据找人 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/ai/suggestions/{suggestion_id}` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/ai/suggestions/{suggestion_id}/apply` | ③数据找人 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/ai/suggestions/{suggestion_id}/dismiss` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/alert-rules` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/alert-rules` | ③数据找人 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/alert-rules/batch` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/alert-rules/calculate-dynamic` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/alert-rules/check-all` | ③数据找人 | 任总抽验 | ✅ L1自动 |
|
||||||
|
| POST | `/api/cma/alert-rules/check-forecast` | ③数据找人 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/alert-rules/dynamic-thresholds` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/alert-rules/generate-defaults` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/alert-rules/generate-suggestions` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/alert-rules/kpi/{kpi_id}` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/alert-rules/run-forecast-deviation` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/alert-rules/{rule_id}` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/alert-rules/{rule_id}` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/alerts` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/alerts/check-timeout` | ③数据找人 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/alerts/risk-matrix` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/alerts/{alert_id}/create-action-plan` | ③数据找人 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/alerts/{alert_id}/escalate` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/alerts/{alert_id}/process` | ③数据找人 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/alerts/{alert_id}/resolve` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/alignment/config` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/alignment/config` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/alignment/modes` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/alignment/tree` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/analysis/auto-calculate` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/analysis/result` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/analysis/result` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/analysis/result/{result_id}` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/auth/entities` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/auth/login` | ①数据接入 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/auth/login-entities` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/auth/me` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/auth/my-entities` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/auth/register` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/auth/roles` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/auth/switch-entity` | ①数据接入 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/bi-reports` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/bi-reports` | ③数据找人 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/bi-reports/analyze` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/bi-reports/export` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/bi-reports/templates` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/bi-reports/templates/seed` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/bi-reports/templates/{template_id}` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/bi-reports/{report_id}` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/bi-reports/{report_id}` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/bi-reports/{report_id}` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/bot-bridge/kpi-result` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/bot-bridge/mpm-result` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/bot-bridge/verify/{action_plan_id}` | ②多Bot互动 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/bot-bridge/verify/{action_plan_id}/history` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot-kpis` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/bot-kpis/{kpi_id}/value` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/bot/actions` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/alerts` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/budget/plans` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/cost/actual` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/cost/standard` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/data-sources` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/bot/import` | ②多Bot互动 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/bot/iron-law` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/iron-law/bots` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/bot/kpi-value-with-check` | ②多Bot互动 | 任总抽验 | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/bot/kpis` | ②多Bot互动 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/bot/kpis/create-with-links` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/bot/kpis/{kpi_id}/history` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/nlp` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/bot/okr/create` | ②多Bot互动 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/bot/okr/list` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/organization` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/overview` | ②多Bot互动 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/ping` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/query` | ②多Bot互动 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/risk-levels` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/strategic-maps` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bot/users` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/bsc-layers` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/budget/alert-direction` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/budget/alert-direction` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/apply-method` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/auto-decompose` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/cash-classify-rules` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/cash-classify-rules` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/budget/cash-classify-rules/{rule_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/budget/cash-classify-rules/{rule_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/cash-unclassified` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/cash-unclassified/{item_id}/classify` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/cash-unclassified/{item_id}/ignore` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/comparison` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/budget/comparison/kpi/{kpi_id}` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/budget/config` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/config` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/derivation-rules` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/derivation-rules` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/budget/derivation-rules/{rule_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/budget/derivation-rules/{rule_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/deviation-alerts` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/budget/deviation-alerts/{alert_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/deviation-alerts/{alert_id}/attribution` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/deviation-check` | ④决策闭环 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/budget/deviation-report` | ④决策闭环 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/driver/calculate` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/driver/history` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/budget/driver/industries` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/budget/driver/mode` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/driver/mode` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/driver/sensitivity` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/driver/templates` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/generate-from-kpis` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/kpi-budget-candidates` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/method-comparison` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/plans` | ④决策闭环 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/plans` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/budget/plans/{plan_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/budget/plans/{plan_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/roll-forward` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/sync-cash-plans` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/value-collect/logs` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/value-collect/run` | ①数据接入 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/value-sources` | ①数据接入 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/value-sources` | ①数据接入 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/value-sources/coverage` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/value-sources/test` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/budget/value-sources/{source_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/budget/value-sources/{source_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/versions` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/versions/approve` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/versions/diff` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/versions/submit` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/budget/zero-based/generate` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/budget/zero-based/items` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/budget/zero-based/items` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/budget/zero-based/items/{item_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/budget/zero-based/items/{item_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/cash/alerts/status` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/cash/balance` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/cash/balance` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/cash/check-alerts` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/cash/dashboard` | ①数据接入 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/cash/gap-forecast` | ①数据接入 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/cash/import/bohai-ar` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/cash/import/template` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/cash/import/vouchers` | ①数据接入 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/cash/plans` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/cash/plans` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/cash/plans/{plan_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/cash/plans/{plan_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/cash/plans/{plan_id}/complete` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/cash/receivables` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/cash/receivables/{plan_id}/payment` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/cash/upcoming` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/cost/abc/activities` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/cost/abc/activities` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/cost/abc/allocate` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/cost/abc/allocations` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/cost/actual-costs` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/cost/actual-costs` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/cost/breakdown` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/cost/comparison` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/cost/dashboard` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/cost/overview` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/cost/standard-costs` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/cost/standard-costs` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/cost/standard-costs/{cost_id}` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/cost/standard-costs/{cost_id}` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/cost/variance` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/customer-dashboard` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/customer-dashboard/summary` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/customer-dashboard/trend/{kpi_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/dashboard/finance-analysis` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/dashboard/kpis` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/dashboard/my-dashboard` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/dashboard/my-kpis` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/dashboard/predict` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/dashboard/summary` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/data-classification/batch` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/data-classification/export` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/data-classification/industry-reference` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/data-classification/inventory` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/data-classification/kpis/{kpi_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/data-classification/stats` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
||||||
|
| PUT | `/api/cma/data-classification/subjects/{subject_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/data-quality/check` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/data-quality/check-governance` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/data-quality/governance-check` | ①数据接入 | 任总抽验 | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/data-quality/logs` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| DELETE | `/api/cma/data-quality/logs/{log_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/data-quality/logs/{log_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/data-quality/stats` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| POST | `/api/cma/data/import-excel` | ①数据接入 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/data/import-excel-smart` | ①数据接入 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/data/sources` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/data/sources` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/data/sources/{source_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/data/sources/{source_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/data/sync-kpis` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/deviation-push/map-nodes/{map_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/deviation-push/push-to-map` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/entities` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/entities` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/entities/{entity_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/expenses/reimbursements` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/expenses/reimbursements` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/expenses/reimbursements/{reimb_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/expenses/reimbursements/{reimb_id}/approve` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/expenses/reimbursements/{reimb_id}/reject` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/expenses/reimbursements/{reimb_id}/resubmit` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/expenses/reimbursements/{reimb_id}/return` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/expenses/rules` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/expenses/rules` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/expenses/rules/seed` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/expenses/rules/{rule_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/expenses/rules/{rule_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/expenses/stats` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/growth-quality/diagnosis` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/growth-quality/periods` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/knowledge-articles` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/knowledge-articles/{article_id}` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/knowledge/ethics-quiz` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/knowledge/events` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/knowledge/generate/daily` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/knowledge/generate/monthly` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/knowledge/generate/weekly` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/knowledge/summaries` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/knowledge/summaries/latest` | ②多Bot互动 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/knowledge/summaries/{summary_id}` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/kpi-causality` | ④决策闭环 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/kpi-causality` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpi-causality/full-network` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/kpi-causality/kpi/{kpi_id}/network` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/kpi-causality/simulate` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpi-causality/verify-status` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| DELETE | `/api/cma/kpi-causality/{causality_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpi-causality/{causality_id}` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/kpi-causality/{causality_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/kpi-causality/{causality_id}/verify` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpi/governance/audit` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/kpi/validate` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpis` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/kpis` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpis/categories` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/kpis/glossary` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/kpis/hierarchy` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/kpis/score` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| DELETE | `/api/cma/kpis/{kpi_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpis/{kpi_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/kpis/{kpi_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/kpis/{kpi_id}/associate-map` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpis/{kpi_id}/causality-chain` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/kpis/{kpi_id}/objectives` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/kpis/{kpi_id}/restore` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/kpis/{kpi_id}/values` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/kpis/{kpi_id}/values` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/lead` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/maps` | ④决策闭环 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/maps` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/maps/batch-delete` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/maps/create-with-template` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/maps/{map_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/maps/{map_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/maps/{map_id}/connections` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/maps/{map_id}/connections` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/maps/{map_id}/krs` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/maps/{map_id}/objectives` | ④决策闭环 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/maps/{map_id}/objectives` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/maps/{map_id}/objectives/sort` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/maps/{map_id}/objectives/{obj_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/maps/{map_id}/objectives/{obj_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/maps/{map_id}/review` | ④决策闭环 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/maps/{map_id}/review-records` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/maps/{map_id}/review-records` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/maps/{map_id}/review-records/{record_id}` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/maps/{map_id}/versions` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/maps/{map_id}/versions/snapshot` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/maps/{map_id}/versions/{ver_id}/rollback` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/notifications/channels` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/notifications/channels` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/notifications/channels/{ch_id}` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/notifications/channels/{ch_id}` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/notifications/channels/{ch_id}/test` | ②多Bot互动 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/notifications/logs` | ②多Bot互动 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/okr` | ④决策闭环 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/okr` | ④决策闭环 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/okr-templates` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/okr-templates` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/okr-templates/{template_id}` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/okr-templates/{template_id}/apply` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/okr-templates/{template_id}/use` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/okr/{obj_id}` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PATCH | `/api/cma/okr/{obj_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/okr/{objective_id}/krs` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/okr/{objective_id}/krs/sync` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/okr/{objective_id}/krs/{kr_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/okr/{objective_id}/krs/{kr_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/okr/{okr_id}/decomposition` | ④决策闭环 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/okr/{okr_id}/decomposition/milestones/generate` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/okr/{okr_id}/decomposition/milestones/{kr_id}` | ④决策闭环 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/ontology/objectives` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/ontology/trace` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/org/nodes` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/org/nodes` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/org/nodes/{node_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/org/nodes/{node_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/org/nodes/{node_id}/toggle` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/org/tree` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/permissions/config` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/permissions/config` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/permissions/modules` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/predict/accuracy` | ③数据找人 | 任总抽验 | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/predict/cash-forecast` | ③数据找人 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/predict/cash-forecast/history` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/predict/cvp` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/predict/cvp-detailed` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/predict/growth-quality` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/predict/investment` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/predict/kpi-forecast` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/predict/kpi-forecast/finance` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/predict/kpi-forecast/sensitivity` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/predict/real-option` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/predict/relevant-decision` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/predict/scenario` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/predict/scenario-suggestion/generate` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/predict/scenario-suggestions` | ③数据找人 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/predict/sensitivity` | ③数据找人 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/products/matrix` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/balance-sheet` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/bsc-scorecard` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/budget-execution` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/cash-flow` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/category-map` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/dupont` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/reports/generate` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/reports/history` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/history/{report_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/kpi-trends` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/reports/mpm-calculate` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/reports/profit-statement` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/profit-summary` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/proforma/balance-sheet` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/proforma/cash-flow` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/proforma/profit-statement` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/restatement` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/statutory` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/reports/statutory/export` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/security/captcha/request` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/security/captcha/request2` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/security/captcha/verify` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/subjects` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/subjects/batch/category` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/subjects/{subject_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/tax/burden` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/tax/check` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| GET | `/api/cma/tax/dashboard` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/tax/demo-data` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/tax/invoices` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/tax/invoices` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/tax/invoices/abnormal` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/tax/invoices/check` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| DELETE | `/api/cma/tax/invoices/{invoice_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/tax/invoices/{invoice_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/tax/records` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/tax/records` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/tax/records/{record_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/tax/records/{record_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/tax/ss` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/tax/ss` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/tax/ss/abnormal` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/tax/ss/check` | ①数据接入 | 脚本/cron | ✅ L1自动 |
|
||||||
|
| DELETE | `/api/cma/tax/ss/{ss_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/tax/ss/{ss_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/templates` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/templates` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/templates/{template_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/templates/{template_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| PUT | `/api/cma/templates/{template_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/templates/{template_id}/instantiate` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/tenant/current` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/tenant/switch` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/api/cma/thresholds/suggest/{kpi_id}` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| GET | `/api/cma/users` | ①数据接入 | 脚本/cron | 🟡 读接口待抽验 |
|
||||||
|
| POST | `/api/cma/users` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| DELETE | `/api/cma/users/{user_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| PUT | `/api/cma/users/{user_id}` | ①数据接入 | 项目Bot/QA | 🔵 写接口Bot闭环 |
|
||||||
|
| POST | `/api/cma/verify/{plan_id}` | ①数据接入 | 任总抽验 | 🔵 写接口Bot闭环 |
|
||||||
|
| GET | `/health` | ①数据接入 | 脚本/cron | ✅ 已验证 |
|
||||||
|
|
||||||
|
## L3必测清单(任总亲测,50路径/60操作)
|
||||||
|
|
||||||
|
| # | 方法 | API路径 | 归属 |
|
||||||
|
|:--|:--|:--|:--|
|
||||||
|
| 1 | GET | `/api/cma/action-plans` | ④闭环-行动方案 |
|
||||||
|
| 2 | GET | `/api/cma/action-plans/stats` | ④闭环-行动统计 |
|
||||||
|
| 3 | POST | `/api/cma/ai/ask` | ③找人-AI问答 |
|
||||||
|
| 4 | GET | `/api/cma/ai/suggestions` | ③找人-AI建议 |
|
||||||
|
| 5 | POST | `/api/cma/ai/suggestions/{suggestion_id}/apply` | ③找人-建议落地 |
|
||||||
|
| 6 | GET | `/api/cma/alert-rules` | ③找人-预警规则 |
|
||||||
|
| 7 | POST | `/api/cma/alert-rules/check-all` | ③找人-全量预警检查 |
|
||||||
|
| 8 | GET | `/api/cma/alerts` | ③找人-预警列表 |
|
||||||
|
| 9 | POST | `/api/cma/alerts/{alert_id}/create-action-plan` | ③找人-预警转行动 |
|
||||||
|
| 10 | POST | `/api/cma/alerts/{alert_id}/process` | ③找人-预警处理 |
|
||||||
|
| 11 | POST | `/api/cma/auth/login` | ①数据接入-登录(账套) |
|
||||||
|
| 12 | POST | `/api/cma/auth/switch-entity` | ①数据接入-切换企业 |
|
||||||
|
| 13 | GET | `/api/cma/bi-reports` | ③找人-BI报表 |
|
||||||
|
| 14 | POST | `/api/cma/bot-bridge/verify/{action_plan_id}` | ②多Bot-验证桥 |
|
||||||
|
| 15 | POST | `/api/cma/bot/import` | ②多Bot-Bot导入 |
|
||||||
|
| 16 | POST | `/api/cma/bot/kpi-value-with-check` | ②多Bot-KPI带校验写入 |
|
||||||
|
| 17 | GET | `/api/cma/bot/kpis` | ②多Bot-KPI读写 |
|
||||||
|
| 18 | POST | `/api/cma/bot/okr/create` | ②多Bot-OKR创建 |
|
||||||
|
| 19 | GET | `/api/cma/bot/overview` | ②多Bot-总览 |
|
||||||
|
| 20 | GET | `/api/cma/bot/query` | ②多Bot-查询 |
|
||||||
|
| 21 | POST | `/api/cma/budget/auto-decompose` | ④闭环-预算自动分解 |
|
||||||
|
| 22 | GET | `/api/cma/budget/deviation-report` | ④闭环-预算偏差报告 |
|
||||||
|
| 23 | GET | `/api/cma/budget/plans` | ④闭环-预算 |
|
||||||
|
| 24 | POST | `/api/cma/budget/roll-forward` | ④闭环-滚动预算 |
|
||||||
|
| 25 | POST | `/api/cma/budget/value-collect/run` | ①数据接入-实际值归集 |
|
||||||
|
| 26 | GET | `/api/cma/budget/value-sources` | ①数据接入-数据源管理 |
|
||||||
|
| 27 | POST | `/api/cma/budget/versions/approve` | ④闭环-预算版本审批 |
|
||||||
|
| 28 | GET | `/api/cma/cash/dashboard` | ③找人-资金驾驶舱 |
|
||||||
|
| 29 | GET | `/api/cma/cash/gap-forecast` | ③找人-资金缺口 |
|
||||||
|
| 30 | POST | `/api/cma/cash/import/vouchers` | ①数据接入-凭证导入 |
|
||||||
|
| 31 | GET | `/api/cma/cost/dashboard` | ③找人-成本驾驶舱 |
|
||||||
|
| 32 | GET | `/api/cma/cost/variance` | ③找人-成本差异 |
|
||||||
|
| 33 | GET | `/api/cma/dashboard/kpis` | ③找人-KPI看板 |
|
||||||
|
| 34 | GET | `/api/cma/dashboard/my-dashboard` | ③找人-工作台 |
|
||||||
|
| 35 | GET | `/api/cma/dashboard/summary` | ③找人-总览 |
|
||||||
|
| 36 | GET | `/api/cma/data-classification/stats` | ①数据接入-分级统计 |
|
||||||
|
| 37 | GET | `/api/cma/data-quality/check` | ①数据接入-质量检查 |
|
||||||
|
| 38 | GET | `/api/cma/data-quality/governance-check` | ①数据接入-治理检查 |
|
||||||
|
| 39 | POST | `/api/cma/data/import-excel` | ①数据接入-Excel导入 |
|
||||||
|
| 40 | POST | `/api/cma/data/import-excel-smart` | ①数据接入-智能导入 |
|
||||||
|
| 41 | GET | `/api/cma/knowledge/summaries/latest` | ③找人-知识摘要 |
|
||||||
|
| 42 | GET | `/api/cma/kpi-causality` | ④闭环-KPI因果 |
|
||||||
|
| 43 | POST | `/api/cma/kpi-causality/simulate` | ④闭环-因果模拟 |
|
||||||
|
| 44 | GET | `/api/cma/maps` | ④闭环-战略地图 |
|
||||||
|
| 45 | GET | `/api/cma/maps/{map_id}/objectives` | ④闭环-地图目标 |
|
||||||
|
| 46 | GET | `/api/cma/maps/{map_id}/review` | ④闭环-地图评审 |
|
||||||
|
| 47 | GET | `/api/cma/okr` | ④闭环-OKR |
|
||||||
|
| 48 | GET | `/api/cma/predict/accuracy` | ③找人-预测准确率 |
|
||||||
|
| 49 | POST | `/api/cma/predict/cash-forecast` | ③找人-现金流预测 |
|
||||||
|
| 50 | POST | `/api/cma/verify/{plan_id}` | ④闭环-自动验证 |
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 预算流程串联验证报告(2026-08-27)
|
||||||
|
|
||||||
|
> 任务:yanxue-budget-flow-verify(P2,验证+报告,不加功能)
|
||||||
|
> 数据源:budget_plans 134行 / kpi_values / budget_deviation_alerts 9条 / cash_plans 20条 / action_plans(实测走查)
|
||||||
|
|
||||||
|
## 一、完整流程图(数据流向)
|
||||||
|
|
||||||
|
```
|
||||||
|
战略地图32(陕西酣客, 5目标9KPI)
|
||||||
|
│ ① 战略预算编制(tab: 选KPI编制)
|
||||||
|
▼
|
||||||
|
预算录入 budget_plans (134行, 10个KPI, 5版本: v1.0/v2.0/incremental/zero_based/flexible)
|
||||||
|
│ ② 年度分解(预算年度/月度字段)
|
||||||
|
▼
|
||||||
|
版本审批 (status=active)
|
||||||
|
│ ③ 预算执行(tab: 预算 vs 实际 kpi_values, execution_rate)
|
||||||
|
▼
|
||||||
|
差异分析 budget_deviation_alerts (9条: 偏差率/建议)
|
||||||
|
│ ④ 偏差告警(红黄绿)
|
||||||
|
▼
|
||||||
|
滚动调整 (incremental/zero_based/flexible 版本 = 滚动痕迹)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、各环节贯通状态(实测)
|
||||||
|
|
||||||
|
| 环节 | 上游驱动 | 状态 | 证据 |
|
||||||
|
|:--|:--|:--:|:--|
|
||||||
|
| 战略→预算 | 地图32 的9KPI → 预算覆盖 | 🟡 半通 | 核心财务KPI(营收/净利/费用率/毛利 各21行)全覆盖;非财务目标(进销存/数字赋能)预算薄弱 |
|
||||||
|
| 预算→执行 | budget_plans → kpi_values 实际 | ✅ 通 | 10个预算KPI中 **8个有实际值**(营收10/净利9/费用率8/毛利8/渠补9/厂补12) |
|
||||||
|
| 执行→差异 | kpi_values → deviation_alerts | ✅ 通 | 9条偏差告警(deviation_rate/建议) |
|
||||||
|
| 差异→滚动 | 版本管理 | ✅ 通 | v2.0(24行)+incremental+zero_based+flexible 版本并存 |
|
||||||
|
| 预算↔现金流 | budget_plans ↔ cash_plans | 🔴 **断** | 无关联字段(budget_plans 无 cash引用;cash_plans 无 budget/kpi_id引用,仅 description/source 间接) |
|
||||||
|
| 预算↔行动/KR | budget_plans ↔ action_plans | 🟡 半通 | 通过 kpi_id 间接关联;**5/10 预算KPI无行动方案**(净利/新客/厂补/供应链/数据自动化) |
|
||||||
|
|
||||||
|
## 三、断点清单
|
||||||
|
|
||||||
|
| # | 断点 | 位置 | 问题 | 修复建议(另行排期) |
|
||||||
|
|---|------|------|------|---------------------|
|
||||||
|
| 1 | **预算↔现金流断** | budget_plans / cash_plans | 利润表预算与现金流量计划无结构化关联(预算收入→应收→现金流 receive 链路未建) | cash_plans 加 related_kpi_id/budget_plan_id;预算执行时按应收应付生成现金流计划(建议A/B:kpi-value-with-check 同模式) |
|
||||||
|
| 2 | **预算KPI无行动抓手** | action_plans | 净利/新客/厂补率/供应链/数据自动化 5个预算KPI 无行动方案=预算无执行抓手 | 战略回顾会核对时为这些KPI补行动方案(KR联动) |
|
||||||
|
| 3 | **悬空预算** | budget_plans | P_SUPPLY_CYCLE / L_DATA_AUTO_RATE 各1行预算但无实际值无行动(无来源支撑) | 清理或补实际数据源 |
|
||||||
|
| 4 | **非财务目标预算薄弱** | 地图32 | 进销存流程优化/数字系统赋能目标 的KPI 几乎无预算 | 战略预算编制时引导覆盖非财务维度 |
|
||||||
|
|
||||||
|
## 四、结论:流程闭环度 ≈ 80%
|
||||||
|
|
||||||
|
```
|
||||||
|
主链路(战略→预算→执行→差异→滚动) 全通 = 85%
|
||||||
|
断点扣分: 预算↔现金流断(-10%) + 预算↔行动半通(-5%) = 80%
|
||||||
|
```
|
||||||
|
|
||||||
|
**判断**:预算功能**不是独立堆积**——主链路(战略→预算→执行→差异→滚动)数据贯通,版本管理完整(滚动闭环真实存在)。核心断点在**跨模块联动**(预算↔现金流、预算↔行动),属"模块内闭环、跨模块待接"状态。与现金流/行动/KR 的联动是下阶段重点(符合克制原则:本次只报告不修)。
|
||||||
|
|
||||||
|
## 五、验证记录(铁律七)
|
||||||
|
|
||||||
|
- [x] 预算数据走查(134行/10KPI/5版本/实体1)
|
||||||
|
- [x] 地图32 KPI→预算→实际→偏差 逐环节核对
|
||||||
|
- [x] 现金流关联字段检查(断,证据:无关联字段)
|
||||||
|
- [x] 行动/KR关联检查(半通,证据:5个KPI无行动)
|
||||||
|
- [x] 断点清单4条 + 闭环度80%
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# CMA 数据分析三原则(找异常 / 读动机 / 理关联)——交付方法论
|
||||||
|
|
||||||
|
> 版本:v1.0 | 2026-08-26 | 提出:yanxueBot(user-insight-9-perspectives 视角5)
|
||||||
|
> 落地:项目Bot + 财务Bot | 状态:交付方法论(P2,不新增功能,用现有CMA能力)
|
||||||
|
> 配套模板:`docs/templates/cma-data-analysis-report-template.md`
|
||||||
|
|
||||||
|
## 0. 为什么做这套方法论
|
||||||
|
|
||||||
|
客户买了CMA,看到的不该是"一堆报表",而应该是"一个故事"。
|
||||||
|
同一个数据,讲法不同,客户价值感知完全不同:
|
||||||
|
|
||||||
|
- 讲数据:营业收入8.08万 → 客户:"哦,知道了"(无感)
|
||||||
|
- 讲故事:营业收入环比暴跌90%,触发红色预警,根因是渠道补贴冲减、真实毛利51.4%被账面口径掩盖 → 客户:"那怎么办?"(有行动)
|
||||||
|
|
||||||
|
三原则就是把"数据→洞察"做成可复制的标准流程:**找异常(信号)→ 读动机(原因)→ 理关联(对策)**。
|
||||||
|
|
||||||
|
## 1. 三原则总览
|
||||||
|
|
||||||
|
| # | 原则 | 一句话 | 对应CMA能力 | 输出 |
|
||||||
|
|---|------|--------|------------|------|
|
||||||
|
| 1 | 找异常 | 偏离预期的数字=信号=机会 | 预警规则 alert_rules(static/trend_down)、KPI偏离看板、预测偏差告警 | 异常清单(KPI×偏离度×红黄绿) |
|
||||||
|
| 2 | 读动机 | 数据背后是活生生的人 | 业务访谈、场景还原、口径拆解(多模型对比) | 动机/口径解释(为什么会这样) |
|
||||||
|
| 3 | 理关联 | 串联行为与市场,理清因果 | KPI因果链(positive/negative)、模拟推演、四维度联动 | 因果链图+对策建议(所以怎么办) |
|
||||||
|
|
||||||
|
记忆口诀:**"哪儿不对 → 为什么 → 牵一发动哪里"**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 原则一:找异常(不只看常态)
|
||||||
|
|
||||||
|
### 2.1 定义
|
||||||
|
常态是背景,异常才是信号。客户最值钱的信息不是"这个月正常",而是"这个月不对劲"。
|
||||||
|
异常=偏离预期(目标/阈值/趋势/历史同期)的数字。偏离越大,机会越大(无论好坏)。
|
||||||
|
|
||||||
|
### 2.2 CMA落地工具
|
||||||
|
- **预警规则**(alert_rules):static(绿/黄/红阈值)+ trend_down(环比跌幅)双引擎,现行78条
|
||||||
|
- **KPI看板/偏离视图**:实际值 vs 目标值,红黄绿一目了然
|
||||||
|
- **预测偏差告警**(rule_type=forecast_deviation):实际 vs 模型预测,跑偏即报警
|
||||||
|
- **多粒度对比**:月/季/年目标(target_calc_type)齐比,避免单粒度误判
|
||||||
|
|
||||||
|
### 2.3 落地步骤(30分钟)
|
||||||
|
1. 拉当期全量KPI实际值,按红黄绿筛出红/黄
|
||||||
|
2. 环比/同比/目标三个维度排序,取Top5偏离
|
||||||
|
3. 只保留"偏离有业务含义"的,剔除口径噪音(如导入错误、季节性)
|
||||||
|
4. 输出异常清单:KPI名 | 实际值 | 预期值 | 偏离度 | 红黄绿 | 首次出现时间
|
||||||
|
|
||||||
|
### 2.4 客户话术
|
||||||
|
- 开场:"这个月有X个指标在警报区,我们一个个看。"
|
||||||
|
- 提问:"这个数字偏离了目标X%,您觉得是市场变了,还是口径变了?"
|
||||||
|
- 升级:"连续两个月trend_down,这不是偶发,是结构性问题。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 原则二:读动机(不只看表象)
|
||||||
|
|
||||||
|
### 3.1 定义
|
||||||
|
数据是人行为的痕迹。同一个"毛利率低",可能是成本高(真差),也可能是返利未确认(口径差)。
|
||||||
|
不访谈、不还原场景,就敢下结论 = 报告是废纸。
|
||||||
|
|
||||||
|
### 3.2 CMA落地工具
|
||||||
|
- **业务访谈**:按KPI问业务负责人"这个数怎么来的、最近变了什么"
|
||||||
|
- **口径拆解/多模型对比**:账面口径 vs 管理口径(如Model C),还原真实经营
|
||||||
|
- **场景还原**:把数字放回业务场景(门店、客户、合同、渠道)
|
||||||
|
- **数据血缘/来源核查**:确认数字本身没错,再谈动机
|
||||||
|
|
||||||
|
### 3.3 落地步骤(60分钟)
|
||||||
|
1. 对异常清单每条,先做"口径自检":数字对了吗?口径对吗?
|
||||||
|
2. 再问"业务自检":最近有什么动作/事件影响它?
|
||||||
|
3. 访谈至少1个业务负责人,记录原话
|
||||||
|
4. 输出动机解释:异常KPI → 表面原因 → 深层动机(谁、为什么、什么场景)
|
||||||
|
|
||||||
|
### 3.4 客户话术
|
||||||
|
- "账面看是XX,但您看这个口径拆解——实际是XX。"
|
||||||
|
- "我猜是XX原因导致的,对吗?"(给客户一个可确认/可纠正的假设,不要下结论)
|
||||||
|
- "这个数字背后是哪个业务动作?是主动调整还是被动结果?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 原则三:理关联(不只盯增长)
|
||||||
|
|
||||||
|
### 4.1 定义
|
||||||
|
单点数字会骗人,因果链不会。涨跌不只看自己,要看它牵动谁、被谁牵动。
|
||||||
|
理清因果 = 找到真正的驱动杠杆,对策才有落点。
|
||||||
|
|
||||||
|
### 4.2 CMA落地工具
|
||||||
|
- **KPI因果链**(kpi_causality):positive/negative 方向 + strength 强度 + lag_months 滞后(现行28条:positive 20 / negative 8)
|
||||||
|
- **模拟推演**(POST /api/cma/kpi-causality/simulate):改一个KPI,预测下游影响链
|
||||||
|
- **四维度联动**:财务/客户/内部流程/学习成长 跨层传导(BSC四层泳道)
|
||||||
|
- **战略地图**:节点间连线即因果,从KPI追溯到OKR/战略
|
||||||
|
|
||||||
|
### 4.3 落地步骤(45分钟)
|
||||||
|
1. 对每个异常KPI,查上游(谁驱动它)+ 下游(它影响谁)
|
||||||
|
2. 标注强度(>0.7强相关)与方向,找出Top3驱动链
|
||||||
|
3. 用模拟推演验证:"如果修复这个驱动,下游能改善多少"
|
||||||
|
4. 输出因果链图 + 对策建议:优先动"强驱动、可干预"的杠杆点
|
||||||
|
|
||||||
|
### 4.4 客户话术
|
||||||
|
- "这个KPI不是孤立的——它由X驱动(强度0.9),又牵动Y。"
|
||||||
|
- "真正的问题不在表面这个数,在它上游的X。"
|
||||||
|
- "我们建议先动X:按模拟推演,X每改善10%,Y能改善约9%。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 《CMA数据分析报告》结构模板(含三原则应用位置)
|
||||||
|
|
||||||
|
完整可复用模板见 `docs/templates/cma-data-analysis-report-template.md`,骨架如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
一、经营总览(一页纸)
|
||||||
|
- 核心KPI仪表盘(红黄绿)+ 一句话结论【找异常入口】
|
||||||
|
|
||||||
|
二、异常发现【原则一·找异常】
|
||||||
|
- 异常清单表:KPI | 实际 | 预期 | 偏离 | 红黄绿
|
||||||
|
- Top3异常重点展开(趋势图+阈值线)
|
||||||
|
|
||||||
|
三、动机解读【原则二·读动机】
|
||||||
|
- 每条Top异常:口径自检结论 + 业务动机(谁/为什么/什么场景)
|
||||||
|
- 管理口径 vs 账面口径对比(如适用)
|
||||||
|
|
||||||
|
四、因果关联【原则三·理关联】
|
||||||
|
- 异常KPI的因果链图(上游驱动/下游影响,标注强度方向)
|
||||||
|
- 模拟推演结果:动哪个杠杆、影响多大
|
||||||
|
|
||||||
|
五、对策与行动
|
||||||
|
- 按"强驱动+可干预"排序的3条建议(对应责任KPI)
|
||||||
|
- 下期目标修正建议(如需)
|
||||||
|
|
||||||
|
六、附录
|
||||||
|
- 数据口径说明 / 预警规则清单 / 访谈记录要点
|
||||||
|
```
|
||||||
|
|
||||||
|
每章都在"讲一个故事":**先让客户看见异常(信号),再让他理解为什么(动机),最后带他看连锁反应和对策(关联)**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 样例:陕酣客(陕西酣客文化传媒,entity 1)三原则试跑
|
||||||
|
|
||||||
|
> 用CMA现行真实数据(2026-08-26 查询):预警规则78条(static 39 + trend_down 39)、因果链28条(positive 20 + negative 8)、营业收入实际值2026-06~08。
|
||||||
|
|
||||||
|
### 6.1 找异常
|
||||||
|
- 营业收入:2026-06 实际129.32 → 2026-07 81.08 → 2026-08 8.08
|
||||||
|
- 环比:7月 -37%,8月 **-90%**;静态规则 red < 4000(触发红色);trend_down 阈值10%(远超)
|
||||||
|
- 毛利率:账面口径 0.13%(静态规则 red < 30,严重偏离)
|
||||||
|
- 结论:收入崩盘 + 毛利率异常 = 双红色信号,值得深挖
|
||||||
|
|
||||||
|
### 6.2 读动机
|
||||||
|
- 毛利率账面0.13%的原因(访谈+口径拆解):白酒经销模式下,上游厂返利/补贴挂账未确认、渠道补贴冲减收入,账面口径失真
|
||||||
|
- 调整为管理口径(Model C:还原厂补+剔除冲减)后,真实毛利率 **51.4%**
|
||||||
|
- 动机解读:不是经营变差,是"返利确认节奏"和"渠道补贴政策"两个业务动作主导了账面数字——数据背后的活人是酒厂结算员和渠道客户
|
||||||
|
|
||||||
|
### 6.3 理关联(因果链实证)
|
||||||
|
| 因果 | 方向 | 强度 | 含义 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 毛利率 → 净利润 | positive | 0.96 | 毛利是利润的第一驱动(强) |
|
||||||
|
| 上游厂补率 → 净利润 | positive | 0.9 | 厂补确认=真金白银 |
|
||||||
|
| 渠补率 → 净利润 | negative | 0.85 | 渠道补贴是利润黑洞 |
|
||||||
|
| 费用率 → 净利润 | negative | 0.8 | 费用管控第二杠杆 |
|
||||||
|
| 营业收入 → 净利润 | positive | 0.1 | 收入规模对利润贡献很弱(反直觉!) |
|
||||||
|
|
||||||
|
- 洞察:表面看"收入崩了",因果链显示**利润真正的驱动是毛利率(0.96)和厂补率(0.9)**,而收入规模只贡献0.1——所以对策不是"冲收入",而是"确认厂补、压渠补、管费用"
|
||||||
|
- 模拟推演示意:厂补率每改善10%,净利润传导约 +9%;渠补率每压缩10%,净利润 +8.5%
|
||||||
|
|
||||||
|
### 6.4 一句话故事
|
||||||
|
"这个月收入掉了90%,账面毛利0.13%——但拆开口径,真实毛利51.4%;因果链告诉我们利润的命门是厂补确认和渠道补贴,不是收入规模。所以下月重点:催厂补、砍无效渠补。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 交付流程(标准三步)
|
||||||
|
|
||||||
|
| 步骤 | 动作 | 工具 | 时间盒 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| Step 1 | 跑异常清单 | 预警/KPI看板 | 30min |
|
||||||
|
| Step 2 | 访谈+口径拆解 | 业务访谈/多模型 | 60min |
|
||||||
|
| Step 3 | 因果链+对策 | 因果链/模拟推演 | 45min |
|
||||||
|
|
||||||
|
铁律:**没有访谈就写动机 = 编故事;没有因果链就写对策 = 拍脑袋。**
|
||||||
|
|
||||||
|
## 8. 验收与自检清单
|
||||||
|
|
||||||
|
- [ ] 报告含异常清单(有红黄绿,不只有常态)
|
||||||
|
- [ ] 每条Top异常有动机解释(有访谈/口径依据,不是猜测)
|
||||||
|
- [ ] 每个对策能追溯到因果链(有强度/方向/推演支撑)
|
||||||
|
- [ ] 客户能一句话复述故事("收入掉了但真实毛利51.4%,命门是厂补")
|
||||||
|
|
||||||
|
## 关联
|
||||||
|
- 来源:user-insight-9-perspectives.md(视角5,一组数据一个故事)
|
||||||
|
- CMA能力:alert_rules(78条)/ kpi_causality(28条)/ simulate / 四维度BSC
|
||||||
|
- 案例:陕酣客(entity 1)—— 账面毛利0.13% → Model C 51.4%
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# CMA 产品愿景与实施路线图 v1.0(2026-08-30 固化)
|
||||||
|
|
||||||
|
> 提出:任富海 | 整理:项目Bot | 状态:✅ 已确认(北极星)+ 战略全景(8方向)
|
||||||
|
|
||||||
|
## 一、产品愿景(北极星)
|
||||||
|
|
||||||
|
**一句话**:CMA = 管理会计操作系统——数据接入(接口/手工) → 多Bot互动数据 → 数据找人(主动) → 决策建议 → 决策修改+检查(闭环可审计)。
|
||||||
|
|
||||||
|
**四层北极星**:
|
||||||
|
```
|
||||||
|
① 数据接入(财务软件接口/手工录入)
|
||||||
|
↓
|
||||||
|
② 多Bot互动(14Bot协作处理数据)
|
||||||
|
↓
|
||||||
|
③ 数据找人(异常+机会主动推送)
|
||||||
|
↓
|
||||||
|
④ 决策建议 → 决策修改+检查(闭环)
|
||||||
|
```
|
||||||
|
|
||||||
|
**价值主张**:让管理团队从"被数据淹没"到"数据找人、人做决策、决策留痕"——每项决策可追溯(谁/何时/依据什么/结果如何)。
|
||||||
|
|
||||||
|
## 二、战略全景(8 补充方向)
|
||||||
|
|
||||||
|
| 方向 | 定位 | 优先级 |
|
||||||
|
|:--|:--|:--:|
|
||||||
|
| A 产品化/商业化 | CMA→可交付产品(SaaS/私有/实施) | 🔴 |
|
||||||
|
| B 行业纵深 | 白酒经销→贸易→制造(行业包) | 🟠 |
|
||||||
|
| C 数据资产化 | 博海+客户数据→数据产品(DAMA治理) | 🟠 |
|
||||||
|
| D 决策智能 | 提建议→预测决策(敏感性/因果/复盘) | 🔴 |
|
||||||
|
| E AI原生组织方法论 | **护城河**:14Bot/铁律/闭环体系产品化 | 🟡 |
|
||||||
|
| F 生态联盟 | 财务软件对接(用友/金蝶)+渠道 | 🟡 |
|
||||||
|
| G 信任合规 | PIPL/等保/AI可信/审计链 | 🟠 |
|
||||||
|
| H 技术前瞻 | 数字员工/AI同事/Agent自动执行 | 🟡 |
|
||||||
|
|
||||||
|
## 三、实施路线图(按优先级)
|
||||||
|
|
||||||
|
### 🔴 近期(1-3个月)——北极星核心闭环
|
||||||
|
| # | 方向 | 目标 | 关键动作 | 验收 |
|
||||||
|
|:--|:--|:--|:--|:--|
|
||||||
|
| R1 | ④决策智能 | AI建议→一键落地 | ai_analysis 建议可"应用到KPI/预算/行动"(写库+留痕) | 建议生成→点击落地→操作日志可查 |
|
||||||
|
| R2 | ③数据找人 | 主动推送扩大 | 机会/趋势推送(不止异常):KPI向好/预算余量/滚动机会 | 每日推送含异常+机会两类 |
|
||||||
|
| R3 | ①数据接入调研 | 财务软件接口方案 | 调研用友/金蝶/管家婆开放API+实施成本 | 接口可行性报告 |
|
||||||
|
| R4 | A产品化准备 | 酣客试点成案例 | 试点数据闭环+试点报告(作首个客户案例) | 案例文档+官网可引用 |
|
||||||
|
| R5 | 预算bug修复链 | 系统稳定 | 年度分解幂等+预算/现金流/行动闭环加固 | pytest全绿 |
|
||||||
|
|
||||||
|
### 🟠 中期(3-6个月)——产品化+合规
|
||||||
|
| # | 方向 | 目标 | 关键动作 | 验收 |
|
||||||
|
|:--|:--|:--|:--|:--|
|
||||||
|
| M1 | A产品化 | CMA可交付形态 | SaaS多租户完善/私有部署包/实施文档 | 第2-3个客户可用 |
|
||||||
|
| M2 | B行业复制 | 白酒经销行业包 | 行业KPI库/OKR模板/科目模板校准(酣客数据) | 行业包v1 |
|
||||||
|
| M3 | G合规 | 信任背书 | 数据安全分级/PIPL清单/审计链完善 | 合规清单 |
|
||||||
|
| M4 | ①数据接入落地 | 财务软件接口 | 按R3方案接入1个财务软件 | 接口联调通过 |
|
||||||
|
|
||||||
|
### 🟡 远期(6-12个月)——方法论+生态
|
||||||
|
| # | 方向 | 目标 | 关键动作 | 验收 |
|
||||||
|
|:--|:--|:--|:--|:--|
|
||||||
|
| F1 | E方法论 | AI原生组织产品 | 评估+实施+运营三件套方法论文档化 | 方法论v1可售 |
|
||||||
|
| F2 | F生态 | 渠道伙伴 | 代账/咨询/本地IT渠道首批 | 3家伙伴 |
|
||||||
|
| F3 | H前瞻 | 数字员工试点 | AI同事(自动执行例行决策)试点 | 试点报告 |
|
||||||
|
| F4 | D完整版 | 预测性成本智能完整 | 宏观数据回归校准+预测偏差告警完善 | IMA对标 |
|
||||||
|
|
||||||
|
## 四、依赖与飞轮
|
||||||
|
|
||||||
|
```
|
||||||
|
R4酣客案例 → M1产品化 → F1方法论 → F2生态
|
||||||
|
↑____________↑___________________↓
|
||||||
|
数据/案例反哺(B行业包)
|
||||||
|
```
|
||||||
|
|
||||||
|
**飞轮起点**:近期 R1-R5(决策闭环+稳定+案例)——先让内部系统达到"决策可落地可追溯",再谈产品化。
|
||||||
|
|
||||||
|
## 五、北极星四层 → 落地项(映射)
|
||||||
|
|
||||||
|
| 层 | 近期 | 中期 | 远期 |
|
||||||
|
|:--|:--|:--|:--|
|
||||||
|
| ①数据接入 | R3调研 | M4接口落地 | 多软件适配 |
|
||||||
|
| ②多Bot互动 | 保持 | Bot联合决策 | Agent自动执行(F3) |
|
||||||
|
| ③数据找人 | R2推送扩大 | 推送策略化 | AI同事(F3) |
|
||||||
|
| ④决策闭环 | R1建议落地+R5稳定 | 决策复盘闭环 | 预测决策(F4) |
|
||||||
|
|
||||||
|
## 六、节奏建议
|
||||||
|
- **月度检查点**:每月对照路线图验收(R/M/F 项完成度)
|
||||||
|
- **北极星校验**:每季度问"数据找人了吗?决策落地了吗?可追溯吗?"
|
||||||
|
- **资源配置**:近期全栈Bot集中 R1/R2/R5(代码);项目Bot R3调研+R4案例(方案)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user