Compare commits
111
Commits
d7e97978c6
..
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 | ||
|
|
cb318115e8 | ||
|
|
acb4709ca9 | ||
|
|
60be96bfaa | ||
|
|
d20a270ba2 | ||
|
|
199278a552 | ||
|
|
046a7cf8b9 | ||
|
|
0daa079b96 | ||
|
|
cc7cddd646 | ||
|
|
142fff2cae | ||
|
|
236fd34f08 | ||
|
|
3a57afcec4 | ||
|
|
dbfa6ba0a2 | ||
|
|
4d2fba67f7 | ||
|
|
a1d014258f | ||
|
|
863514fec0 | ||
|
|
b6613bbde2 | ||
|
|
835ac10e95 | ||
|
|
1c536cc5e1 | ||
|
|
1e0439ff72 | ||
|
|
54107b50cd | ||
|
|
6b6043536a | ||
|
|
8ec846c6df | ||
|
|
1c8b01d682 | ||
|
|
9de6e522a4 | ||
|
|
13aa153875 | ||
|
|
fb9eba38a8 | ||
|
|
27b5b0da47 | ||
|
|
50c15ddbf6 | ||
|
|
9c61b02d66 | ||
|
|
412a302699 | ||
|
|
9ccdd0db63 | ||
|
|
8103ee4788 | ||
|
|
b787d22fdf | ||
|
|
b06821a426 | ||
|
|
d5edcefb48 | ||
|
|
a9845eb2cf | ||
|
|
a13a080381 | ||
|
|
dd53212bcc | ||
|
|
bd9c70ea05 | ||
|
|
0b305e3ab3 | ||
|
|
31080500f0 | ||
|
|
c952930dfb | ||
|
|
5805ef34e7 | ||
|
|
58e8a9f5b3 | ||
|
|
32fb8d9b06 | ||
|
|
bf0f0b3054 | ||
|
|
40d743fc96 | ||
|
|
acf0665010 | ||
|
|
7b5795bf07 | ||
|
|
603d6ae5cf | ||
|
|
778a5d9c79 | ||
|
|
5a196dc105 | ||
|
|
266277d121 | ||
|
|
a268c4e74d | ||
|
|
1bdfd95fde | ||
|
|
2f2ada530d |
@@ -11,3 +11,6 @@ __pycache__/
|
||||
*.tsbuildinfo
|
||||
venv/
|
||||
backend/logs/
|
||||
|
||||
# 因果链验证报告(生成物)
|
||||
backend/scripts/reports/
|
||||
|
||||
+20
-10
@@ -22,6 +22,16 @@ steps:
|
||||
when:
|
||||
- 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:
|
||||
image: node:20-alpine
|
||||
commands:
|
||||
@@ -32,6 +42,15 @@ steps:
|
||||
when:
|
||||
- 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:
|
||||
image: alpine:latest
|
||||
secrets:
|
||||
@@ -49,15 +68,6 @@ steps:
|
||||
- SSH_DEPLOY_KEY
|
||||
commands:
|
||||
- *ssh_setup
|
||||
- ssh root@git.sxbh.ltd '
|
||||
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 &
|
||||
'
|
||||
- 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'
|
||||
when:
|
||||
- path: backend/**
|
||||
|
||||
Binary file not shown.
@@ -7,8 +7,10 @@ import re
|
||||
import logging
|
||||
from calendar import monthrange
|
||||
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 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")
|
||||
|
||||
@@ -63,6 +65,7 @@ def plan_to_dict(p: ActionPlan) -> dict:
|
||||
"alert_id": p.alert_id,
|
||||
"kpi_id": p.kpi_id,
|
||||
"objective_id": p.objective_id,
|
||||
"kr_id": p.kr_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"assignee": p.assignee,
|
||||
@@ -86,11 +89,13 @@ def list_plans(
|
||||
status: Optional[str] = None,
|
||||
kpi_id: Optional[int] = None,
|
||||
alert_id: Optional[int] = None,
|
||||
keyword: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取行动计划列表"""
|
||||
query = db.query(ActionPlan).order_by(ActionPlan.created_at.desc())
|
||||
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)"""
|
||||
query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc())
|
||||
|
||||
if status:
|
||||
query = query.filter(ActionPlan.status == status)
|
||||
@@ -98,6 +103,8 @@ def list_plans(
|
||||
query = query.filter(ActionPlan.kpi_id == kpi_id)
|
||||
if alert_id:
|
||||
query = query.filter(ActionPlan.alert_id == alert_id)
|
||||
if keyword:
|
||||
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
|
||||
|
||||
# business角色只看自己的
|
||||
if current_user.role == "business":
|
||||
@@ -113,6 +120,16 @@ def list_plans(
|
||||
# 附带KPI名称
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||
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)
|
||||
|
||||
return {"data": result}
|
||||
@@ -123,12 +140,27 @@ def create_plan(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""创建改善行动计划(也是OKR的KR)"""
|
||||
required = ["title", "kpi_id"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
raise HTTPException(400, f"缺少必填字段: {field}")
|
||||
# 账套隔离: 关联KPI必须属于当前企业 (2026-08-23 P1b)
|
||||
kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == data["kpi_id"]).first()
|
||||
if not kpi_ent or kpi_ent.entity_id != entity_id:
|
||||
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
|
||||
|
||||
@@ -143,6 +175,7 @@ def create_plan(
|
||||
alert_id=data.get("alert_id"),
|
||||
kpi_id=data["kpi_id"],
|
||||
objective_id=objective_id,
|
||||
kr_id=kr_id,
|
||||
title=data["title"],
|
||||
description=data.get("description"),
|
||||
assignee=data.get("assignee"),
|
||||
@@ -151,7 +184,22 @@ def create_plan(
|
||||
status="pending",
|
||||
progress=0,
|
||||
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.commit()
|
||||
db.refresh(plan)
|
||||
@@ -207,6 +255,21 @@ def update_plan(
|
||||
plan.assignee = data["assignee"]
|
||||
if "priority" in data:
|
||||
plan.priority = data["priority"]
|
||||
if "kr_id" in data:
|
||||
# 行动挂KR: 支持置空(null)或改挂
|
||||
if data["kr_id"] is None:
|
||||
plan.kr_id = None
|
||||
else:
|
||||
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
plan.kr_id = kr_ent.id
|
||||
if data.get("objective_id") is not None:
|
||||
plan.objective_id = data["objective_id"]
|
||||
elif plan.objective_id is None:
|
||||
plan.objective_id = kr_ent.objective_id
|
||||
if "objective_id" in data:
|
||||
plan.objective_id = data["objective_id"]
|
||||
if "due_date" in data:
|
||||
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
if "status" in data:
|
||||
|
||||
+263
-13
@@ -2,20 +2,247 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
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.deps import get_entity_id
|
||||
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
|
||||
import json, hashlib, httpx, os
|
||||
from datetime import datetime
|
||||
import json, hashlib, httpx, os, urllib.request, urllib.parse
|
||||
from datetime import datetime, date
|
||||
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
|
||||
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:
|
||||
"""调用DeepSeek API"""
|
||||
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
|
||||
api_key = _require_deepseek_key()
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
"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", "")
|
||||
|
||||
@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分析驾驶舱数据"""
|
||||
# 尝试缓存
|
||||
cache_key = f"dashboard_analysis:{role}"
|
||||
cache_key = f"dashboard_analysis:{role}:{entity_id}"
|
||||
cached = cache_get("ai", cache_key)
|
||||
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
|
||||
# 获取当前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 = []
|
||||
for k in kpis:
|
||||
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:
|
||||
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分钟
|
||||
cache_set("ai", cache_key, result, ttl_seconds=600)
|
||||
return result
|
||||
|
||||
|
||||
@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"""
|
||||
# 尝试缓存
|
||||
cache_key = f"kpi_analysis:{kpi_id}"
|
||||
cache_key = f"kpi_analysis:{kpi_id}:{entity_id}"
|
||||
cached = cache_get("ai", cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
@@ -129,7 +372,14 @@ KPI名称:{kpi.kpi_name}
|
||||
except Exception as 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)
|
||||
return result
|
||||
|
||||
@@ -141,7 +391,7 @@ async def _stream_analysis(prompt: str):
|
||||
"POST",
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY', 'sk-8e24e6eb87f2475e96ea0980002dc2e8')}",
|
||||
"Authorization": f"Bearer {_require_deepseek_key()}",
|
||||
"Content-Type": "application/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": "建议已忽略"}
|
||||
+151
-49
@@ -11,6 +11,7 @@ import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db, Base
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||
|
||||
@@ -23,6 +24,7 @@ class AlertRule(Base):
|
||||
"""预警规则配置"""
|
||||
__tablename__ = "alert_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||
rule_type = Column(String(30), nullable=False, comment="static/dynamic/trend_up/trend_down")
|
||||
trigger_on = Column(String(20), default="actual", comment="actual/forecast/both — 实际值/预测值/两者触发")
|
||||
@@ -39,6 +41,7 @@ class DynamicThresholdCache(Base):
|
||||
"""动态阈值缓存 — 存储近3个月历史统计"""
|
||||
__tablename__ = "dynamic_threshold_cache"
|
||||
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")
|
||||
period = Column(String(20), nullable=False, comment="计算期间 2026-07")
|
||||
mean_value = Column(Float, nullable=True, comment="近3月均值")
|
||||
@@ -64,9 +67,10 @@ def list_alert_rules(
|
||||
rule_type: Optional[str] = None,
|
||||
enabled: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""列出所有预警规则"""
|
||||
query = db.query(AlertRule)
|
||||
"""列出所有预警规则(账套隔离: 按token企业, 2026-08-23 P2)"""
|
||||
query = db.query(AlertRule).filter(AlertRule.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(AlertRule.kpi_id == kpi_id)
|
||||
if rule_type:
|
||||
@@ -88,15 +92,16 @@ def list_alert_rules(
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}")
|
||||
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单个KPI的所有预警规则"""
|
||||
rules = db.query(AlertRule).filter(AlertRule.kpi_id == kpi_id).order_by(AlertRule.id).all()
|
||||
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取单个KPI的所有预警规则(账套隔离: 按token企业)"""
|
||||
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]}
|
||||
|
||||
|
||||
@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")
|
||||
rule_type = data.get("rule_type", "static")
|
||||
trigger_on = data.get("trigger_on", "actual")
|
||||
@@ -104,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()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if rule_type not in ("static", "dynamic", "trend_up", "trend_down"):
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if rule_type not in ("static", "dynamic", "trend_up", "trend_down", "forecast_deviation"): # 升级2b: 预测偏差
|
||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
||||
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
trigger_on=trigger_on,
|
||||
@@ -129,9 +137,9 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
|
||||
|
||||
@router.put("/{rule_id}")
|
||||
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""更新预警规则"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||
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, AlertRule.entity_id == entity_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "预警规则不存在")
|
||||
|
||||
@@ -144,9 +152,9 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
|
||||
"""删除预警规则"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||
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, AlertRule.entity_id == entity_id).first()
|
||||
if rule:
|
||||
db.delete(rule)
|
||||
db.commit()
|
||||
@@ -154,8 +162,8 @@ def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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": {...}}, ...]
|
||||
"""
|
||||
rules_data = data.get("rules", [])
|
||||
@@ -163,14 +171,19 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
||||
for rule_data in rules_data:
|
||||
kpi_id = rule_data.get("kpi_id")
|
||||
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(
|
||||
AlertRule.kpi_id == kpi_id,
|
||||
AlertRule.rule_type == rule_type,
|
||||
AlertRule.entity_id == entity_id,
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
enabled=rule_data.get("enabled", 1),
|
||||
@@ -183,15 +196,17 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/generate-defaults")
|
||||
def generate_default_rules(db: Session = Depends(get_db)):
|
||||
"""为所有尚未配置预警规则的KPI生成默认规则"""
|
||||
# 找到所有active KPI
|
||||
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""为当前企业尚未配置预警规则的KPI生成默认规则(账套隔离 2026-08-31)"""
|
||||
# 找到当前企业所有active KPI
|
||||
all_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||
|
||||
created = 0
|
||||
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:
|
||||
continue
|
||||
|
||||
@@ -200,6 +215,7 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
||||
# 1. 静态阈值规则(基于kpi_definitions的阈值)
|
||||
if kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red:
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type="static",
|
||||
enabled=1,
|
||||
@@ -214,6 +230,7 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
||||
|
||||
# 2. 动态趋势规则(所有KPI默认加 trend_down)
|
||||
rule2 = AlertRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type="trend_down",
|
||||
enabled=1,
|
||||
@@ -227,9 +244,10 @@ def generate_default_rules(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/check-all")
|
||||
def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
"""执行所有KPI的预警检查 — 生成新的预警记录"""
|
||||
rules = db.query(AlertRule).filter(AlertRule.enabled == 1).all()
|
||||
def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)"""
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
|
||||
kpi_cache = {}
|
||||
value_cache = {}
|
||||
|
||||
@@ -243,7 +261,7 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
if kpi:
|
||||
kpi_cache[rule.kpi_id] = kpi
|
||||
|
||||
if not kpi:
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
continue
|
||||
|
||||
# 获取最新值
|
||||
@@ -285,6 +303,7 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
).first()
|
||||
if not existing_alert:
|
||||
alert = KPIAlert(
|
||||
entity_id=entity_id,
|
||||
kpi_id=rule.kpi_id,
|
||||
kpi_value_id=latest_value.id,
|
||||
alert_level=alert_level,
|
||||
@@ -303,9 +322,9 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/dynamic-thresholds")
|
||||
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
"""获取动态阈值缓存"""
|
||||
query = db.query(DynamicThresholdCache)
|
||||
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).filter(DynamicThresholdCache.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
|
||||
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
|
||||
@@ -313,9 +332,10 @@ def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(g
|
||||
|
||||
|
||||
@router.post("/calculate-dynamic")
|
||||
def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
||||
"""计算所有KPI的动态阈值(基于近3个月历史均值±标准差)"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
def calculate_dynamic_thresholds(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""计算当前企业所有KPI的动态阈值(账套隔离 2026-08-31)"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
computed = 0
|
||||
@@ -348,8 +368,9 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
||||
dynamic_yellow = f">={mean_val:.2f}"
|
||||
dynamic_red = f"<{mean_val:.2f}"
|
||||
|
||||
# 检查是否已有缓存
|
||||
# 检查是否已有缓存(同企业内)
|
||||
existing = db.query(DynamicThresholdCache).filter(
|
||||
DynamicThresholdCache.entity_id == entity_id,
|
||||
DynamicThresholdCache.kpi_id == kpi.id,
|
||||
DynamicThresholdCache.period == current_period,
|
||||
).first()
|
||||
@@ -362,6 +383,7 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
||||
existing.dynamic_red = dynamic_red
|
||||
else:
|
||||
cache = DynamicThresholdCache(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi.id,
|
||||
period=current_period,
|
||||
mean_value=mean_val,
|
||||
@@ -397,7 +419,8 @@ def _check_static(value: float, params: dict, kpi) -> tuple:
|
||||
return ("green", f"[静态] {kpi.kpi_name}={value}, 绿灯{green}")
|
||||
elif _eval_threshold(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 (None, None)
|
||||
@@ -466,16 +489,17 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
|
||||
try:
|
||||
if threshold_str.startswith(">="):
|
||||
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("<="):
|
||||
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(">"):
|
||||
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("<"):
|
||||
limit = float(threshold_str[1:])
|
||||
return value < limit if not invert else value < limit
|
||||
return value >= limit if invert else value < limit
|
||||
else:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
@@ -486,13 +510,14 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
|
||||
# 预测值检查 + 情景建议
|
||||
# ============================================================
|
||||
|
||||
def _check_forecast_alerts(db: Session) -> int:
|
||||
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast'和'both'的规则"""
|
||||
def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
|
||||
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast'和'both'的规则(账套隔离 2026-08-31)"""
|
||||
from app.utils.cash_forecast_engine import forecast_cash_flow, generate_scenario_suggestion
|
||||
from app.models import CashForecast
|
||||
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.enabled == 1,
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.trigger_on.in_(["forecast", "both"]),
|
||||
).all()
|
||||
|
||||
@@ -509,11 +534,10 @@ def _check_forecast_alerts(db: Session) -> int:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
if kpi:
|
||||
rule_kpi_cache[rule.kpi_id] = kpi
|
||||
if not kpi:
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
continue
|
||||
|
||||
entity_id = kpi.entity_id or 1
|
||||
# 获取最新的预测
|
||||
# 获取最新的预测(按规则所属企业)
|
||||
latest_forecasts = db.query(CashForecast).filter(
|
||||
CashForecast.entity_id == entity_id,
|
||||
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
|
||||
@@ -559,6 +583,7 @@ def _check_forecast_alerts(db: Session) -> int:
|
||||
).first()
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
entity_id=entity_id,
|
||||
kpi_id=rule.kpi_id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_message,
|
||||
@@ -577,18 +602,19 @@ def _check_forecast_alerts(db: Session) -> int:
|
||||
|
||||
|
||||
@router.post("/check-forecast")
|
||||
def run_forecast_alert_check(db: Session = Depends(get_db)):
|
||||
"""执行预测值预警检查 — 检查未来7天预测值是否超限"""
|
||||
generated = _check_forecast_alerts(db)
|
||||
def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)"""
|
||||
generated = _check_forecast_alerts(db, entity_id=entity_id)
|
||||
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
pending = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.suggestion.is_(None),
|
||||
).all()
|
||||
@@ -616,3 +642,79 @@ def generate_alert_suggestions(db: Session = Depends(get_db)):
|
||||
|
||||
db.commit()
|
||||
return {"message": f"已为{updated}条预警生成情景建议", "updated": updated}
|
||||
|
||||
|
||||
@router.post("/run-forecast-deviation")
|
||||
def run_forecast_deviation_check(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
|
||||
对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values),
|
||||
偏差 > threshold_pct → 生成/更新 pending 预警(去重)"""
|
||||
from app.models import KpiForecastLog
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.rule_type == "forecast_deviation",
|
||||
AlertRule.enabled == 1,
|
||||
).all()
|
||||
if not rules:
|
||||
return {"message": "无预测偏差规则,可先创建 rule_type=forecast_deviation 规则", "generated": 0}
|
||||
|
||||
generated = 0
|
||||
for rule in rules:
|
||||
try:
|
||||
params = rule.params or {}
|
||||
threshold = float(params.get("threshold_pct", 15))
|
||||
# 最新预测
|
||||
log = db.query(KpiForecastLog).filter(
|
||||
KpiForecastLog.entity_id == entity_id,
|
||||
KpiForecastLog.kpi_id == rule.kpi_id,
|
||||
).order_by(KpiForecastLog.created_at.desc()).first()
|
||||
if not log or log.forecast_value is None:
|
||||
continue
|
||||
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
|
||||
actual = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == rule.kpi_id,
|
||||
KPIValue.period == log.period,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if not actual or not actual.actual_value:
|
||||
continue
|
||||
base = abs(actual.actual_value)
|
||||
if base < 1e-9:
|
||||
continue
|
||||
deviation_pct = abs(log.forecast_value - actual.actual_value) / base * 100
|
||||
if deviation_pct <= threshold:
|
||||
continue
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
kpi_label = f"{kpi.kpi_name}({kpi.kpi_code})" if kpi else f"KPI#{rule.kpi_id}"
|
||||
alert_level = "red" if deviation_pct > threshold * 2 else "yellow"
|
||||
alert_message = (
|
||||
f"预测偏差 {deviation_pct:.1f}% > 阈值{threshold}%:"
|
||||
f"{kpi_label} 预测{log.period}={log.forecast_value},实际={actual.actual_value}"
|
||||
)
|
||||
# 去重: 同KPI+period 已有 pending 偏差预警
|
||||
existing = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == rule.kpi_id,
|
||||
KPIAlert.alert_message.like(f"%预测偏差%{log.period}%"),
|
||||
KPIAlert.status == "pending",
|
||||
).first()
|
||||
if existing:
|
||||
existing.alert_message = alert_message
|
||||
existing.alert_level = alert_level
|
||||
else:
|
||||
db.add(KPIAlert(
|
||||
entity_id=entity_id,
|
||||
kpi_id=rule.kpi_id,
|
||||
kpi_value_id=actual.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_message,
|
||||
alert_type="forecast",
|
||||
status="pending",
|
||||
))
|
||||
generated += 1
|
||||
except Exception as e:
|
||||
logger.error(f"预测偏差检查失败 rule_id={rule.id}: {e}")
|
||||
continue
|
||||
db.commit()
|
||||
return {"message": f"预测偏差检查完成: {generated}条", "generated": generated}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIAlert, OperationLog, ActionPlan
|
||||
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.alerts")
|
||||
@@ -13,8 +14,9 @@ router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
|
||||
)
|
||||
|
||||
@router.get("")
|
||||
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db)):
|
||||
query = db.query(KPIAlert)
|
||||
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b)
|
||||
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(KPIAlert.status == status)
|
||||
total = query.count()
|
||||
@@ -22,8 +24,9 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De
|
||||
return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]}
|
||||
|
||||
@router.post("/{alert_id}/resolve")
|
||||
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
||||
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
|
||||
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
alert.status = "resolved"
|
||||
|
||||
@@ -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
|
||||
@@ -79,8 +79,12 @@ def register(data: dict, db: Session = Depends(get_db)):
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
# 新用户默认授予所有active企业
|
||||
_ensure_default_grants(db, user.id)
|
||||
db.refresh(user)
|
||||
# 注册默认授权第一个active企业(不授予全部,保证账套隔离)
|
||||
first_ent = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).first()
|
||||
if first_ent:
|
||||
db.add(UserEntity(user_id=user.id, entity_id=first_ent.id))
|
||||
db.commit()
|
||||
return {"message": "注册成功"}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
|
||||
|
||||
@@ -126,9 +127,9 @@ def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_reports(db: Session = Depends(get_db)):
|
||||
"""获取用户保存的报表"""
|
||||
reports = db.query(BiReport).order_by(BiReport.updated_at.desc()).all()
|
||||
def list_reports(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取用户保存的报表(账套隔离: 按token企业, 2026-08-23 P2)"""
|
||||
reports = db.query(BiReport).filter(BiReport.entity_id == entity_id).order_by(BiReport.updated_at.desc()).all()
|
||||
result = []
|
||||
for r in reports:
|
||||
d = {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
+241
-46
@@ -9,14 +9,18 @@ from sqlalchemy import func, desc
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.models import (
|
||||
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
||||
NotificationLog, RolePermission, ActionPlan, OrgNode,
|
||||
StrategicMapVersion, MapObjective, Objective,
|
||||
StrategicMapVersion, MapObjective, Objective, KR,
|
||||
)
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.models import KPICausality
|
||||
from app.risk_levels import risk_level
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge")
|
||||
|
||||
@@ -77,6 +81,7 @@ def _model_dict(obj, fields: dict):
|
||||
# ═══════════════ 端点 ═══════════════
|
||||
|
||||
@router.get("/ping")
|
||||
@risk_level("L1")
|
||||
def ping():
|
||||
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
|
||||
|
||||
@@ -84,23 +89,26 @@ def ping():
|
||||
# ── 总览 ──
|
||||
|
||||
@router.get("/overview")
|
||||
@risk_level("L1")
|
||||
def bot_overview(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""系统总览 — BOT首选入口"""
|
||||
"""系统总览 — BOT首选入口(账套隔离 2026-08-31: 仅统计当前企业)"""
|
||||
return {
|
||||
"bot": bot,
|
||||
"entity_id": entity_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"stats": {
|
||||
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
|
||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
|
||||
"maps_total": db.query(func.count(StrategicMap.id)).scalar() or 0,
|
||||
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
|
||||
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
|
||||
"data_sources": db.query(func.count(DataSourceConfig.id)).scalar() or 0,
|
||||
"users": db.query(func.count(User.id)).scalar() or 0,
|
||||
"org_nodes": db.query(func.count(OrgNode.id)).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", KPIAlert.entity_id == entity_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)).filter(BudgetPlan.entity_id == entity_id).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)).filter(DataSourceConfig.entity_id == entity_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)).filter(OrgNode.entity_id == entity_id).scalar() or 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,14 +116,16 @@ def bot_overview(
|
||||
# ── KPI ──
|
||||
|
||||
@router.get("/kpis")
|
||||
@risk_level("L1")
|
||||
def bot_kpis(
|
||||
dimension: Optional[str] = Query(None),
|
||||
status: str = Query("active"),
|
||||
limit: int = Query(200, le=1000),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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:
|
||||
query = query.filter(KPIDefinition.dimension == dimension)
|
||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
|
||||
@@ -134,7 +144,7 @@ def bot_kpis(
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
"threshold_red": k.threshold_red,
|
||||
"responsible_dept": k.responsible_dept, "owner": k.responsible_user,
|
||||
"objective": k.objective, "description": k.description,
|
||||
"objective": k.objective, "formula_desc": k.formula_desc,
|
||||
"latest_value": _float(latest.actual_value) if latest else None,
|
||||
"latest_period": latest.period if latest else None,
|
||||
"status": k.status,
|
||||
@@ -143,13 +153,15 @@ def bot_kpis(
|
||||
|
||||
|
||||
@router.get("/kpis/{kpi_id}/history")
|
||||
@risk_level("L1")
|
||||
def bot_kpi_history(
|
||||
kpi_id: int, limit: int = Query(12, le=60),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
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不存在")
|
||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
|
||||
.order_by(KPIValue.period.desc()).limit(limit).all()
|
||||
@@ -169,11 +181,13 @@ def bot_kpi_history(
|
||||
# ── 战略地图 ──
|
||||
|
||||
@router.get("/strategic-maps")
|
||||
@risk_level("L1")
|
||||
def bot_maps(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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 = []
|
||||
for m in maps:
|
||||
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
|
||||
@@ -196,14 +210,16 @@ def bot_maps(
|
||||
# ── 预警 ──
|
||||
|
||||
@router.get("/alerts")
|
||||
@risk_level("L1")
|
||||
def bot_alerts(
|
||||
status: str = Query("pending"),
|
||||
level: Optional[str] = Query(None),
|
||||
limit: int = Query(50, le=200),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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)
|
||||
if level:
|
||||
query = query.filter(KPIAlert.alert_level == level)
|
||||
@@ -226,12 +242,14 @@ def bot_alerts(
|
||||
# ── 预算 ──
|
||||
|
||||
@router.get("/budget/plans")
|
||||
@risk_level("L1")
|
||||
def bot_budget_plans(
|
||||
year: Optional[int] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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:
|
||||
query = query.filter(BudgetPlan.budget_year == year)
|
||||
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
|
||||
@@ -253,11 +271,13 @@ def bot_budget_plans(
|
||||
# ── 成本 ──
|
||||
|
||||
@router.get("/cost/standard")
|
||||
@risk_level("L1")
|
||||
def bot_standard_costs(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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 {
|
||||
"total": len(costs),
|
||||
"items": [
|
||||
@@ -276,12 +296,14 @@ def bot_standard_costs(
|
||||
|
||||
|
||||
@router.get("/cost/actual")
|
||||
@risk_level("L1")
|
||||
def bot_actual_costs(
|
||||
period: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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:
|
||||
query = query.filter(ActualCost.period == period)
|
||||
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
|
||||
@@ -304,12 +326,14 @@ def bot_actual_costs(
|
||||
# ── 行动方案 ──
|
||||
|
||||
@router.get("/actions")
|
||||
@risk_level("L1")
|
||||
def bot_actions(
|
||||
status: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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:
|
||||
query = query.filter(ActionPlan.status == status)
|
||||
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
|
||||
@@ -331,11 +355,13 @@ def bot_actions(
|
||||
# ── 组织 ──
|
||||
|
||||
@router.get("/organization")
|
||||
@risk_level("L1")
|
||||
def bot_org(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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 {
|
||||
"total": len(nodes),
|
||||
"items": [
|
||||
@@ -352,11 +378,13 @@ def bot_org(
|
||||
# ── 数据源 ──
|
||||
|
||||
@router.get("/data-sources")
|
||||
@risk_level("L1")
|
||||
def bot_data_sources(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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 {
|
||||
"total": len(sources),
|
||||
"items": [
|
||||
@@ -375,11 +403,15 @@ def bot_data_sources(
|
||||
# ── 用户 ──
|
||||
|
||||
@router.get("/users")
|
||||
@risk_level("L1")
|
||||
def bot_users(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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 {
|
||||
"total": len(users),
|
||||
"items": [
|
||||
@@ -393,24 +425,26 @@ def bot_users(
|
||||
# ── 统一查询(BOT首选) ──
|
||||
|
||||
@router.get("/query")
|
||||
@risk_level("L1")
|
||||
def bot_query(
|
||||
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""统一查询入口 — BOT用这个一次拿完需要的数据"""
|
||||
result = {"bot": bot["name"], "role": bot["role"], "timestamp": datetime.now().isoformat()}
|
||||
"""统一查询入口 — BOT用这个一次拿完需要的数据(账套隔离 2026-08-31)"""
|
||||
result = {"bot": bot["name"], "role": bot["role"], "entity_id": entity_id, "timestamp": datetime.now().isoformat()}
|
||||
|
||||
if q in ("overview", "all"):
|
||||
result["overview"] = {
|
||||
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
|
||||
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
|
||||
"maps": db.query(func.count(StrategicMap.id)).scalar() or 0,
|
||||
"budget_plans": db.query(func.count(BudgetPlan.id)).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", KPIAlert.entity_id == entity_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)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
|
||||
}
|
||||
|
||||
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"] = [
|
||||
{"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
||||
"dimension": k.dimension, "target": _float(k.target_value), "unit": k.unit}
|
||||
@@ -418,7 +452,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
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()
|
||||
result["alerts"] = [
|
||||
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
|
||||
@@ -427,7 +461,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
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"] = [
|
||||
{"id": m.id, "title": m.title, "status": m.status,
|
||||
"version": m.version, "created_at": _safe_iso(m.created_at)}
|
||||
@@ -435,7 +469,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
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"] = [
|
||||
{"id": p.id, "period": p.period, "budget_value": _float(p.budget_value),
|
||||
"year": p.budget_year, "month": p.budget_month, "status": p.status,
|
||||
@@ -444,7 +478,7 @@ def bot_query(
|
||||
]
|
||||
|
||||
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"] = [
|
||||
{"id": c.id, "product": c.product_name, "type": c.cost_type,
|
||||
"standard": _float(c.standard_cost), "unit": c.unit}
|
||||
@@ -452,22 +486,24 @@ def bot_query(
|
||||
]
|
||||
|
||||
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"] = []
|
||||
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({
|
||||
"id": o.id, "title": o.title, "quarter": o.quarter,
|
||||
"dimension": o.dimension, "progress": o.progress,
|
||||
"confidence": o.confidence,
|
||||
"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
|
||||
]
|
||||
})
|
||||
|
||||
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"] = [
|
||||
{"id": a.id, "title": a.title, "status": a.status,
|
||||
"progress": a.progress, "assignee": a.assignee}
|
||||
@@ -478,12 +514,14 @@ def bot_query(
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
@risk_level("L3")
|
||||
def bot_import_excel(
|
||||
file: UploadFile = File(...),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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
|
||||
from app.models import KPIValue
|
||||
try:
|
||||
@@ -527,9 +565,12 @@ def bot_import_excel(
|
||||
if not kpi:
|
||||
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
|
||||
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, period=period, actual_value=val,
|
||||
batch_id=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
||||
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])
|
||||
db.add(kv)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
@@ -542,16 +583,18 @@ def bot_import_excel(
|
||||
# ── 自然语言查询 ──
|
||||
|
||||
@router.post("/okr/create")
|
||||
@risk_level("L2")
|
||||
def bot_okr_create(
|
||||
title: str = Query(...),
|
||||
quarter: str = Query(...),
|
||||
dimension: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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
|
||||
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.commit()
|
||||
db.refresh(obj)
|
||||
@@ -559,14 +602,16 @@ def bot_okr_create(
|
||||
|
||||
|
||||
@router.get("/okr/list")
|
||||
@risk_level("L1")
|
||||
def bot_okr_list(
|
||||
quarter: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
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
|
||||
q = db.query(Objective)
|
||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
objs = q.order_by(Objective.quarter.desc()).all()
|
||||
@@ -580,10 +625,12 @@ def bot_okr_list(
|
||||
|
||||
|
||||
@router.get("/nlp")
|
||||
@risk_level("L1")
|
||||
def bot_nlp(
|
||||
intent: str = Query("overview"),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""
|
||||
自然语言意图映射:
|
||||
@@ -600,4 +647,152 @@ def bot_nlp(
|
||||
"okr": "okr", "目标": "okr", "季度目标": "okr",
|
||||
}
|
||||
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)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 聚合接口(Agent化生产链路 · 行动1, 2026-08-25)
|
||||
# 建议A: KPI值更新联动预警检查 | 建议B: KPI创建联动关联
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@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)):
|
||||
"""聚合A: 写KPI值 + 自动跑该KPI预警检查(Agent一次调用,免自拼check-all)
|
||||
body: {kpi_id, actual_value, period?, entity_id?, run_check?}"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
actual_value = data.get("actual_value")
|
||||
period = data.get("period")
|
||||
entity_id = int(data.get("entity_id") or 1)
|
||||
run_check = bool(data.get("run_check", True))
|
||||
if not kpi_id or actual_value is None:
|
||||
raise HTTPException(400, "kpi_id 和 actual_value 必填")
|
||||
# 校验 KPI 归属(多租户)
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
period = period or datetime.now().strftime("%Y-%m")
|
||||
|
||||
# ① 写值(upsert)
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
|
||||
if existing:
|
||||
existing.actual_value = float(actual_value)
|
||||
existing.source_type = "bot"
|
||||
kv = existing
|
||||
else:
|
||||
kv = KPIValue(kpi_id=kpi_id, entity_id=entity_id, period=period,
|
||||
actual_value=float(actual_value), source_type="bot", data_status="verified")
|
||||
db.add(kv)
|
||||
db.commit()
|
||||
db.refresh(kv)
|
||||
|
||||
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
|
||||
alerts = []
|
||||
if run_check:
|
||||
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
|
||||
AlertRule.enabled == 1).all()
|
||||
for rule in rules:
|
||||
try:
|
||||
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
value = float(actual_value)
|
||||
if rule.rule_type == "static":
|
||||
level, msg = _check_static(value, params, kpi)
|
||||
elif rule.rule_type == "dynamic":
|
||||
level, msg = _check_dynamic(kpi_id, value, params, db)
|
||||
elif rule.rule_type == "trend_up":
|
||||
level, msg = _check_trend(kpi_id, value, "up", params, db)
|
||||
elif rule.rule_type == "trend_down":
|
||||
level, msg = _check_trend(kpi_id, value, "down", params, db)
|
||||
else:
|
||||
continue
|
||||
if level and level != "green":
|
||||
dup = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
|
||||
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
|
||||
if not dup:
|
||||
db.add(KPIAlert(kpi_id=kpi_id, kpi_value_id=kv.id, alert_level=level,
|
||||
alert_message=msg, status="pending", alert_type="bot"))
|
||||
alerts.append({"rule_id": rule.id, "rule_type": rule.rule_type,
|
||||
"level": level, "message": msg})
|
||||
except Exception as e:
|
||||
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
|
||||
db.commit()
|
||||
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
|
||||
"period": period, "alerts": alerts, "status": "ok"}
|
||||
|
||||
|
||||
@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)):
|
||||
"""聚合B: 创建KPI + 关联战略地图 + 批量因果链(Agent建KPI标准动作)
|
||||
body: {kpi_code, kpi_name, dimension, entity_id?, target_value?, unit?, link_map_id?, link_causality?}"""
|
||||
entity_id = int(data.get("entity_id") or 1)
|
||||
from app.api.kpis import _validate_kpi_data, apply_calc_type_inference
|
||||
kpi_data = {k: v for k, v in data.items() if k not in ("entity_id", "link_map_id", "link_causality")}
|
||||
|
||||
# ① 创建KPI(编码唯一 + 治理校验 + 强制企业)
|
||||
code = kpi_data.get("kpi_code", "")
|
||||
if not code:
|
||||
raise HTTPException(400, "kpi_code 必填")
|
||||
if db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first():
|
||||
raise HTTPException(400, f"KPI编码 {code} 已存在")
|
||||
errs = _validate_kpi_data(kpi_data, db=db, is_update=False)
|
||||
if errs:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
kpi_data["entity_id"] = entity_id
|
||||
kpi_data = apply_calc_type_inference(kpi_data)
|
||||
kpi = KPIDefinition(**kpi_data)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
|
||||
# ② 关联战略地图
|
||||
link_map_id = data.get("link_map_id")
|
||||
if link_map_id:
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == link_map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if m:
|
||||
kpi.map_id = link_map_id
|
||||
db.commit()
|
||||
|
||||
# ③ 批量因果链(源=新KPI → 目标列表)
|
||||
links = []
|
||||
for c in data.get("link_causality") or []:
|
||||
tgt = c.get("target_kpi_id")
|
||||
if not tgt or int(tgt) == kpi.id:
|
||||
continue
|
||||
tgt_kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == int(tgt), KPIDefinition.entity_id == entity_id).first()
|
||||
if not tgt_kpi:
|
||||
continue
|
||||
if db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == kpi.id,
|
||||
KPICausality.target_kpi_id == int(tgt)).first():
|
||||
continue
|
||||
db.add(KPICausality(source_kpi_id=kpi.id, target_kpi_id=int(tgt),
|
||||
strength=c.get("strength", 0.5), lag_months=c.get("lag_months", 1),
|
||||
direction=c.get("direction", "positive")))
|
||||
links.append({"source": kpi.kpi_code, "target": tgt_kpi.kpi_code,
|
||||
"strength": c.get("strength", 0.5)})
|
||||
db.commit()
|
||||
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "map_id": link_map_id,
|
||||
"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,
|
||||
ActionPlan, Entity,
|
||||
)
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge_v2")
|
||||
|
||||
@@ -279,6 +280,7 @@ ALERT_THRESHOLDS = {
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.post("/mpm-result")
|
||||
@risk_level("L3")
|
||||
def receive_mpm_result(
|
||||
data: dict,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
@@ -404,6 +406,7 @@ def receive_mpm_result(
|
||||
|
||||
|
||||
@router.post("/kpi-result")
|
||||
@risk_level("L2")
|
||||
def push_kpi_result(
|
||||
data: dict,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
@@ -521,6 +524,7 @@ def push_kpi_result(
|
||||
|
||||
|
||||
@router.post("/verify/{action_plan_id}")
|
||||
@risk_level("L2")
|
||||
def verify_action_plan(
|
||||
action_plan_id: int,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
@@ -528,7 +532,14 @@ def verify_action_plan(
|
||||
):
|
||||
"""
|
||||
验证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
|
||||
2. 读取关联KPI的当前值
|
||||
3. 按condition校验
|
||||
@@ -599,6 +610,7 @@ def verify_action_plan(
|
||||
|
||||
|
||||
@router.get("/verify/{action_plan_id}/history")
|
||||
@risk_level("L1")
|
||||
def verify_history(
|
||||
action_plan_id: int,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.database import get_db
|
||||
from app.models import ActionPlan
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
logger = logging.getLogger("cma.iron_law")
|
||||
|
||||
@@ -150,6 +151,7 @@ def _query_action_plan_verify(db: Session):
|
||||
# ═══════════════ 端点 ═══════════════
|
||||
|
||||
@router.get("/iron-law")
|
||||
@risk_level("L1")
|
||||
def get_iron_law_kpis(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -224,6 +226,7 @@ def get_iron_law_kpis(
|
||||
|
||||
|
||||
@router.get("/iron-law/bots")
|
||||
@risk_level("L1")
|
||||
def get_bot_iron_law_ranking(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
@@ -46,6 +47,7 @@ def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
|
||||
|
||||
|
||||
@router.get("")
|
||||
@risk_level("L1")
|
||||
def list_bot_kpis(
|
||||
source: str = Query("finance-bot", description="Bot标识"),
|
||||
period: Optional[str] = None,
|
||||
@@ -132,6 +134,7 @@ def list_bot_kpis(
|
||||
|
||||
|
||||
@router.post("/{kpi_id}/value")
|
||||
@risk_level("L2")
|
||||
def update_bot_kpi_value(
|
||||
kpi_id: int,
|
||||
data: dict,
|
||||
@@ -161,6 +164,7 @@ def update_bot_kpi_value(
|
||||
else:
|
||||
val = KPIValue(
|
||||
kpi_id=kpi_id,
|
||||
entity_id=kpi.entity_id if kpi else None, # 账套隔离 P2
|
||||
period=period,
|
||||
actual_value=actual_value,
|
||||
source_type="manual",
|
||||
|
||||
+780
-62
File diff suppressed because it is too large
Load Diff
+787
-2
@@ -1,8 +1,15 @@
|
||||
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 (资金管理智能体)"""
|
||||
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 + 网银流水导入 (资金管理智能体)"""
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
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 import or_
|
||||
import pandas as pd
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request
|
||||
from app.auth_middleware import require_role
|
||||
@@ -23,18 +30,54 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _aging_bucket(days_late: int) -> str:
|
||||
"""账龄区间(按逾期天数):未到期 / 0-30天 / 30-60天 / 60-90天 / 90天以上"""
|
||||
if days_late <= 0:
|
||||
return "未到期"
|
||||
if days_late <= 30:
|
||||
return "0-30天"
|
||||
if days_late <= 60:
|
||||
return "30-60天"
|
||||
if days_late <= 90:
|
||||
return "60-90天"
|
||||
return "90天以上"
|
||||
|
||||
|
||||
def _plan_dict(p: CashPlan) -> dict:
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
due = p.plan_date.replace(hour=0, minute=0, second=0, microsecond=0) if p.plan_date else today
|
||||
days_late = (today - due).days
|
||||
paid = round(p.paid_amount or 0, 2)
|
||||
balance = round((p.amount or 0) - paid, 2)
|
||||
if p.plan_type == "receive":
|
||||
overdue = p.status == "pending" and due < today
|
||||
if p.status == "pending":
|
||||
aging = _aging_bucket(days_late)
|
||||
elif p.status == "completed":
|
||||
aging = "已结清"
|
||||
else:
|
||||
aging = "已取消"
|
||||
else:
|
||||
overdue = False
|
||||
aging = "—"
|
||||
return {
|
||||
"id": p.id,
|
||||
"entity_id": p.entity_id,
|
||||
"plan_type": p.plan_type,
|
||||
"plan_type_label": "收款" if p.plan_type == "receive" else "付款",
|
||||
"amount": round(p.amount or 0, 2),
|
||||
"paid_amount": paid,
|
||||
"receivable_balance": max(balance, 0),
|
||||
"plan_date": p.plan_date.strftime("%Y-%m-%d") if p.plan_date else "",
|
||||
"counterparty": p.counterparty or "",
|
||||
"description": p.description or "",
|
||||
"status": p.status,
|
||||
"status_label": {"pending": "待执行", "completed": "已完成", "cancelled": "已取消"}.get(p.status, p.status),
|
||||
"owner": p.owner or "",
|
||||
"source": p.source or "manual",
|
||||
"overdue": overdue,
|
||||
"overdue_days": max(days_late, 0) if overdue else 0,
|
||||
"aging_bucket": aging,
|
||||
"completed_at": p.completed_at.strftime("%Y-%m-%d %H:%M") if p.completed_at else None,
|
||||
"created_at": p.created_at.strftime("%Y-%m-%d %H:%M") if p.created_at else None,
|
||||
}
|
||||
@@ -143,6 +186,9 @@ def api_create_plan(request: Request, data: dict, db: Session = Depends(get_db))
|
||||
counterparty=(data.get("counterparty") or "").strip(),
|
||||
description=(data.get("description") or "").strip(),
|
||||
status=data.get("status", "pending"),
|
||||
owner=(data.get("owner") or "").strip() or None,
|
||||
source=(data.get("source") or "manual").strip(),
|
||||
paid_amount=float(data.get("paid_amount") or 0),
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
@@ -175,6 +221,12 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
plan.counterparty = (data["counterparty"] or "").strip()
|
||||
if "description" in data:
|
||||
plan.description = (data["description"] or "").strip()
|
||||
if "owner" in data:
|
||||
plan.owner = (data["owner"] or "").strip() or None
|
||||
if "source" in data:
|
||||
plan.source = (data["source"] or "manual").strip()
|
||||
if "paid_amount" in data:
|
||||
plan.paid_amount = float(data["paid_amount"] or 0)
|
||||
if "status" in data:
|
||||
plan.status = data["status"]
|
||||
if data["status"] == "completed" and not plan.completed_at:
|
||||
@@ -183,6 +235,15 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
plan.completed_at = None
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
# 完成收款时联动:消除到期未收款预警 + 催收行动 + F_AR_DAYS
|
||||
if plan.plan_type == "receive" and plan.status == "completed":
|
||||
try:
|
||||
_resolve_plan_alerts(db, plan.entity_id, plan.id, plan.paid_amount or plan.amount)
|
||||
_sync_collection_action_plan(db, plan.entity_id)
|
||||
_update_ar_days_kpi(db, plan.entity_id)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"完成收款联动失败: {e}")
|
||||
return {"message": "计划已更新", "data": _plan_dict(plan)}
|
||||
|
||||
|
||||
@@ -205,8 +266,18 @@ def api_complete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
raise HTTPException(404, "计划不存在")
|
||||
plan.status = "completed"
|
||||
plan.completed_at = datetime.now()
|
||||
if plan.plan_type == "receive" and not plan.paid_amount:
|
||||
plan.paid_amount = plan.amount
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
if plan.plan_type == "receive":
|
||||
try:
|
||||
_resolve_plan_alerts(db, plan.entity_id, plan.id, plan.paid_amount or plan.amount)
|
||||
_sync_collection_action_plan(db, plan.entity_id)
|
||||
_update_ar_days_kpi(db, plan.entity_id)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"完成收款联动失败: {e}")
|
||||
return {"message": "已标记完成", "data": _plan_dict(plan)}
|
||||
|
||||
|
||||
@@ -327,3 +398,717 @@ def api_cash_alert_status(entity_id: int = Depends(get_entity_id), db: Session =
|
||||
"min_cash_date": result["min_cash_date"],
|
||||
"summary": result["summary"],
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 5. 应收催收闭环 — 催收视图 / 回款登记 / 博海数据录入
|
||||
# 唯一应收载体:cash_plans(plan_type=receive)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
# 博海应收款汇总表(2026H1期末余额,按业务员)— 录入源数据
|
||||
# 来源: 应收款汇总表(业务员+客户).xlsx / bohai_comprehensive_report.md
|
||||
BOHAI_AR_DATA = [
|
||||
# (业务员, 期末余额元, 业务说明)
|
||||
("董均国", 387676, "IT业务-重点催收"),
|
||||
("蒋亚文", 529786, "IT业务-重点催收"),
|
||||
("陈艳", 89949, "IT业务"),
|
||||
("李亚玲", 92450, "IT业务"),
|
||||
("李巧玲", 45006, "IT业务"),
|
||||
("贾妮", 27820, "IT业务"),
|
||||
("王平安", 31298, "IT业务"),
|
||||
("任富海", 26978, "IT业务"),
|
||||
("其他", 6559, "IT业务"),
|
||||
("王婧", 1065000, "酣客酒类(独立核算)"),
|
||||
]
|
||||
|
||||
|
||||
def _resolve_plan_alerts(db: Session, entity_id: int, plan_id: int, amount: float) -> int:
|
||||
"""回款登记后自动消除该计划的【到期未收款】预警(kpi_alerts.alert_type=cash_plan)"""
|
||||
from app.models import KPIAlert
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.alert_type == "cash_plan",
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).all()
|
||||
resolved = 0
|
||||
for a in alerts:
|
||||
try:
|
||||
sug = json.loads(a.suggestion or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if sug.get("plan_id") == plan_id:
|
||||
a.status = "resolved"
|
||||
a.resolution = f"回款登记+{amount:.2f}万,系统自动消除"
|
||||
a.resolved_at = datetime.now()
|
||||
resolved += 1
|
||||
if resolved:
|
||||
db.commit()
|
||||
return resolved
|
||||
|
||||
|
||||
def _sync_collection_action_plan(db: Session, entity_id: int):
|
||||
"""逾期应收 → 『应收账款催收行动』联动:有逾期→in_progress,全部结清→completed"""
|
||||
from app.models import ActionPlan
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
plans = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.plan_type == "receive",
|
||||
).all()
|
||||
pending = [p for p in plans if p.status == "pending"]
|
||||
overdue = [p for p in pending if p.plan_date < today]
|
||||
total_amount = sum(p.amount or 0 for p in plans)
|
||||
paid_amount = sum(p.paid_amount or 0 for p in plans)
|
||||
progress = round(paid_amount / total_amount * 100) if total_amount > 0 else 0
|
||||
if overdue:
|
||||
new_status = "in_progress"
|
||||
elif not pending:
|
||||
new_status = "completed"
|
||||
progress = 100
|
||||
else:
|
||||
new_status = "in_progress"
|
||||
|
||||
ap = db.query(ActionPlan).filter(
|
||||
ActionPlan.title.like("%催收%"),
|
||||
ActionPlan.status != "cancelled",
|
||||
).order_by(ActionPlan.id.asc()).first()
|
||||
if not ap:
|
||||
return
|
||||
changed = False
|
||||
if ap.status != new_status:
|
||||
ap.status = new_status
|
||||
changed = True
|
||||
if ap.progress is None or progress > (ap.progress or 0):
|
||||
ap.progress = progress
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
logger.info(f"催收行动#{ap.id} 联动: status={new_status} progress={progress}%")
|
||||
|
||||
|
||||
def _update_ar_days_kpi(db: Session, entity_id: int):
|
||||
"""联动F_AR_DAYS(应收周转天数KPI)— 按当前应收余额重算当期值 = 应收余额/月营收×30"""
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code == "F_AR_DAYS",
|
||||
).first()
|
||||
if not kpi:
|
||||
return
|
||||
plans = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.plan_type == "receive",
|
||||
CashPlan.status == "pending",
|
||||
).all()
|
||||
ar_balance_wan = round(sum((p.amount or 0) - (p.paid_amount or 0) for p in plans), 2)
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
monthly_rev = 100.0 # 缺省月营收(万元)
|
||||
rev_kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code == "F_REVENUE",
|
||||
).first()
|
||||
if rev_kpi:
|
||||
val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == rev_kpi.id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if val and val.actual_value:
|
||||
monthly_rev = float(val.actual_value)
|
||||
ar_days = round(ar_balance_wan / monthly_rev * 30, 1) if monthly_rev > 0 else None
|
||||
if ar_days is None:
|
||||
return
|
||||
remark = f"应收催收闭环联动: 应收余额{ar_balance_wan:.2f}万/月营收{monthly_rev:.1f}万×30天"
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if existing:
|
||||
existing.actual_value = ar_days
|
||||
existing.source_type = "cash_plan"
|
||||
existing.remark = remark
|
||||
existing.calculated_at = datetime.now()
|
||||
else:
|
||||
db.add(KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=ar_days,
|
||||
source_type="cash_plan",
|
||||
data_status="calculated",
|
||||
remark=remark,
|
||||
))
|
||||
db.commit()
|
||||
logger.info(f"F_AR_DAYS联动更新: {period} = {ar_days}天 (应收{ar_balance_wan}万)")
|
||||
|
||||
|
||||
@router.get("/receivables")
|
||||
def api_receivables(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
owner: str = Query(None, description="按负责人/业务员筛选"),
|
||||
status: str = Query(None, description="pending/completed/cancelled/overdue"),
|
||||
aging: str = Query(None, description="账龄: 未到期/0-30天/30-60天/60-90天/90天以上/已结清"),
|
||||
keyword: str = Query(None, description="客户/说明关键字"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选"""
|
||||
query = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.plan_type == "receive",
|
||||
)
|
||||
if owner:
|
||||
query = query.filter(CashPlan.owner == owner)
|
||||
if keyword:
|
||||
kw = f"%{keyword}%"
|
||||
query = query.filter(or_(CashPlan.counterparty.like(kw), CashPlan.description.like(kw)))
|
||||
|
||||
plans = query.order_by(CashPlan.plan_date.asc(), CashPlan.id.asc()).all()
|
||||
items = []
|
||||
total_amount = total_paid = 0.0
|
||||
pending_balance = 0.0
|
||||
overdue_count = 0
|
||||
overdue_amount = 0.0
|
||||
aging_amount = {}
|
||||
completed_amount = 0.0
|
||||
for p in plans:
|
||||
d = _plan_dict(p)
|
||||
if status:
|
||||
if status == "overdue":
|
||||
if not d["overdue"]:
|
||||
continue
|
||||
elif d["status"] != status:
|
||||
continue
|
||||
if aging and d["aging_bucket"] != aging:
|
||||
continue
|
||||
items.append(d)
|
||||
total_amount += d["amount"]
|
||||
total_paid += d["paid_amount"]
|
||||
if d["status"] == "pending":
|
||||
pending_balance += d["receivable_balance"]
|
||||
if d["overdue"]:
|
||||
overdue_count += 1
|
||||
overdue_amount += d["receivable_balance"]
|
||||
if d["status"] == "pending":
|
||||
aging_amount[d["aging_bucket"]] = aging_amount.get(d["aging_bucket"], 0) + d["receivable_balance"]
|
||||
elif d["status"] == "completed":
|
||||
completed_amount += d["amount"]
|
||||
|
||||
owners = [r[0] for r in db.query(CashPlan.owner).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.plan_type == "receive",
|
||||
CashPlan.owner.isnot(None),
|
||||
CashPlan.owner != "",
|
||||
).distinct().order_by(CashPlan.owner.asc()).all()]
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"total": len(items),
|
||||
"owners": owners,
|
||||
"summary": {
|
||||
"total_amount": round(total_amount, 2),
|
||||
"total_paid": round(total_paid, 2),
|
||||
"total_balance": round(total_amount - total_paid, 2),
|
||||
"pending_balance": round(pending_balance, 2),
|
||||
"overdue_count": overdue_count,
|
||||
"overdue_amount": round(overdue_amount, 2),
|
||||
"completed_amount": round(completed_amount, 2),
|
||||
"aging": {k: round(v, 2) for k, v in sorted(aging_amount.items(), key=lambda x: x[0])},
|
||||
},
|
||||
"data": items,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/receivables/{plan_id}/payment")
|
||||
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS"""
|
||||
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "应收计划不存在")
|
||||
if plan.plan_type != "receive":
|
||||
raise HTTPException(400, "仅应收(receive)计划支持回款登记")
|
||||
if plan.status == "cancelled":
|
||||
raise HTTPException(400, "已取消的计划不能登记回款")
|
||||
amount = float(data.get("amount", 0))
|
||||
if amount <= 0:
|
||||
raise HTTPException(400, "回款金额必须大于0")
|
||||
paid_date_str = str(data.get("paid_date") or "")[:10]
|
||||
if not paid_date_str:
|
||||
paid_date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
try:
|
||||
paid_date = datetime.strptime(paid_date_str, "%Y-%m-%d")
|
||||
except Exception:
|
||||
raise HTTPException(400, "paid_date格式应为YYYY-MM-DD")
|
||||
|
||||
balance = round((plan.amount or 0) - (plan.paid_amount or 0), 2)
|
||||
if amount > balance + 1e-9:
|
||||
raise HTTPException(400, f"回款金额{amount}万超过应收余额{balance}万")
|
||||
|
||||
plan.paid_amount = round((plan.paid_amount or 0) + amount, 2)
|
||||
if plan.paid_amount >= (plan.amount or 0) - 1e-9:
|
||||
plan.paid_amount = plan.amount
|
||||
plan.status = "completed"
|
||||
plan.completed_at = paid_date
|
||||
else:
|
||||
plan.status = "pending"
|
||||
plan.completed_at = None
|
||||
db.commit()
|
||||
|
||||
entity_id = plan.entity_id
|
||||
resolved = _resolve_plan_alerts(db, entity_id, plan.id, amount)
|
||||
_sync_collection_action_plan(db, entity_id)
|
||||
_update_ar_days_kpi(db, entity_id)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
logger.info(f"回款登记 #{plan.id} {plan.counterparty or ''} +{amount}万 → {plan.status}, 消除预警{resolved}条")
|
||||
return {
|
||||
"message": "回款登记成功",
|
||||
"resolved_alerts": resolved,
|
||||
"data": _plan_dict(plan),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import/bohai-ar")
|
||||
def api_import_bohai_ar(data: dict = None, entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
|
||||
"""录入博海应收汇总表数据(¥2.3M, 按业务员)到cash_plans — 幂等,重复调用不重复导入"""
|
||||
if data is None:
|
||||
data = {}
|
||||
existing = db.query(CashPlan).filter(
|
||||
CashPlan.source == "bohai_ar",
|
||||
CashPlan.entity_id == entity_id,
|
||||
).count()
|
||||
if existing and not data.get("force"):
|
||||
return {"message": "博海应收已录入,未重复导入", "existing": existing, "imported": 0}
|
||||
if existing:
|
||||
db.query(CashPlan).filter(
|
||||
CashPlan.source == "bohai_ar",
|
||||
CashPlan.entity_id == entity_id,
|
||||
).delete()
|
||||
db.commit()
|
||||
plan_date = datetime(2026, 6, 30) # 2026H1期末余额基准日
|
||||
imported = 0
|
||||
total_wan = 0.0
|
||||
for sp, yuan, note in BOHAI_AR_DATA:
|
||||
wan = round(yuan / 10000, 2)
|
||||
total_wan += wan
|
||||
db.add(CashPlan(
|
||||
entity_id=entity_id,
|
||||
plan_type="receive",
|
||||
amount=wan,
|
||||
plan_date=plan_date,
|
||||
counterparty=sp,
|
||||
description=f"博海应收款汇总表2026H1期末余额({note})",
|
||||
status="pending",
|
||||
owner=sp,
|
||||
source="bohai_ar",
|
||||
paid_amount=0,
|
||||
))
|
||||
imported += 1
|
||||
db.commit()
|
||||
# 触发预警检查生成到期未收款预警 + 联动催收行动/F_AR_DAYS
|
||||
try:
|
||||
check_cash_alerts(db, entity_id=entity_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"导入后预警检查失败: {e}")
|
||||
_sync_collection_action_plan(db, entity_id)
|
||||
_update_ar_days_kpi(db, entity_id)
|
||||
db.commit()
|
||||
return {
|
||||
"message": "博海应收已录入",
|
||||
"imported": imported,
|
||||
"total_amount_wan": round(total_wan, 2),
|
||||
"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": "已忽略"}
|
||||
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.utils.cost_engine import (
|
||||
calc_product_variance, get_cost_overview, get_cost_breakdown,
|
||||
@@ -22,9 +23,10 @@ router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
|
||||
@router.get("/standard-costs")
|
||||
def list_standard_costs(product_code: Optional[str] = Query(None),
|
||||
cost_type: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查询标准成本卡片"""
|
||||
query = db.query(StandardCost).filter(StandardCost.status == "active")
|
||||
query = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id)
|
||||
if product_code:
|
||||
query = query.filter(StandardCost.product_code == product_code)
|
||||
if cost_type:
|
||||
@@ -86,9 +88,10 @@ def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)):
|
||||
@router.get("/actual-costs")
|
||||
def list_actual_costs(period: Optional[str] = Query(None),
|
||||
product_code: Optional[str] = Query(None),
|
||||
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:
|
||||
query = query.filter(ActualCost.period == period)
|
||||
if product_code:
|
||||
|
||||
@@ -52,6 +52,24 @@ def period_prefix(period_type: str):
|
||||
return str(datetime.now().year)
|
||||
return None
|
||||
|
||||
def kpi_target_by_frequency(k):
|
||||
"""按考核频率返回对应周期的目标值(多粒度改造)
|
||||
monthly/weekly -> target_monthly; quarterly/half_year -> target_quarterly; yearly -> target_yearly
|
||||
兼容: 对应列无值时回退 target_value
|
||||
"""
|
||||
freq = (k.frequency or "monthly").lower()
|
||||
if freq in ("monthly", "weekly"):
|
||||
val = getattr(k, "target_monthly", None)
|
||||
elif freq in ("quarterly", "half_year"):
|
||||
val = getattr(k, "target_quarterly", None)
|
||||
elif freq == "yearly":
|
||||
val = getattr(k, "target_yearly", None)
|
||||
else:
|
||||
val = None
|
||||
if val is None:
|
||||
val = k.target_value
|
||||
return val
|
||||
|
||||
@router.get("/summary")
|
||||
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"),
|
||||
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
@@ -143,6 +161,7 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
||||
result.append({
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
|
||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else None,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
@@ -198,6 +217,7 @@ def get_my_kpis(
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else period_str,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
@@ -251,6 +271,7 @@ def get_finance_analysis(
|
||||
kpi_data.append({
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"unit": k.unit, "target_value": k.target_value,
|
||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
@@ -369,8 +390,9 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
|
||||
def my_dashboard(
|
||||
current_user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
|
||||
"""个人工作台:返回我的KPI、改善行动、待办提醒(账套隔离: 按token企业过滤)"""
|
||||
username = current_user.username
|
||||
name = current_user.name
|
||||
role = current_user.role
|
||||
@@ -384,22 +406,24 @@ def my_dashboard(
|
||||
}
|
||||
preset_codes = ROLE_PRESET_KPIS.get(role, [])
|
||||
|
||||
# 1. 我的KPI(responsible_user匹配用户名或姓名)+ 角色预设
|
||||
# 1. 我的KPI(responsible_user匹配用户名或姓名)+ 角色预设(均按企业隔离)
|
||||
assigned_kpis = db.query(KPIDefinition).filter(
|
||||
or_(
|
||||
KPIDefinition.responsible_user == username,
|
||||
KPIDefinition.responsible_user == name,
|
||||
),
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
assigned_ids = {k.id for k in assigned_kpis}
|
||||
|
||||
# 补充角色预设KPI(去重)
|
||||
# 补充角色预设KPI(去重,按企业隔离)
|
||||
preset_kpis = []
|
||||
if preset_codes:
|
||||
q = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code.in_(preset_codes),
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
)
|
||||
if assigned_ids:
|
||||
q = q.filter(~KPIDefinition.id.in_(assigned_ids))
|
||||
@@ -414,11 +438,22 @@ def my_dashboard(
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
|
||||
actual = latest_v.actual_value if latest_v else None
|
||||
target = k.target_value
|
||||
target = kpi_target_by_frequency(k)
|
||||
level = "gray"
|
||||
if actual is not None and target:
|
||||
ratio = actual / target
|
||||
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
|
||||
# 反向指标(越低越好):费用率/渠补率/应收天数/返利率/成本率/存货天数
|
||||
REVERSE_INDICATORS = {
|
||||
"F_COST_RATIO", "C_REBATE_RATE", "F_AR_DAYS",
|
||||
"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:
|
||||
# 反向:实际≤目标=绿;实际≤目标*1.1=黄;否则红
|
||||
level = "green" if actual <= target else (
|
||||
"yellow" if actual <= target * 1.1 else "red")
|
||||
else:
|
||||
ratio = actual / target
|
||||
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
|
||||
|
||||
kpi_list.append({
|
||||
"id": k.id,
|
||||
@@ -427,20 +462,32 @@ def my_dashboard(
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"target_value": target,
|
||||
"target_monthly": k.target_monthly,
|
||||
"target_quarterly": k.target_quarterly,
|
||||
"target_yearly": k.target_yearly,
|
||||
"frequency": k.frequency,
|
||||
"actual_value": actual,
|
||||
"unit": k.unit,
|
||||
"level": level,
|
||||
"period": latest_v.period if latest_v else None,
|
||||
})
|
||||
|
||||
# 2. 我的改善行动(assignee匹配)
|
||||
# 2. 我的改善行动(assignee匹配;CEO/管理员看全部)
|
||||
from app.models import ActionPlan
|
||||
my_plans = db.query(ActionPlan).filter(
|
||||
or_(
|
||||
ActionPlan.assignee == username,
|
||||
ActionPlan.assignee == name,
|
||||
)
|
||||
).order_by(ActionPlan.updated_at.desc()).all()
|
||||
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(
|
||||
or_(
|
||||
ActionPlan.assignee == username,
|
||||
ActionPlan.assignee == name,
|
||||
),
|
||||
ActionPlan.entity_id == entity_id,
|
||||
).order_by(ActionPlan.updated_at.desc()).all()
|
||||
|
||||
plan_list = []
|
||||
for p in my_plans:
|
||||
|
||||
+17
-6
@@ -39,6 +39,7 @@ async def import_excel(file: UploadFile = File(...),
|
||||
|
||||
from app.models import KPIDefinition
|
||||
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).all()}
|
||||
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).all()} # 账套隔离 P2
|
||||
|
||||
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
|
||||
count = 0
|
||||
@@ -59,6 +60,7 @@ async def import_excel(file: UploadFile = File(...),
|
||||
|
||||
db.add(KPIValue(
|
||||
kpi_id=kid,
|
||||
entity_id=kpi_entity_map.get(kpi_code),
|
||||
period=period,
|
||||
actual_value=float(value),
|
||||
source_type="excel",
|
||||
@@ -80,11 +82,11 @@ async def import_excel(file: UploadFile = File(...),
|
||||
# ── 智能导入(BOT自动识别,无需手动映射) ──
|
||||
|
||||
_SMART_MAP = {
|
||||
# KPI编码列匹配模式 → 标准kpi_code
|
||||
# KPI名称/编码列匹配模式(顺序重要:名称类列优先,避免科目编码被当名称)
|
||||
"kpi_code_patterns": [
|
||||
re.compile(r'^(kpi_?code|指标编码|编码)$', re.I),
|
||||
re.compile(r'^(科目|项目|账户|报表项目|项目名称)$'),
|
||||
re.compile(r'^(指标名称?|kpi名称?|name)$', re.I),
|
||||
re.compile(r'^(科目名称|项目名称|指标名称?|kpi名称?|name|名称)$', re.I),
|
||||
re.compile(r'^(科目编码|科目代码|kpi_?code|指标编码|编码)$', re.I),
|
||||
re.compile(r'^(科目|项目|账户|报表项目)$'),
|
||||
],
|
||||
# 期间列匹配
|
||||
"period_patterns": [
|
||||
@@ -218,11 +220,19 @@ async def import_excel_smart(
|
||||
skipped_rows = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
raw_kpi = str(row.get(kpi_col, "")).strip()
|
||||
raw_kpi_raw = row.get(kpi_col, "")
|
||||
raw_kpi = str(raw_kpi_raw).strip()
|
||||
raw_val = row.get(value_col)
|
||||
raw_period = str(row.get(period_col, period or "")).strip() if period_col else (period or "")
|
||||
|
||||
if not raw_kpi or pd.isna(raw_val):
|
||||
# 名称防护:NaN/空/None 或 纯数字(疑似科目编码被误当名称)→ 跳过,避免创建垃圾KPI
|
||||
if raw_kpi.lower() in ("nan", "none") or not raw_kpi:
|
||||
skipped_rows.append(f"第{idx+2}行: KPI名称为空")
|
||||
continue
|
||||
if re.fullmatch(r"\d+(\.\d+)?", raw_kpi):
|
||||
skipped_rows.append(f"第{idx+2}行: KPI名称疑似科目编码「{raw_kpi}」,跳过")
|
||||
continue
|
||||
if pd.isna(raw_val):
|
||||
skipped_rows.append(f"第{idx+2}行: 缺数据")
|
||||
continue
|
||||
if not raw_period:
|
||||
@@ -290,6 +300,7 @@ async def import_excel_smart(
|
||||
|
||||
db.add(KPIValue(
|
||||
kpi_id=kpis[kpi_code].id,
|
||||
entity_id=kpis[kpi_code].entity_id, # 账套隔离 P2
|
||||
period=raw_period,
|
||||
actual_value=val,
|
||||
source_type="excel",
|
||||
|
||||
@@ -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 sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from sqlalchemy import func, and_, text
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
@@ -322,3 +322,255 @@ def quality_stats(db: Session = Depends(get_db)):
|
||||
"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": "派生规则已删除"}
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
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 ExpenseRule, ExpenseReimbursement, OperationLog
|
||||
|
||||
@@ -120,6 +121,7 @@ def list_rules(
|
||||
expense_type: str = Query(None),
|
||||
status: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询费用规则列表"""
|
||||
@@ -397,6 +399,7 @@ def list_reimbursements(
|
||||
applicant: str = Query(None),
|
||||
keyword: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""KPI因果链建模 — 任务2
|
||||
KPI间因果关系网络 + 模拟推演
|
||||
KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
@@ -8,8 +9,14 @@ from typing import Optional
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
||||
from app.services.causality_verification import (
|
||||
ALL_STATUSES,
|
||||
ALL_SOURCE_TYPES,
|
||||
STATUS_PENDING,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kpi-causality")
|
||||
|
||||
@@ -28,9 +35,9 @@ def _to_dict(obj):
|
||||
# ============================================================
|
||||
|
||||
@router.get("/full-network")
|
||||
def get_full_network(db: Session = Depends(get_db)):
|
||||
"""获取全局因果网络数据(用于力导向图)"""
|
||||
edges = db.query(KPICausality).all()
|
||||
def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)"""
|
||||
edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
|
||||
node_ids = set()
|
||||
edge_list = []
|
||||
for e in edges:
|
||||
@@ -60,14 +67,19 @@ def get_full_network(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}/network")
|
||||
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""获取KPI的因果网络(上游驱动 + 下游影响)"""
|
||||
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业"""
|
||||
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不存在") # 跨企业不暴露存在性
|
||||
|
||||
# 上游(指向当前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 = []
|
||||
for c in upstream:
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
@@ -77,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,
|
||||
"strength": c.strength, "lag_months": c.lag_months,
|
||||
"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指向的因果)
|
||||
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 = []
|
||||
for c in downstream:
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
@@ -90,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,
|
||||
"strength": c.strength, "lag_months": c.lag_months,
|
||||
"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 {
|
||||
@@ -100,7 +121,7 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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的影响
|
||||
Body: { kpi_id: int, new_value: float, period: str }
|
||||
"""
|
||||
@@ -114,6 +135,8 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not source_kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if source_kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 获取当前值
|
||||
current_value = None
|
||||
@@ -141,9 +164,10 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
continue
|
||||
visited.add(current_kpi_id)
|
||||
|
||||
# 查找从current_kpi_id出发的下游因果链
|
||||
# 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
|
||||
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()
|
||||
|
||||
for edge in downstream:
|
||||
@@ -211,14 +235,18 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
def list_causalities(
|
||||
source_kpi_id: Optional[int] = None,
|
||||
target_kpi_id: Optional[int] = None,
|
||||
verify_status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取因果链列表"""
|
||||
query = db.query(KPICausality)
|
||||
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选)"""
|
||||
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
|
||||
if source_kpi_id:
|
||||
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
|
||||
if 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()
|
||||
|
||||
result = []
|
||||
@@ -234,11 +262,51 @@ def list_causalities(
|
||||
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}")
|
||||
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()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
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()
|
||||
@@ -249,7 +317,7 @@ def get_causality(causality_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("")
|
||||
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")
|
||||
target_id = data.get("target_kpi_id")
|
||||
if not source_id or not target_id:
|
||||
@@ -260,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()
|
||||
if not src or not tgt:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if src.entity_id != tgt.entity_id:
|
||||
raise HTTPException(400, "源KPI和目标KPI必须属于同一企业")
|
||||
|
||||
existing = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == source_id,
|
||||
@@ -268,40 +338,91 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
|
||||
if existing:
|
||||
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(
|
||||
entity_id=src.entity_id,
|
||||
source_kpi_id=source_id,
|
||||
target_kpi_id=target_id,
|
||||
strength=data.get("strength", 0.5),
|
||||
lag_months=data.get("lag_months", 1),
|
||||
formula=data.get("formula"),
|
||||
direction=data.get("direction", "positive"),
|
||||
source_type=source_type,
|
||||
verify_status=STATUS_PENDING,
|
||||
)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
db.add(OperationLog(action="create", target_type="kpi_causality",
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code}"))
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})"))
|
||||
db.commit()
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@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()
|
||||
if not c:
|
||||
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 == "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])
|
||||
# 修改链定义后,验证状态回到待检(定义变了旧结论失效)
|
||||
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.refresh(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}")
|
||||
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()
|
||||
if c:
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
db.delete(c)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
@@ -27,7 +27,9 @@ router = APIRouter(
|
||||
# 维度 → 编码前缀
|
||||
DIM_PREFIX = {"finance": "F", "customer": "C", "process": "P", "learning": "L"}
|
||||
# 历史遗留兼容前缀: EXT_ = 科目余额表导入的财务科目KPI(仅限 finance 维度)
|
||||
LEGACY_PREFIX_DIM = {"EXT": "finance"}
|
||||
LEGACY_PREFIX_DIM = {"EXT": "finance", "FB": "process"}
|
||||
# 品牌级业务KPI前缀(跨维度合法,2026-08-21 治理审计适配: BH_ = 博海业务KPI系列)
|
||||
BRAND_PREFIXES = ("BH",)
|
||||
VALID_LEVELS = ("strategic", "operational")
|
||||
# 视为"未完善"的占位符值
|
||||
PLACEHOLDERS = ("待补充", "待指定", "待完善", "待定", "暂无", "TBD", "tbd", "-", "--", "N/A", "n/a")
|
||||
@@ -37,6 +39,7 @@ META_FIELDS = [
|
||||
("formula", "计算公式"),
|
||||
("data_source", "数据来源"),
|
||||
("data_owner", "数据责任人"),
|
||||
("kpi_name", "KPI名称"),
|
||||
]
|
||||
|
||||
|
||||
@@ -106,7 +109,9 @@ def validate_kpi_payload(
|
||||
prefix = code.split("_")[0] if "_" in code else code
|
||||
if prefix not in ("F", "C", "P", "L"):
|
||||
# 兼容历史遗留 EXT_ 前缀(科目余额表导入的财务科目KPI,仅限finance维度)
|
||||
if not (LEGACY_PREFIX_DIM.get(prefix) and dimension == LEGACY_PREFIX_DIM[prefix]):
|
||||
# 兼容 FB_ 前缀(财务Bot KPI,仅限process维度)与 BH_ 品牌前缀(跨维度合法)
|
||||
if not (LEGACY_PREFIX_DIM.get(prefix) and dimension == LEGACY_PREFIX_DIM[prefix]) \
|
||||
and prefix not in BRAND_PREFIXES:
|
||||
add(3, "kpi_code", f"编码前缀不符: {code} 应以F_/C_/P_/L_开头")
|
||||
elif dimension and DIM_PREFIX.get(dimension) and prefix != DIM_PREFIX[dimension]:
|
||||
expected = DIM_PREFIX[dimension]
|
||||
|
||||
+275
-10
@@ -9,7 +9,7 @@ import json
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy
|
||||
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
|
||||
|
||||
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',
|
||||
'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):
|
||||
@@ -474,12 +475,141 @@ def get_kpi_causality_chain(
|
||||
# 动态路由(必须在静态路由之后)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/{kpi_id}")
|
||||
def get_kpi(kpi_id: int, db: Session = Depends(get_db)):
|
||||
@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不存在")
|
||||
return kpi_to_dict(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}")
|
||||
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()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 账套隔离: 禁止跨企业读取 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "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):
|
||||
@@ -488,16 +618,115 @@ def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] =
|
||||
return kpi_issues_message(issues)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# KPI多粒度目标:指标类型推断 + 周期目标派生(docs/kpi-design-rule.md 落地)
|
||||
# 规则:累计型 月×3=季、月×12=年(季×4=年);比率型 季/年沿用基准(可手调)
|
||||
# 派生为"虚拟展示值":DB只存用户手填真值,API返回时补派生值+derived标记
|
||||
# ════════════════════════════════════════════════════════════
|
||||
RATIO_NAME_HINTS = ['率', '比', '满意度', '周转', '时长', '周期', '天数', '指数', 'NPS', 'LTV', 'CAC',
|
||||
'份额', '集中度', '响应', '完成', '达成', '人均', '单价', '净推荐', '覆盖', '保留',
|
||||
'复购', '转介绍', '投诉', '合规', '认证', '掌握', '胜任', '认知', '采纳', '引用',
|
||||
'复用', '一致性', '准确', '间隙', '时效', '及时']
|
||||
ACCUM_NAME_HINTS = ['营收', '收入', '利润', '净利', '销售', '客户数', '新客', '新增', '产量', '销量',
|
||||
'金额', '现金流', '回款', '毛利额', '产值', '储备', '数量', '篇数', '报告产出',
|
||||
'提案', '发现数', '知识沉淀', '招待费']
|
||||
RATIO_UNIT_HINTS = ['%', '倍', '天', '分', '小时', '分钟']
|
||||
ACCUM_UNIT_HINTS = ['万元', '元', '个', '件', '人', '篇', '份', '万']
|
||||
|
||||
|
||||
def infer_calc_type(kpi_code: str = "", kpi_name: str = "", unit: str = "") -> str:
|
||||
"""推断指标类型: accumulate累计(可乘) / ratio比率(不可乘)。名称关键词优先于单位"""
|
||||
n = (kpi_name or "") + " " + (kpi_code or "")
|
||||
u = unit or ""
|
||||
if any(k in n for k in RATIO_NAME_HINTS):
|
||||
return "ratio"
|
||||
if any(k in n for k in ACCUM_NAME_HINTS):
|
||||
return "accumulate"
|
||||
if u in RATIO_UNIT_HINTS or u.startswith("小时"):
|
||||
return "ratio"
|
||||
if u in ACCUM_UNIT_HINTS:
|
||||
return "accumulate"
|
||||
return "ratio" # 兜底比率(率值不能乘,更安全)
|
||||
|
||||
|
||||
def derive_cycle_targets(kpi) -> dict:
|
||||
"""按指标类型派生月/季/年目标(虚拟值,不落库)。
|
||||
返回: {"derived": {monthly/quarterly/yearly: 显示值}, "flags": {monthly/quarterly/yearly: 是否派生}}
|
||||
"""
|
||||
calc_type = (getattr(kpi, "target_calc_type", None) or infer_calc_type(
|
||||
kpi.kpi_code or "", kpi.kpi_name or "", kpi.unit or "")).lower()
|
||||
m = kpi.target_monthly
|
||||
q = kpi.target_quarterly
|
||||
y = kpi.target_yearly
|
||||
freq = (kpi.frequency or "monthly").lower()
|
||||
|
||||
# 基准值(考核周期优先,回退 target_value)
|
||||
base = None
|
||||
if freq == "yearly":
|
||||
base = y
|
||||
elif freq in ("quarterly", "half_year"):
|
||||
base = q
|
||||
elif freq in ("monthly", "weekly"):
|
||||
base = m
|
||||
if base is None:
|
||||
base = kpi.target_value
|
||||
# 无基准值则不派生
|
||||
if base is None:
|
||||
return {"derived": {"monthly": m, "quarterly": q, "yearly": y},
|
||||
"flags": {"monthly": False, "quarterly": False, "yearly": False}}
|
||||
|
||||
dm, dq, dy = m, q, y
|
||||
fm, fq, fy = False, False, False
|
||||
if calc_type == "accumulate":
|
||||
# 锚点月值:手填月目标优先;月基准且手填月空时用 target_value 回退
|
||||
anchor_m = dm
|
||||
if anchor_m is None and base is not None and freq in ("monthly", "weekly"):
|
||||
anchor_m = base
|
||||
if anchor_m is not None:
|
||||
if dm is None:
|
||||
dm = anchor_m # target_value 回退显示为月基准
|
||||
if dq is None:
|
||||
dq, fq = anchor_m * 3, True
|
||||
if dy is None:
|
||||
dy, fy = anchor_m * 12, True
|
||||
elif dq is not None:
|
||||
# 季基准(累计型):年=季×4;月不反推(避免小数噪声)
|
||||
if dy is None:
|
||||
dy, fy = dq * 4, True
|
||||
else: # ratio:季/年沿用基准,不乘
|
||||
if dq is None:
|
||||
dq, fq = base, True
|
||||
if dy is None:
|
||||
dy, fy = base, True
|
||||
return {"derived": {"monthly": dm, "quarterly": dq, "yearly": dy},
|
||||
"flags": {"monthly": fm, "quarterly": fq, "yearly": fy}}
|
||||
|
||||
|
||||
def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict:
|
||||
"""create/update 前:未显式传 target_calc_type 时按名称/单位推断。
|
||||
infer_missing=False(update场景):仅当用户显式传了空值时推断,未传则保留DB原值"""
|
||||
if "target_calc_type" in data:
|
||||
if not data.get("target_calc_type"):
|
||||
data["target_calc_type"] = infer_calc_type(
|
||||
data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", ""))
|
||||
elif infer_missing:
|
||||
data["target_calc_type"] = infer_calc_type(
|
||||
data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", ""))
|
||||
return data
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
# 检查编码唯一性
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", "")).first()
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", ""), KPIDefinition.entity_id == entity_id).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在")
|
||||
# 数据治理校验(规则1强制拦截)
|
||||
errs = _validate_kpi_data(data, db=db, is_update=False)
|
||||
if errs:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a)
|
||||
data = apply_calc_type_inference(data)
|
||||
kpi = KPIDefinition(**data)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
@@ -506,15 +735,37 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
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}")
|
||||
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
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()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 账套隔离: 禁止跨企业修改 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 数据治理校验(更新时只检查传了但为空的字段)
|
||||
errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True)
|
||||
if errs:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
data.pop("entity_id", None) # 禁止通过update改企业归属
|
||||
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():
|
||||
if hasattr(kpi, k) and v is not None:
|
||||
setattr(kpi, k, v)
|
||||
@@ -524,18 +775,24 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
|
||||
|
||||
|
||||
@router.delete("/{kpi_id}")
|
||||
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def delete_kpi(kpi_id: int, 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()
|
||||
if kpi:
|
||||
# 账套隔离: 禁止跨企业删除 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
kpi.status = "disabled"
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.put("/{kpi_id}/restore")
|
||||
def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def restore_kpi(kpi_id: int, 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()
|
||||
if kpi:
|
||||
# 账套隔离: 禁止跨企业恢复 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
kpi.status = "active"
|
||||
db.commit()
|
||||
return {"message": "已恢复"}
|
||||
@@ -543,6 +800,14 @@ def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
|
||||
def kpi_to_dict(k):
|
||||
d = {c.name: getattr(k, c.name) for c in k.__table__.columns}
|
||||
# 多粒度目标派生:月/季/年显示值 + derived标记(虚拟,不落库)
|
||||
try:
|
||||
der = derive_cycle_targets(k)
|
||||
d["derived_targets"] = der["derived"]
|
||||
d["derived_flags"] = der["flags"]
|
||||
except Exception:
|
||||
d["derived_targets"] = {"monthly": k.target_monthly, "quarterly": k.target_quarterly, "yearly": k.target_yearly}
|
||||
d["derived_flags"] = {"monthly": False, "quarterly": False, "yearly": False}
|
||||
# 附加战略地图信息
|
||||
if k.map_id:
|
||||
from app.database import get_session_local
|
||||
|
||||
+429
-27
@@ -2,9 +2,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import StrategicMap, OperationLog, MapObjective
|
||||
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
||||
@@ -60,12 +62,24 @@ STRATEGIC_MAP_TEMPLATE = [
|
||||
# ── CRUD ────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
def list_maps(db: Session = Depends(get_db)):
|
||||
maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all()
|
||||
def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 账套隔离: 仅当前企业地图 (2026-08-25)
|
||||
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
|
||||
return {"data": [m_to_dict(m, db) for m in maps]}
|
||||
|
||||
@router.post("")
|
||||
def create_map(data: dict, db: Session = Depends(get_db)):
|
||||
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
|
||||
if "dimensions" not in data or data.get("dimensions") is None:
|
||||
data = {**data, "dimensions": [
|
||||
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", "objectives": []},
|
||||
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": []},
|
||||
{"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A", "objectives": []},
|
||||
{"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C", "objectives": []},
|
||||
]}
|
||||
if "canvas_data" not in data or data.get("canvas_data") is None:
|
||||
data = {**data, "canvas_data": {"connections": []}}
|
||||
data["entity_id"] = entity_id # 账套隔离: 强制token企业
|
||||
m = StrategicMap(**data)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
@@ -75,12 +89,13 @@ def create_map(data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/create-with-template")
|
||||
def create_map_with_template(data: dict, db: Session = Depends(get_db)):
|
||||
"""一键创建带四维度模板的战略地图"""
|
||||
def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""一键创建带四维度模板的战略地图(账套隔离: 强制token企业)"""
|
||||
m = StrategicMap(
|
||||
title=data.get("title", "新建战略地图"),
|
||||
version=data.get("version", "v1.0"),
|
||||
status="draft",
|
||||
entity_id=entity_id,
|
||||
dimensions=STRATEGIC_MAP_TEMPLATE,
|
||||
canvas_data={"connections": []},
|
||||
)
|
||||
@@ -92,19 +107,29 @@ def create_map_with_template(data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{map_id}")
|
||||
def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def update_map(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).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
# 账套隔离: 禁止跨企业修改
|
||||
if m.entity_id != entity_id:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
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():
|
||||
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)
|
||||
|
||||
db.commit()
|
||||
# 同步目标到map_objectives表
|
||||
_sync_map_objectives(m, db)
|
||||
# KR完整修复(2026-08-27): 同步 objectives+krs 表
|
||||
_sync_objectives_krs(m, db)
|
||||
|
||||
# ├─ 版本管理: draft → published 时自动创建快照
|
||||
if old_status == "draft" and m.status == "published":
|
||||
@@ -117,25 +142,27 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/{map_id}")
|
||||
def delete_map(map_id: int, db: Session = Depends(get_db)):
|
||||
"""删除战略地图"""
|
||||
def delete_map(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""删除战略地图(账套隔离: 禁止跨企业删除)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
if m.entity_id != entity_id:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
db.delete(m)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.post("/batch-delete")
|
||||
def batch_delete_maps(data: dict, db: Session = Depends(get_db)):
|
||||
"""批量删除战略地图"""
|
||||
def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""批量删除战略地图(账套隔离: 仅本企业地图)"""
|
||||
ids = data.get("ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(400, "请选择要删除的地图")
|
||||
deleted = 0
|
||||
for mid in ids:
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == mid, StrategicMap.entity_id == entity_id).first()
|
||||
if m:
|
||||
db.delete(m)
|
||||
deleted += 1
|
||||
@@ -159,9 +186,9 @@ def _get_connections(m: StrategicMap) -> list:
|
||||
|
||||
|
||||
@router.post("/{map_id}/connections")
|
||||
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
@@ -186,16 +213,22 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
if c.get("from") == from_id and c.get("to") == to_id:
|
||||
raise HTTPException(400, "已存在相同的连线")
|
||||
|
||||
conns.append({"from": from_id, "to": to_id, "style": "solid"})
|
||||
conns.append({
|
||||
"from": from_id,
|
||||
"to": to_id,
|
||||
"style": "solid",
|
||||
"effect": data.get("effect", "positive"),
|
||||
"label": data.get("label", ""),
|
||||
})
|
||||
m.canvas_data["connections"] = conns
|
||||
db.commit()
|
||||
return {"connections": conns}
|
||||
|
||||
|
||||
@router.delete("/{map_id}/connections")
|
||||
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""根据 from/to 删除连线"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
@@ -277,10 +310,12 @@ def _sync_map_objectives(m, db):
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
dims = json.loads(dims)
|
||||
dims = dims or []
|
||||
for dim in dims:
|
||||
for i, obj in enumerate(dim.get("objectives", [])):
|
||||
mo = MapObjective(
|
||||
map_id=m.id,
|
||||
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
|
||||
dimension_key=dim.get("key", ""),
|
||||
name=obj.get("name", ""),
|
||||
description=obj.get("description", ""),
|
||||
@@ -293,13 +328,18 @@ def _sync_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()
|
||||
if not objs:
|
||||
return
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
dims = json.loads(dims)
|
||||
dims = dims or []
|
||||
# 按dimension_key分组
|
||||
from collections import defaultdict
|
||||
grouped = defaultdict(list)
|
||||
@@ -323,24 +363,261 @@ def _merge_map_objectives(m, db):
|
||||
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")
|
||||
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db)):
|
||||
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""战略回顾会:返回目标状态、KPI值、改善行动
|
||||
|
||||
level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI;
|
||||
不传则返回全部KPI(向后兼容)。
|
||||
"""
|
||||
from app.models import KPIDefinition, KPIValue, ActionPlan
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
dims = json.loads(dims)
|
||||
dims = dims or [] # NULL容错
|
||||
|
||||
# 收集所有KPI code
|
||||
all_kpi_codes = set()
|
||||
@@ -349,9 +626,10 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
for code in obj.get("kpis", []):
|
||||
all_kpi_codes.add(code)
|
||||
|
||||
# 查询KPI定义
|
||||
# 查询KPI定义(账套隔离: 同编码跨企业会混入, OpenCode审查#10)
|
||||
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 []
|
||||
kpi_map = {k.kpi_code: k for k in kpi_defs}
|
||||
|
||||
@@ -417,14 +695,24 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
continue
|
||||
lv = latest_values.get(kpi_def.id, {})
|
||||
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%)
|
||||
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
|
||||
level = "gray"
|
||||
if actual is not None and target:
|
||||
ratio = actual / 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
|
||||
if ratio < 0:
|
||||
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
|
||||
if ratio >= 0.9:
|
||||
level = "green"
|
||||
elif ratio >= 0.7:
|
||||
elif ratio >= 0.6:
|
||||
level = "yellow"
|
||||
else:
|
||||
level = "red"
|
||||
@@ -452,6 +740,13 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
"kpis": obj_kpis,
|
||||
"level": worst_level,
|
||||
"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)
|
||||
|
||||
@@ -494,3 +789,110 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
"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)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import MapObjective, StrategicMap, KPIDefinition
|
||||
|
||||
@@ -11,8 +12,11 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"],
|
||||
|
||||
|
||||
@router.get("/{map_id}/objectives")
|
||||
def list_objectives(map_id: int, db: Session = Depends(get_db)):
|
||||
"""获取某地图下的所有目标"""
|
||||
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(
|
||||
MapObjective.map_id == map_id
|
||||
).order_by(MapObjective.sort_order).all()
|
||||
@@ -20,9 +24,9 @@ def list_objectives(map_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{map_id}/objectives")
|
||||
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
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).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = MapObjective(
|
||||
@@ -40,23 +44,30 @@ def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{map_id}/objectives/{obj_id}")
|
||||
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""修改目标"""
|
||||
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(
|
||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||
).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
ALLOWED_OBJ_FIELDS = {"name", "description", "icon", "sort_order", "kpis", "dimension_key"}
|
||||
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)
|
||||
db.commit()
|
||||
return _obj_to_dict(obj)
|
||||
|
||||
|
||||
@router.delete("/{map_id}/objectives/{obj_id}")
|
||||
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
|
||||
"""删除目标"""
|
||||
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(
|
||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||
).first()
|
||||
@@ -68,8 +79,11 @@ def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{map_id}/objectives/sort")
|
||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""批量排序: {"ids": [3, 1, 2]}"""
|
||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""批量排序: {"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", [])
|
||||
for idx, obj_id in enumerate(ids):
|
||||
db.query(MapObjective).filter(
|
||||
|
||||
+387
-65
@@ -1,29 +1,185 @@
|
||||
"""
|
||||
OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
|
||||
KR完整修复(2026-08-27): KR读取从ActionPlan改为krs表 + 方向符号operator + 权重
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, date as date_type
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import Objective, ActionPlan
|
||||
from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition
|
||||
|
||||
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ── 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("")
|
||||
def list_objectives(
|
||||
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
||||
dimension: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""列出OKR目标"""
|
||||
q = db.query(Objective)
|
||||
"""列出OKR目标(账套隔离: 按token企业) — KR读取自krs表(2026-08-27)"""
|
||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
if dimension:
|
||||
@@ -33,9 +189,11 @@ def list_objectives(
|
||||
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
||||
results = []
|
||||
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 = [
|
||||
{"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
|
||||
]
|
||||
results.append({
|
||||
@@ -45,7 +203,7 @@ def list_objectives(
|
||||
"confidence": o.confidence,
|
||||
"key_results": kr_summary,
|
||||
"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,
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
@@ -55,9 +213,9 @@ def list_objectives(
|
||||
def create_objective(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""创建OKR目标(支持JSON Body和Query参数两种方式)"""
|
||||
# 兼容旧版Query参数
|
||||
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
|
||||
title = data.get("title") or ""
|
||||
quarter = data.get("quarter") or ""
|
||||
description = data.get("description")
|
||||
@@ -66,7 +224,7 @@ def create_objective(
|
||||
if not title or not quarter:
|
||||
raise HTTPException(422, "缺少必填字段: title, quarter")
|
||||
obj = Objective(title=title, quarter=quarter, description=description,
|
||||
dimension=dimension, owner=owner)
|
||||
dimension=dimension, owner=owner, entity_id=entity_id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
@@ -74,12 +232,10 @@ def create_objective(
|
||||
|
||||
|
||||
@router.get("/{obj_id}")
|
||||
def get_objective(obj_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单个OKR详情"""
|
||||
obj = db.query(Objective).filter(Objective.id == obj_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
|
||||
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取单个OKR详情(账套隔离: 跨企业404) — KR读取自krs表(2026-08-27)"""
|
||||
obj = _get_objective_or_404(db, obj_id, entity_id)
|
||||
krs = db.query(KR).filter(KR.objective_id == obj_id).order_by(KR.id).all()
|
||||
return {
|
||||
"objective": {
|
||||
"id": obj.id, "title": obj.title, "description": obj.description,
|
||||
@@ -87,31 +243,195 @@ def get_objective(obj_id: int, db: Session = Depends(get_db)):
|
||||
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
|
||||
"confidence": obj.confidence,
|
||||
},
|
||||
"key_results": [
|
||||
{"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
|
||||
]
|
||||
"key_results": [_kr_serialize(kr, db) 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}")
|
||||
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进度"""
|
||||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "OKR不存在")
|
||||
kr = db.query(ActionPlan).filter(
|
||||
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
|
||||
).first()
|
||||
_get_objective_or_404(db, okr_id, entity_id)
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||
milestones = data.get("milestones")
|
||||
if not isinstance(milestones, list):
|
||||
raise HTTPException(400, "milestones 必须是数组")
|
||||
# 规范化:只保留 month/label/status/target 字段
|
||||
normalized = []
|
||||
for ms in milestones:
|
||||
normalized.append({
|
||||
@@ -121,44 +441,45 @@ def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depe
|
||||
"target": ms.get("target"),
|
||||
})
|
||||
kr.monthly_milestones = normalized
|
||||
# 里程碑完成 → KR进度同步更新(完成数/总数)
|
||||
if normalized:
|
||||
done = sum(1 for ms in normalized if ms.get("status") == "completed")
|
||||
kr.progress = round(done / len(normalized) * 100)
|
||||
if kr.progress >= 100:
|
||||
kr.status = "completed"
|
||||
elif kr.status == "completed" and kr.progress < 100:
|
||||
kr.status = "achieved"
|
||||
elif kr.status == "achieved" and kr.progress < 100:
|
||||
kr.status = "in_progress"
|
||||
db.commit()
|
||||
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
|
||||
|
||||
|
||||
@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自动生成月度里程碑(按季度剩余月份均分)"""
|
||||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "OKR不存在")
|
||||
_get_objective_or_404(db, okr_id, entity_id)
|
||||
kr_id = data.get("kr_id")
|
||||
if not kr_id:
|
||||
raise HTTPException(400, "缺少 kr_id")
|
||||
kr = db.query(ActionPlan).filter(
|
||||
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
|
||||
).first()
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||
# 重新生成:覆盖已有里程碑(幂等)
|
||||
milestones = _build_auto_milestones(kr)
|
||||
kr.monthly_milestones = milestones
|
||||
kr.monthly_milestones = _build_auto_milestones(kr)
|
||||
db.commit()
|
||||
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个月度里程碑"""
|
||||
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)
|
||||
# 计算前3个月份(从最早的月份开始)
|
||||
month_keys = []
|
||||
for i in range(3, 0, -1):
|
||||
y, m = base.year, base.month - i
|
||||
@@ -177,34 +498,35 @@ def _build_auto_milestones(kr: ActionPlan) -> list:
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{obj_id}")
|
||||
def update_objective(obj_id: int, db: Session = Depends(get_db)):
|
||||
"""更新OKR进度(通过查询ActionPlan自动计算)"""
|
||||
obj = db.query(Objective).filter(Objective.id == obj_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
|
||||
if krs:
|
||||
obj.progress = sum(kr.progress for kr in krs) // len(krs)
|
||||
db.commit()
|
||||
return {"ok": True, "id": obj_id, "progress": obj.progress}
|
||||
def _parse_date(v):
|
||||
"""兼容 str/date/datetime → date"""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, date_type):
|
||||
return v
|
||||
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("/{okr_id}/decomposition")
|
||||
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db)):
|
||||
"""获取OKR的时间分解视图数据"""
|
||||
okr = db.query(Objective).filter(Objective.id == okr_id).first()
|
||||
if not okr:
|
||||
raise HTTPException(404, "OKR不存在")
|
||||
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""获取OKR的时间分解视图数据 — KR读取自krs表(2026-08-27)"""
|
||||
okr = _get_objective_or_404(db, okr_id, entity_id)
|
||||
|
||||
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
|
||||
bsc_o = db.query(Objective).filter(
|
||||
Objective.dimension == okr.dimension,
|
||||
Objective.quarter.is_(None)
|
||||
Objective.quarter.is_(None),
|
||||
Objective.entity_id == entity_id,
|
||||
).first()
|
||||
|
||||
# 2. 本OKR的所有KR(关联到该Objective的ActionPlan)
|
||||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == okr_id).all()
|
||||
# 2. 本OKR的所有KR(krs表)
|
||||
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
|
||||
|
||||
# 3. 当前周的ActionPlan(本周行动计划)
|
||||
now = datetime.now()
|
||||
|
||||
@@ -68,6 +68,7 @@ def list_okr_templates(
|
||||
"industry_tag": t.industry_tag,
|
||||
"preset_krs": t.preset_krs,
|
||||
"source": t.source,
|
||||
"owner": t.owner,
|
||||
"use_count": t.use_count,
|
||||
"sort_order": t.sort_order,
|
||||
"is_active": t.is_active,
|
||||
@@ -93,6 +94,7 @@ def get_okr_template(template_id: int, db: Session = Depends(get_db)):
|
||||
"industry_tag": t.industry_tag,
|
||||
"preset_krs": t.preset_krs,
|
||||
"source": t.source,
|
||||
"owner": t.owner,
|
||||
"use_count": t.use_count,
|
||||
"sort_order": t.sort_order,
|
||||
"is_active": t.is_active,
|
||||
@@ -101,7 +103,7 @@ def get_okr_template(template_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("")
|
||||
def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
||||
"""用户自定义 OKR 模板"""
|
||||
"""用户自定义 OKR 模板(治理: 同名去重 + owner 标记)"""
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "模板名称不能为空")
|
||||
@@ -111,6 +113,14 @@ def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
||||
preset_krs = data.get("preset_krs", [])
|
||||
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
|
||||
raise HTTPException(400, "至少需要一个预设KR")
|
||||
# P2治理: 同名+同维度去重(防止 id=33 式重复模板,2026-08-21)
|
||||
dup = db.query(OKRTemplate).filter(
|
||||
OKRTemplate.name == name,
|
||||
OKRTemplate.dimension == dimension,
|
||||
OKRTemplate.is_active == 1,
|
||||
).first()
|
||||
if dup:
|
||||
raise HTTPException(409, f"已存在同名模板「{name}」(#{dup.id}, source={dup.source}),请改用现有模板或改名")
|
||||
|
||||
t = OKRTemplate(
|
||||
name=name,
|
||||
@@ -120,6 +130,7 @@ def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
||||
industry_tag=data.get("industry_tag", "general"),
|
||||
preset_krs=preset_krs,
|
||||
source="user",
|
||||
owner="用户自定义",
|
||||
sort_order=data.get("sort_order", 0),
|
||||
)
|
||||
db.add(t)
|
||||
@@ -164,6 +175,8 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d
|
||||
"name": kr.get("name", ""),
|
||||
"target_value": kr.get("target_value", ""),
|
||||
"weight": kr.get("weight", 33),
|
||||
"metric_kpi_id": kr.get("metric_kpi_id"),
|
||||
"metric_kpi_code": kr.get("metric_kpi_code"),
|
||||
}
|
||||
for kr in preset_krs
|
||||
],
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
CMA本体三支柱追溯链 API — 科目 ↔ KPI ↔ OKR的O 三层互联 (2026-08-19)
|
||||
|
||||
追溯链: 目标(O) → 指标(KPI) → 科目(数据)
|
||||
objective_kpi 表: O 由哪些 KPI 度量
|
||||
kpi_subject_map 表: KPI 由哪些科目计算
|
||||
krs 表: O 的关键结果 KR (OKR完整化)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import Objective, KPIDefinition, ObjectiveKPI, KPISubjectMap, KR, Subject
|
||||
|
||||
router = APIRouter(prefix="/api/cma/ontology", tags=["本体三支柱"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it", "business"))],
|
||||
)
|
||||
|
||||
|
||||
def _num(v):
|
||||
"""Decimal → float 便于 JSON 序列化"""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
@router.get("/trace")
|
||||
def trace_ontology(
|
||||
objective_id: int = Query(..., description="OKR目标ID, 从O→KPI→科目逐层追溯"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""本体追溯链: O(目标) → KPI(指标) → 科目(数据) — 账套隔离 (2026-08-23 P2)"""
|
||||
obj = db.query(Objective).filter(Objective.id == objective_id, Objective.entity_id == entity_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
|
||||
# 第2层: O 支撑的 KPI (objective_kpi)
|
||||
links = (
|
||||
db.query(ObjectiveKPI, KPIDefinition)
|
||||
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
|
||||
.filter(ObjectiveKPI.objective_id == objective_id)
|
||||
.order_by(ObjectiveKPI.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 第3层: 每个 KPI 依赖的科目 (kpi_subject_map)
|
||||
subject_by_code = {
|
||||
s.subject_code: s.subject_name
|
||||
for s in db.query(Subject).filter(Subject.is_active == 1).all()
|
||||
}
|
||||
kpi_layer = []
|
||||
for link, kpi in links:
|
||||
maps = (
|
||||
db.query(KPISubjectMap)
|
||||
.filter(KPISubjectMap.kpi_id == kpi.id)
|
||||
.order_by(KPISubjectMap.id)
|
||||
.all()
|
||||
)
|
||||
subjects = [
|
||||
{
|
||||
"subject_code": m.subject_code,
|
||||
"subject_name": subject_by_code.get(m.subject_code, ""),
|
||||
"calc_type": m.calc_type,
|
||||
"weight": _num(m.weight),
|
||||
"remark": m.remark,
|
||||
}
|
||||
for m in maps
|
||||
]
|
||||
kpi_layer.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"unit": kpi.unit,
|
||||
"weight": _num(link.weight),
|
||||
"formula": kpi.formula,
|
||||
"subjects": subjects,
|
||||
})
|
||||
|
||||
# KR 层 (OKR完整化: O→KR)
|
||||
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
|
||||
kr_list = []
|
||||
for kr in krs:
|
||||
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first() if kr.metric_kpi_id else None
|
||||
kr_list.append({
|
||||
"id": kr.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": kr.operator or ">=",
|
||||
"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,
|
||||
})
|
||||
|
||||
subject_total = sum(len(k["subjects"]) for k in kpi_layer)
|
||||
return {
|
||||
"objective": {
|
||||
"id": obj.id,
|
||||
"title": obj.title,
|
||||
"description": obj.description,
|
||||
"dimension": obj.dimension,
|
||||
"quarter": obj.quarter,
|
||||
"owner": obj.owner,
|
||||
"status": obj.status,
|
||||
"progress": obj.progress,
|
||||
},
|
||||
"krs": kr_list,
|
||||
"kpis": kpi_layer,
|
||||
"chain": {
|
||||
"objective_id": obj.id,
|
||||
"objective_title": obj.title,
|
||||
"kpi_count": len(kpi_layer),
|
||||
"subject_count": subject_total,
|
||||
"path": "O(目标) → KPI(指标) → 科目(数据)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/objectives")
|
||||
def list_ontology_objectives(
|
||||
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""所有OKR目标的三层链路概览(前端OKR页用) — 账套隔离 (2026-08-23 P2)"""
|
||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
||||
results = []
|
||||
for o in objs:
|
||||
kpi_links = (
|
||||
db.query(ObjectiveKPI, KPIDefinition)
|
||||
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
|
||||
.filter(ObjectiveKPI.objective_id == o.id)
|
||||
.all()
|
||||
)
|
||||
krs = db.query(KR).filter(KR.objective_id == o.id).all()
|
||||
results.append({
|
||||
"id": o.id,
|
||||
"title": o.title,
|
||||
"dimension": o.dimension,
|
||||
"quarter": o.quarter,
|
||||
"owner": o.owner,
|
||||
"status": o.status,
|
||||
"progress": o.progress,
|
||||
"kpi_count": len(kpi_links),
|
||||
"kr_count": len(krs),
|
||||
"kpis": [{"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"weight": _num(l.weight)} for l, k in kpi_links],
|
||||
"krs": [{"id": kr.id, "title": kr.title, "progress": kr.progress,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None}
|
||||
for kr in krs],
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
+206
-1
@@ -1,6 +1,6 @@
|
||||
"""预测模拟API — 管理会计OS"""
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||||
from app.utils.predict_engine import (
|
||||
cvp_analysis, npv, irr,
|
||||
sensitivity_analysis, scenario_analysis,
|
||||
@@ -161,6 +161,97 @@ def api_cvp_detailed(data: dict):
|
||||
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事前预警) ────────────────────────────────────
|
||||
|
||||
|
||||
@@ -714,3 +805,117 @@ def api_growth_quality(request: Request, data: dict):
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
|
||||
|
||||
|
||||
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
||||
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
||||
MODELS, forecast_kpi, forecast_finance_kpis,
|
||||
MACRO_FACTORS, factor_sensitivity_for_kpi, factor_sensitivity_with_history,
|
||||
adjusted_next_with_factor, save_forecast_logs,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/kpi-forecast")
|
||||
def api_kpi_forecast(
|
||||
kpi_code: str,
|
||||
periods: int = 3,
|
||||
model: str = "linear",
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""单个财务KPI预测 — 线性回归/移动平均,多租户隔离(entity_id 权限校验)"""
|
||||
if periods < 0 or periods > 24:
|
||||
raise HTTPException(400, "periods 必须在 0~24 之间")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
result = forecast_kpi(entity_id, kpi_code, db, periods=periods, model=model)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
404,
|
||||
f"KPI {kpi_code} 在企业 entity_id={entity_id} 下不存在,或历史数据不足(至少2条)",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/kpi-forecast/finance")
|
||||
def api_kpi_forecast_finance(
|
||||
periods: int = 3,
|
||||
model: str = "linear",
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""批量预测该企业全部财务维度KPI(历史≥3条),按可预测性排序"""
|
||||
if periods < 0 or periods > 24:
|
||||
raise HTTPException(400, "periods 必须在 0~24 之间")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
|
||||
try:
|
||||
save_forecast_logs(entity_id, results, db, model=model) # 升级2a: 预测落库(供偏差告警)
|
||||
except Exception as e:
|
||||
logger.warning(f"预测落库失败(不影响返回): {e}")
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"model": model,
|
||||
"periods": periods,
|
||||
"total": len(results),
|
||||
"data": results,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/kpi-forecast/sensitivity")
|
||||
def api_kpi_forecast_sensitivity(
|
||||
pct: float = Query(10, description="宏观因素变动幅度% (±)"),
|
||||
periods: int = Query(3),
|
||||
model: str = Query("linear"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""宏观敏感性因素联动(IMA 2026.7)— 财务KPI × 宏观因素(油价/汇率/CPI)敏感性矩阵
|
||||
输出:每个KPI的预测值 + 各因素 ±pct% 情景下的调整后预测值
|
||||
MVP:弹性系数为规则推断(按KPI类别),诚实标注"模型弹性"非历史回归"""
|
||||
if abs(pct) > 50:
|
||||
raise HTTPException(400, "pct 必须在 ±50 以内")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
|
||||
matrix = []
|
||||
for r in results:
|
||||
kpi_info = r.get("kpi", {})
|
||||
# v2: 有历史数据用变化率弹性校准,无数据回退规则推断
|
||||
sens = factor_sensitivity_with_history(
|
||||
kpi_info.get("name", ""), kpi_info.get("code", ""), r.get("history", []))
|
||||
next_val = r.get("next_target")
|
||||
factor_effects = []
|
||||
for s in sens:
|
||||
up_val = adjusted_next_with_factor(next_val, pct, s["direction"], s["elasticity"])
|
||||
down_val = adjusted_next_with_factor(next_val, -pct, s["direction"], s["elasticity"])
|
||||
factor_effects.append({
|
||||
"factor_key": s["factor_key"],
|
||||
"factor_name": s["factor_name"],
|
||||
"factor_unit": s["factor_unit"],
|
||||
"direction": s["direction"],
|
||||
"elasticity": s["elasticity"],
|
||||
"elasticity_source": s.get("elasticity_source", "rule"),
|
||||
"matched_periods": s.get("matched_periods"),
|
||||
"rule_direction": s.get("rule_direction"),
|
||||
"adj_up": up_val,
|
||||
"adj_down": down_val,
|
||||
})
|
||||
matrix.append({
|
||||
"kpi": kpi_info,
|
||||
"category": sens[0]["category"] if sens else "profit",
|
||||
"next_target": next_val,
|
||||
"confidence": r.get("confidence"),
|
||||
"trend": r.get("trend"),
|
||||
"factors": factor_effects,
|
||||
})
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"model": model,
|
||||
"periods": periods,
|
||||
"pct": pct,
|
||||
"factors": MACRO_FACTORS,
|
||||
"total": len(matrix),
|
||||
"data": matrix,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""波士顿产品矩阵 API — 四象限分析"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import ProductSales
|
||||
from collections import defaultdict
|
||||
|
||||
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"])
|
||||
|
||||
|
||||
def _calc_quadrant(trend: float, margin: float) -> str:
|
||||
"""四象限分类:
|
||||
横轴=近3月销售趋势(正=增长),纵轴=毛利率
|
||||
明星(Star) = 高趋势+高毛利
|
||||
现金牛(CashCow) = 低趋势+高毛利
|
||||
问题(QuestionMark) = 高趋势+低毛利
|
||||
瘦狗(Dog) = 低趋势+低毛利
|
||||
"""
|
||||
trend_high = trend >= 0
|
||||
margin_high = margin >= 0
|
||||
if trend_high and margin_high:
|
||||
return "star"
|
||||
if not trend_high and margin_high:
|
||||
return "cash_cow"
|
||||
if trend_high and not margin_high:
|
||||
return "question_mark"
|
||||
return "dog"
|
||||
|
||||
|
||||
@router.get("/matrix")
|
||||
def get_product_matrix(
|
||||
entity_id: int = Query(1, description="1=酣客 2=博海"),
|
||||
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额"""
|
||||
# 取最近 months+1 个月(多取1个月用于计算趋势)
|
||||
periods = db.query(ProductSales.period_month).filter(
|
||||
ProductSales.entity_id == entity_id
|
||||
).distinct().order_by(ProductSales.period_month.desc()).limit(months + 1).all()
|
||||
periods = sorted([p[0] for p in periods])
|
||||
|
||||
if len(periods) < 2:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"has_data": False,
|
||||
"message": "数据不足,至少需要2个月数据",
|
||||
"quadrants": [],
|
||||
"products": [],
|
||||
}
|
||||
|
||||
trend_periods = periods[-months:] # 最近 months 个月
|
||||
prev_periods = periods[:-months] if len(periods) > months else periods[:1]
|
||||
|
||||
# 加载数据
|
||||
rows = db.query(ProductSales).filter(
|
||||
ProductSales.entity_id == entity_id,
|
||||
ProductSales.period_month.in_(periods),
|
||||
).all()
|
||||
|
||||
# 按商品聚合
|
||||
products = defaultdict(lambda: {
|
||||
"code": "", "name": "", "months": {},
|
||||
"total_sales": 0, "total_qty": 0, "total_gross": 0,
|
||||
})
|
||||
for row in rows:
|
||||
p = products[row.product_code]
|
||||
p["code"] = row.product_code
|
||||
p["name"] = row.product_name
|
||||
p["months"][row.period_month] = {
|
||||
"sales": float(row.sales_amount or 0),
|
||||
"margin": float(row.gross_margin_rate or 0),
|
||||
"gross": float(row.gross_profit or 0),
|
||||
"qty": int(row.sales_qty or 0),
|
||||
}
|
||||
p["total_sales"] += float(row.sales_amount or 0)
|
||||
p["total_qty"] += int(row.sales_qty or 0)
|
||||
p["total_gross"] += float(row.gross_profit or 0)
|
||||
|
||||
# 计算每个商品的趋势和毛利率
|
||||
result_products = []
|
||||
for code, p in products.items():
|
||||
# 趋势 = 最近月份 vs 前一月的环比(取趋势期间的平均环比增速)
|
||||
# 用最近3个月的销售序列做简单线性趋势
|
||||
trend_sales = []
|
||||
for pp in periods:
|
||||
trend_sales.append(p["months"].get(pp, {}).get("sales", 0))
|
||||
# 线性回归斜率(最小二乘)
|
||||
n = len(trend_sales)
|
||||
if n >= 2:
|
||||
xs = list(range(n))
|
||||
x_mean = sum(xs) / n
|
||||
y_mean = sum(trend_sales) / n
|
||||
numerator = sum((xs[i] - x_mean) * (trend_sales[i] - y_mean) for i in range(n))
|
||||
denominator = sum((xs[i] - x_mean) ** 2 for i in range(n))
|
||||
slope = numerator / denominator if denominator else 0
|
||||
# 斜率转为百分比(相对期间平均销售)
|
||||
avg = y_mean if y_mean != 0 else 1
|
||||
trend = slope / abs(avg) * 100
|
||||
else:
|
||||
trend = 0.0
|
||||
|
||||
# 毛利率 = 加权平均(按销售额)
|
||||
weighted_margin = 0.0
|
||||
total_sales_for_margin = 0
|
||||
for pp in trend_periods:
|
||||
m = p["months"].get(pp)
|
||||
if m and m["sales"] > 0:
|
||||
weighted_margin += m["margin"] * m["sales"]
|
||||
total_sales_for_margin += m["sales"]
|
||||
if total_sales_for_margin > 0:
|
||||
weighted_margin = weighted_margin / total_sales_for_margin
|
||||
else:
|
||||
# 无销售用平均毛利率
|
||||
margins = [p["months"][pp]["margin"] for pp in p["months"] if p["months"][pp]["margin"] != 0]
|
||||
weighted_margin = sum(margins) / len(margins) if margins else 0
|
||||
|
||||
quadrant = _calc_quadrant(trend, weighted_margin)
|
||||
|
||||
result_products.append({
|
||||
"code": code,
|
||||
"name": p["name"],
|
||||
"total_sales": round(p["total_sales"], 2),
|
||||
"total_qty": p["total_qty"],
|
||||
"total_gross": round(p["total_gross"], 2),
|
||||
"trend_pct": round(trend, 1),
|
||||
"margin_pct": round(weighted_margin, 1),
|
||||
"quadrant": quadrant,
|
||||
})
|
||||
|
||||
# 按销售额排序
|
||||
result_products.sort(key=lambda x: -x["total_sales"])
|
||||
|
||||
# 四象限汇总
|
||||
quadrant_labels = {
|
||||
"star": {"label": "明星产品", "icon": "🌟", "advice": "高增长+有毛利,重点主推,加大投入"},
|
||||
"cash_cow": {"label": "现金牛", "icon": "🥇", "advice": "销量大但增长放缓,维持稳定产出"},
|
||||
"question_mark": {"label": "问题产品", "icon": "❓", "advice": "增长好但毛利低,优化成本或提价"},
|
||||
"dog": {"label": "瘦狗产品", "icon": "🐶", "advice": "低增长+低毛利,考虑清库存或停产"},
|
||||
}
|
||||
quadrants = []
|
||||
for q in ["star", "cash_cow", "question_mark", "dog"]:
|
||||
items = [p for p in result_products if p["quadrant"] == q]
|
||||
quadrants.append({
|
||||
"key": q,
|
||||
**quadrant_labels[q],
|
||||
"count": len(items),
|
||||
"products": items,
|
||||
})
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"has_data": True,
|
||||
"periods": periods,
|
||||
"months_analyzed": months,
|
||||
"quadrants": quadrants,
|
||||
"products": result_products,
|
||||
}
|
||||
+620
-113
File diff suppressed because it is too large
Load Diff
@@ -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%
|
||||
→ 通知任总 (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",
|
||||
@@ -86,14 +92,22 @@ def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def update_okr_progress(db: Session, plan: ActionPlan) -> dict:
|
||||
"""验证通过 → 所属OKR progress +15%(每通过1个KR)"""
|
||||
def update_okr_progress(db: Session, plan: ActionPlan, already_verified: bool = False, force_recalc: bool = False) -> dict:
|
||||
"""验证通过 → 所属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:
|
||||
return {"updated": False, "reason": "no_objective"}
|
||||
obj = db.query(Objective).filter(Objective.id == plan.objective_id).first()
|
||||
if not obj:
|
||||
return {"updated": False, "reason": "objective_not_found"}
|
||||
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)
|
||||
db.flush()
|
||||
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"):
|
||||
"""回填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_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:
|
||||
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:
|
||||
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:
|
||||
new_val = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
entity_id=kpi.entity_id, # 缺陷2修复:多租户回填 entity_id
|
||||
period=datetime.now().strftime("%Y-%m"),
|
||||
actual_value=actual,
|
||||
source_type="verify",
|
||||
@@ -215,19 +244,27 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
|
||||
db.commit()
|
||||
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:
|
||||
kpi = None
|
||||
kpi_code = rule.get("kpi_code")
|
||||
if plan.kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||
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:
|
||||
period_limit = payload.get("period") or datetime.now().strftime("%Y-%m")
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
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:
|
||||
actual = latest.actual_value
|
||||
|
||||
@@ -237,6 +274,10 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
|
||||
# 4. 回写KPI当前值
|
||||
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. 更新状态
|
||||
plan.verify_status = "passed" if passed else "failed"
|
||||
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,
|
||||
}]
|
||||
if passed:
|
||||
plan.status = "done"
|
||||
plan.status = "completed" # 缺陷4修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
|
||||
plan.progress = 100
|
||||
plan.verified_at = datetime.now()
|
||||
|
||||
# 6. OKR进度联动(验证通过 → +15%)
|
||||
# 6. OKR进度联动(验证通过 → +15%;缺陷1修复:已通过过的 plan 不再重复累加,force_recalc 可强制重算)
|
||||
okr_update = None
|
||||
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()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import StrategicMap, StrategicMapVersion
|
||||
|
||||
@@ -11,18 +12,19 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
|
||||
|
||||
|
||||
@router.get("/{map_id}/versions")
|
||||
def list_versions(map_id: int, db: Session = Depends(get_db)):
|
||||
"""查看版本历史"""
|
||||
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(
|
||||
StrategicMapVersion.map_id == map_id
|
||||
StrategicMapVersion.map_id == map_id,
|
||||
StrategicMapVersion.entity_id == entity_id,
|
||||
).order_by(StrategicMapVersion.id.desc()).all()
|
||||
return {"data": [v_to_dict(v) for v in versions]}
|
||||
|
||||
|
||||
@router.post("/{map_id}/versions/snapshot")
|
||||
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""手动创建快照"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
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()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
@@ -49,6 +51,7 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
snapshot = StrategicMapVersion(
|
||||
map_id=map_id,
|
||||
entity_id=entity_id, # 继承地图企业
|
||||
version=new_ver,
|
||||
dimensions=dims,
|
||||
canvas_data=canvas,
|
||||
@@ -61,9 +64,9 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{map_id}/versions/{ver_id}/rollback")
|
||||
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db)):
|
||||
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:
|
||||
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:
|
||||
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):
|
||||
"""插入5层级组织示例数据"""
|
||||
|
||||
+13
-1
@@ -5,10 +5,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
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, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality
|
||||
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 scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
from app.api.audit_log import bot_audit_middleware
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -30,6 +31,9 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Bot API 操作审计(L1-L4分级标注 + JSON行审计日志,不阻塞业务)
|
||||
app.middleware("http")(bot_audit_middleware)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(kpis.router)
|
||||
app.include_router(kpi_governance.router)
|
||||
@@ -39,6 +43,7 @@ app.include_router(dashboard.router)
|
||||
app.include_router(data.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(ai_analysis.router)
|
||||
app.include_router(ai_suggestions.router)
|
||||
app.include_router(alert_rules.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(thresholds.router)
|
||||
@@ -50,9 +55,14 @@ app.include_router(org.router)
|
||||
app.include_router(objectives.router)
|
||||
app.include_router(versions.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(predict.router)
|
||||
app.include_router(growth_quality.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(security.router)
|
||||
app.include_router(knowledge.router)
|
||||
@@ -74,10 +84,12 @@ app.include_router(okr_templates.router)
|
||||
app.include_router(subjects.router)
|
||||
app.include_router(driver_budget.router)
|
||||
app.include_router(bot_kpis.router)
|
||||
app.include_router(ontology.router)
|
||||
app.include_router(bot_iron_law.router)
|
||||
app.include_router(analysis_results.router)
|
||||
app.include_router(expenses.router)
|
||||
app.include_router(cash.router)
|
||||
app.include_router(data_classification.router)
|
||||
app.include_router(tax_compliance.router)
|
||||
app.include_router(verify.router)
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""管理会计OS 数据模型"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint
|
||||
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint, Numeric, Date
|
||||
from app.database import Base
|
||||
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
from app.models.product_sales import ProductSales
|
||||
|
||||
|
||||
class Entity(Base):
|
||||
@@ -47,6 +48,7 @@ class StrategicMap(Base):
|
||||
"""战略地图"""
|
||||
__tablename__ = "strategic_maps"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-25)")
|
||||
title = Column(String(200), nullable=False, comment="地图名称")
|
||||
version = Column(String(20), default="v1.0", comment="版本号")
|
||||
status = Column(String(20), default="draft", comment="draft/published")
|
||||
@@ -75,11 +77,18 @@ class KPIDefinition(Base):
|
||||
data_owner = Column(String(100), default="待指定", comment="数据责任人")
|
||||
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
|
||||
unit = Column(String(50), default="%", comment="单位")
|
||||
target_value = Column(Float, nullable=True, comment="目标值")
|
||||
target_value = Column(Float, nullable=True, comment="目标值(兼容旧字段)")
|
||||
target_monthly = Column(Float, nullable=True, comment="月度目标值")
|
||||
target_quarterly = Column(Float, nullable=True, comment="季度目标值")
|
||||
target_yearly = Column(Float, nullable=True, comment="年度目标值")
|
||||
target_calc_type = Column(String(20), nullable=True, comment="指标类型: accumulate累计(月×3=季,×12=年) / ratio比率(季/年沿用基准,可手调)")
|
||||
threshold_green = Column(String(100), nullable=True, comment="绿灯阈值")
|
||||
threshold_yellow = 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")
|
||||
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_user = Column(String(100), nullable=True, comment="负责人")
|
||||
kpi_level = Column(String(20), default="operational", comment="strategic/operational")
|
||||
@@ -95,6 +104,7 @@ class KPIValue(Base):
|
||||
"""KPI实际值"""
|
||||
__tablename__ = "kpi_values"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=None, comment="企业ID (P2多租户隔离 2026-08-23, 按kpi_id回填)")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
actual_value = Column(Float, nullable=True, comment="实际值")
|
||||
@@ -109,6 +119,7 @@ class DataSourceConfig(Base):
|
||||
"""数据源配置"""
|
||||
__tablename__ = "data_source_config"
|
||||
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="数据源名称")
|
||||
source_type = Column(String(20), nullable=False, comment="erp/business/excel")
|
||||
api_endpoint = Column(String(500), nullable=True, comment="API地址")
|
||||
@@ -124,6 +135,7 @@ class KPIAlert(Base):
|
||||
"""预警记录"""
|
||||
__tablename__ = "kpi_alerts"
|
||||
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_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
|
||||
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
|
||||
@@ -189,6 +201,7 @@ class Objective(Base):
|
||||
"""OKR目标"""
|
||||
__tablename__ = "objectives"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
title = Column(String(200), nullable=False, comment="目标标题")
|
||||
description = Column(Text, nullable=True, comment="目标描述")
|
||||
dimension = Column(String(50), nullable=True, comment="关联维度: finance/customer/process/learning")
|
||||
@@ -206,9 +219,11 @@ class ActionPlan(Base):
|
||||
"""改善行动计划"""
|
||||
__tablename__ = "action_plans"
|
||||
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="关联预警")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
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="计划标题")
|
||||
description = Column(Text, nullable=True, comment="详细描述")
|
||||
assignee = Column(String(100), nullable=True, comment="负责人")
|
||||
@@ -235,6 +250,7 @@ class OrgNode(Base):
|
||||
"""组织节点: 集团→事业部→区域→部门→班组 5级"""
|
||||
__tablename__ = "org_nodes"
|
||||
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")
|
||||
name = Column(String(100), nullable=False, comment="节点名称")
|
||||
code = Column(String(50), unique=True, nullable=True, comment="编码")
|
||||
@@ -251,6 +267,7 @@ class StrategicMapVersion(Base):
|
||||
"""战略地图版本快照"""
|
||||
__tablename__ = "strategic_map_versions"
|
||||
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="关联地图")
|
||||
version = Column(String(20), nullable=False, comment="版本号 v1.0 v1.1 ...")
|
||||
dimensions = Column(JSON, nullable=False, comment="维度数据快照")
|
||||
@@ -260,10 +277,27 @@ class StrategicMapVersion(Base):
|
||||
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):
|
||||
"""战略地图目标: 每个维度下的具体目标"""
|
||||
__tablename__ = "map_objectives"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-25)")
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
||||
dimension_key = Column(String(50), nullable=False, comment="所属维度: finance/customer/process/learning")
|
||||
name = Column(String(200), nullable=False, comment="目标名称")
|
||||
@@ -278,12 +312,17 @@ class KPICausality(Base):
|
||||
"""KPI因果链 — 记录KPI间的因果关系"""
|
||||
__tablename__ = "kpi_causality"
|
||||
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(因)")
|
||||
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
|
||||
strength = Column(Float, default=0.5, comment="影响强度 0~1")
|
||||
lag_months = Column(Integer, default=1, comment="滞后期(月)")
|
||||
formula = Column(String(500), nullable=True, comment="影响公式描述")
|
||||
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())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -299,6 +338,21 @@ class MpmResult(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class KpiForecastLog(Base):
|
||||
"""KPI预测历史 — 预测偏差告警数据源 (2026-08-25 升级2a)"""
|
||||
__tablename__ = "kpi_forecast_log"
|
||||
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_code = Column(String(50), nullable=False, comment="KPI编码")
|
||||
period = Column(String(20), nullable=False, comment="预测期间")
|
||||
forecast_value = Column(Float, nullable=True, comment="预测值")
|
||||
model = Column(String(30), default="linear", comment="预测模型")
|
||||
confidence = Column(String(10), nullable=True, comment="置信度")
|
||||
trend = Column(String(10), nullable=True, comment="趋势")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class BotBridgeConfig(Base):
|
||||
"""Bot桥接鉴权配置"""
|
||||
__tablename__ = "bot_bridge_config"
|
||||
@@ -327,6 +381,7 @@ class BiReportTemplate(Base):
|
||||
"""BI报表模板"""
|
||||
__tablename__ = "bi_report_templates"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
name = Column(String(200), nullable=False, comment="模板名称")
|
||||
report_type = Column(String(50), nullable=False, comment="overview/trend/comparison/topn/causality")
|
||||
config = Column(JSON, nullable=False, comment="报表配置")
|
||||
@@ -340,6 +395,7 @@ class BiReport(Base):
|
||||
"""用户保存的BI报表"""
|
||||
__tablename__ = "bi_reports"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
template_id = Column(Integer, ForeignKey("bi_report_templates.id"), nullable=True)
|
||||
name = Column(String(200), nullable=False, comment="报表名称")
|
||||
config = Column(JSON, nullable=False, comment="报表配置(行/列/值)")
|
||||
@@ -406,6 +462,7 @@ class OKRTemplate(Base):
|
||||
industry_tag = Column(String(50), default="general", comment="行业标签")
|
||||
preset_krs = Column(JSON, nullable=False, comment="预设关键结果列表")
|
||||
source = Column(String(20), default="system", comment="system/user/industry_pack")
|
||||
owner = Column(String(50), default="CMA标准库", comment="模板负责人(治理P2: 可追踪)")
|
||||
use_count = Column(Integer, default=0, comment="使用次数")
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Integer, default=1)
|
||||
@@ -427,16 +484,21 @@ class CashForecast(Base):
|
||||
|
||||
|
||||
class CashPlan(Base):
|
||||
"""收付款计划 — 资金管理智能体"""
|
||||
"""收付款计划 — 资金管理智能体(唯一应收载体:含回款登记、负责人、数据来源)"""
|
||||
__tablename__ = "cash_plans"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
|
||||
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="金额(万元)")
|
||||
plan_date = Column(DateTime, nullable=False, comment="计划日期")
|
||||
plan_date = Column(DateTime, nullable=False, comment="计划日期(应收即到期日)")
|
||||
counterparty = Column(String(200), nullable=True, comment="关联客户/供应商")
|
||||
description = Column(String(500), nullable=True, comment="说明")
|
||||
status = Column(String(20), default="pending", comment="pending/completed/cancelled")
|
||||
owner = Column(String(100), nullable=True, comment="负责人/业务员(应收催收责任人)")
|
||||
source = Column(String(50), default="manual", comment="数据来源: manual/bohai_ar/receivables_migrate")
|
||||
paid_amount = Column(Float, default=0, comment="已回款金额(万元)")
|
||||
completed_at = Column(DateTime, nullable=True, comment="完成时间")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -456,6 +518,7 @@ class BudgetDeviationAlert(Base):
|
||||
"""预算偏差预警记录"""
|
||||
__tablename__ = "budget_deviation_alerts"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||
budget_value = Column(Float, nullable=True, comment="预算值")
|
||||
@@ -465,6 +528,9 @@ class BudgetDeviationAlert(Base):
|
||||
alert_level = Column(String(20), default="warning", comment="warning/critical")
|
||||
status = Column(String(20), default="open", comment="open/resolved/ignored")
|
||||
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())
|
||||
|
||||
|
||||
@@ -472,6 +538,7 @@ class ReportHistory(Base):
|
||||
"""自动生成的经营分析报告记录"""
|
||||
__tablename__ = "report_history"
|
||||
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")
|
||||
period = Column(String(20), nullable=False, comment="期间: 2026-W30 / 2026-07 / 2026-Q2")
|
||||
title = Column(String(200), nullable=False, comment="报告标题")
|
||||
@@ -543,6 +610,9 @@ class Subject(Base):
|
||||
level = Column(Integer, default=1, comment="科目级别 1-4")
|
||||
category = Column(String(50), nullable=True, comment="科目类别")
|
||||
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="是否启用")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -550,9 +620,10 @@ class Subject(Base):
|
||||
|
||||
|
||||
class VoucherDetail(Base):
|
||||
"""凭证明细 — 新30号准则分类"""
|
||||
"""凭证明细 — 新30号准则分类 (网银流水导入 2026-08-28)"""
|
||||
__tablename__ = "voucher_details"
|
||||
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_date = Column(DateTime, nullable=False, comment="凭证日期")
|
||||
subject_code = Column(String(20), nullable=False, comment="科目编码")
|
||||
@@ -560,8 +631,27 @@ class VoucherDetail(Base):
|
||||
debit_amount = Column(Float, default=0, comment="借方金额")
|
||||
credit_amount = Column(Float, default=0, 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号准则分类")
|
||||
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())
|
||||
|
||||
|
||||
@@ -573,6 +663,7 @@ class ExpenseRule(Base):
|
||||
"""费用规则 — 自动校验报销单的标准"""
|
||||
__tablename__ = "expense_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
rule_name = Column(String(100), nullable=False, comment="规则名称")
|
||||
dimension = Column(String(20), nullable=False, comment="维度: department/person/expense_type")
|
||||
dimension_value = Column(String(100), nullable=True, comment="维度值: 部门名/人员名/费用类型(空=全局)")
|
||||
@@ -590,6 +681,7 @@ class ExpenseReimbursement(Base):
|
||||
"""费用报销单 — 提交后自动校验规则,超限自动打回"""
|
||||
__tablename__ = "expense_reimbursements"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
reimb_no = Column(String(50), unique=True, nullable=False, comment="报销单号")
|
||||
applicant = Column(String(100), nullable=False, comment="申请人")
|
||||
department = Column(String(100), nullable=True, comment="部门")
|
||||
@@ -672,3 +764,179 @@ class SocialSecurity(Base):
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 本体三支柱: 科目↔KPI↔OKR 三层互联 (2026-08-19)
|
||||
# 追溯链: 目标(O) → 指标(KPI) → 科目(数据)
|
||||
# ============================================================
|
||||
|
||||
class KPISubjectMap(Base):
|
||||
"""科目↔KPI映射 — 指标计算依赖的底层会计科目"""
|
||||
__tablename__ = "kpi_subject_map"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
|
||||
subject_code = Column(String(20), nullable=False, comment="科目编码")
|
||||
calc_type = Column(String(20), default="sum", comment="sum/avg/ratio/other")
|
||||
weight = Column(Numeric(5, 2), default=1.00, comment="权重(负=扣减项)")
|
||||
remark = Column(String(200), nullable=True, comment="备注")
|
||||
__table_args__ = (UniqueConstraint("kpi_id", "subject_code", name="uk_kpi_subject"),)
|
||||
|
||||
|
||||
class ObjectiveKPI(Base):
|
||||
"""KPI↔O支撑 — 目标由哪些KPI度量"""
|
||||
__tablename__ = "objective_kpi"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
|
||||
weight = Column(Numeric(5, 2), default=1.00, comment="支撑权重")
|
||||
__table_args__ = (UniqueConstraint("objective_id", "kpi_id", name="uk_obj_kpi"),)
|
||||
|
||||
|
||||
class KR(Base):
|
||||
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
|
||||
__tablename__ = "krs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
||||
title = Column(String(200), nullable=False, comment="KR标题")
|
||||
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="目标值")
|
||||
current_value = Column(Numeric(15, 2), nullable=True, comment="当前值")
|
||||
progress = Column(Integer, default=0, comment="完成进度 0-100")
|
||||
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled/achieved")
|
||||
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())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 预算系统技术改进 (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())
|
||||
|
||||
@@ -8,6 +8,8 @@ class BudgetPlan(Base):
|
||||
__tablename__ = "budget_plans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
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")
|
||||
period = Column(String(20), nullable=False, comment="预算期间 2026-05")
|
||||
budget_value = Column(Float, nullable=False, comment="预算值")
|
||||
@@ -15,6 +17,9 @@ class BudgetPlan(Base):
|
||||
budget_month = Column(Integer, nullable=False, comment="预算月份 1-12")
|
||||
version = Column(String(20), default="v1.0", comment="版本号 v1.0/v2.0")
|
||||
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="备注")
|
||||
created_by = Column(String(100), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
@@ -10,6 +10,7 @@ class StandardCost(Base):
|
||||
__tablename__ = "standard_costs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
|
||||
cost_type = Column(String(20), nullable=False, comment="成本类型: material/labor/overhead")
|
||||
@@ -30,6 +31,7 @@ class ActualCost(Base):
|
||||
__tablename__ = "actual_costs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
|
||||
@@ -47,6 +49,7 @@ class AbcActivity(Base):
|
||||
__tablename__ = "abc_activities"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
activity_code = Column(String(50), unique=True, nullable=False, comment="作业编码")
|
||||
activity_name = Column(String(200), nullable=False, comment="作业名称")
|
||||
activity_desc = Column(Text, nullable=True, comment="作业描述")
|
||||
@@ -65,6 +68,7 @@ class AbcAllocation(Base):
|
||||
__tablename__ = "abc_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
activity_id = Column(Integer, ForeignKey("abc_activities.id"), nullable=False)
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
|
||||
@@ -23,6 +23,7 @@ class DriverFactorBudget(Base):
|
||||
__tablename__ = "driver_factor_budgets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
name = Column(String(200), nullable=False, comment="预算项名称")
|
||||
industry = Column(String(50), default="general", comment="行业标签")
|
||||
template_id = Column(Integer, nullable=True, comment="关联模板ID")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""商品销售数据模型 — 波士顿产品矩阵
|
||||
从《商品销售排行榜》Excel导入,支撑四象限分析
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProductSales(Base):
|
||||
"""商品销售月度数据"""
|
||||
__tablename__ = "product_sales"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID: 1=酣客 2=博海")
|
||||
product_code = Column(String(50), nullable=False, comment="商品编码")
|
||||
product_name = Column(String(100), nullable=False, comment="商品名称")
|
||||
period_month = Column(String(10), nullable=False, comment="期间 YYYY-MM")
|
||||
sales_amount = Column(Float, default=0, comment="销售金额")
|
||||
cost_amount = Column(Float, default=0, comment="成本金额")
|
||||
gross_profit = Column(Float, default=0, comment="毛利")
|
||||
gross_margin_rate = Column(Float, default=0, comment="毛利率(%)")
|
||||
sales_qty = Column(Integer, default=0, comment="销售数量")
|
||||
unit = Column(String(30), default="", comment="单位")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=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
|
||||
|
||||
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 with httpx.AsyncClient(timeout=20) as c:
|
||||
|
||||
@@ -320,6 +320,7 @@ def calculate_accuracy(entity_id: int, db: Session) -> list:
|
||||
|
||||
def generate_scenario_suggestion(alert_type: str, kpi_name: str, extra: dict = None) -> dict:
|
||||
"""根据预警类型生成情景建议"""
|
||||
extra = extra or {}
|
||||
suggestions = {
|
||||
"cash_low": {
|
||||
"title": "现金流紧张缓解方案",
|
||||
@@ -624,7 +625,7 @@ def forecast_cash_flow_with_plans(
|
||||
def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
|
||||
"""资金预警 — 缺口前3天预警 + 到期未收款提醒,写入预警中心(kpi_alerts)"""
|
||||
import json as _json
|
||||
from app.models import KPIAlert, CashPlan
|
||||
from app.models import KPIAlert, CashPlan, KPIDefinition
|
||||
|
||||
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
|
||||
new_alerts = []
|
||||
@@ -679,7 +680,21 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
|
||||
days_late = (today - p.plan_date).days
|
||||
msg = (f"【到期未收款】应收款{p.counterparty or '客户'} {p.amount:.1f}万 "
|
||||
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
|
||||
alert = KPIAlert(
|
||||
kpi_id=ar_kpi.id,
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation, KPI
|
||||
logger = logging.getLogger("cma.cost")
|
||||
|
||||
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. 差异预警触发(集成到现有预警系统)
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# 越高越好型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:
|
||||
"""运行差异预警检查,返回新增预警数"""
|
||||
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
@@ -221,33 +321,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
||||
|
||||
new_count = 0
|
||||
for kpi in kpis:
|
||||
# 1. 差异预警:实际 vs 预算
|
||||
deviation = calc_period_deviation(db_session, kpi.id, period)
|
||||
if deviation.get("deviation_rate") is not None:
|
||||
rate = abs(deviation["deviation_rate"])
|
||||
|
||||
# 差异化阈值:越高越好型 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
|
||||
|
||||
# 1. 差异预警:实际 vs 预算(统一逻辑)
|
||||
result = build_deviation_alert(db_session, kpi, period)
|
||||
if result["triggered"]:
|
||||
deviation = result["deviation"]
|
||||
alert_msg = (
|
||||
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
|
||||
f"vs 预算{deviation['budget_value']},"
|
||||
@@ -265,13 +342,15 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi.id,
|
||||
alert_level=level,
|
||||
alert_level=result["kpi_alert_level"],
|
||||
alert_message=f"[差异预警] {alert_msg}",
|
||||
alert_type=result["alert_type"] or "actual",
|
||||
suggestion=result["suggestion"],
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
|
||||
# 2. 趋势异常检测(每期检查连续3期)
|
||||
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
"""KPI预测引擎 — 基于历史KPI值做趋势预测(预测性成本智能 MVP)
|
||||
|
||||
模型(MVP原则:简单可用,不上深度学习):
|
||||
- linear 线性回归(最小二乘 y = a + b·x),输出95%预测区间
|
||||
- moving_average 简单移动平均(默认窗口3期),输出均值±波动区间
|
||||
|
||||
置信度诚实标注:基于历史数据量 + 拟合优度(R² / 波动率CV)综合打分,
|
||||
数据不足时明确给出 low,不做虚假高置信。
|
||||
|
||||
复用 cash_forecast_engine.get_entity_kpi_history 取历史数据(不重复写查询)。
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.utils.cash_forecast_engine import get_entity_kpi_history, find_kpi
|
||||
|
||||
logger = logging.getLogger("cma.kpi_forecast")
|
||||
|
||||
MODELS = ("linear", "moving_average")
|
||||
DEFAULT_PERIODS = 3
|
||||
TREND_THRESHOLD_PCT = 3.0 # |趋势百分比| ≥ 3% 判定为有明确趋势方向
|
||||
|
||||
# 置信度档位
|
||||
CONF_LEVELS = {3: "high", 2: "medium", 1: "low"}
|
||||
# 中文映射(供 summary 使用)
|
||||
TREND_CN = {"up": "上升", "down": "下降", "flat": "基本平稳"}
|
||||
CONF_CN = {"high": "高", "medium": "中", "low": "低"}
|
||||
|
||||
|
||||
def next_period(period: str, steps: int = 1) -> str:
|
||||
"""期数递增:"2026-05" + 1 → "2026-06";解析失败时退化为 period+N"""
|
||||
try:
|
||||
y, m = str(period).split("-")
|
||||
total = int(y) * 12 + (int(m) - 1) + steps
|
||||
return f"{total // 12:04d}-{total % 12 + 1:02d}"
|
||||
except Exception:
|
||||
return f"{period}+{steps}"
|
||||
|
||||
|
||||
def _t_crit(n: int) -> float:
|
||||
"""95%双尾学生t临界值近似(小样本查表取保守值,大样本趋近1.96)"""
|
||||
table = {
|
||||
2: 12.71, 3: 4.30, 4: 3.18, 5: 2.78, 6: 2.57, 7: 2.45,
|
||||
8: 2.31, 9: 2.26, 10: 2.23, 12: 2.18, 15: 2.13,
|
||||
20: 2.09, 30: 2.04, 60: 2.00,
|
||||
}
|
||||
for k in sorted(table):
|
||||
if n <= k:
|
||||
return table[k]
|
||||
return 1.96
|
||||
|
||||
|
||||
def _std(values: list) -> float:
|
||||
"""样本标准差(n>=2),n==1 返回0"""
|
||||
n = len(values)
|
||||
if n < 2:
|
||||
return 0.0
|
||||
mean = sum(values) / n
|
||||
return math.sqrt(sum((v - mean) ** 2 for v in values) / (n - 1))
|
||||
|
||||
|
||||
def _rel_trend_pct(values: list) -> float:
|
||||
"""趋势百分比 = 线性回归斜率 / |均值| × 100(与 cash_forecast_engine.calc_trend 同口径)"""
|
||||
n = len(values)
|
||||
if n < 2:
|
||||
return 0.0
|
||||
xbar = (n - 1) / 2.0
|
||||
ybar = sum(values) / n
|
||||
sxx = sum((i - xbar) ** 2 for i in range(n))
|
||||
if sxx == 0:
|
||||
return 0.0
|
||||
slope = sum((i - xbar) * (values[i] - ybar) for i in range(n)) / sxx
|
||||
return slope / max(abs(ybar), 1.0) * 100
|
||||
|
||||
|
||||
def judge_trend(trend_pct: float, threshold: float = TREND_THRESHOLD_PCT) -> str:
|
||||
"""趋势方向判定:up / down / flat"""
|
||||
if trend_pct > threshold:
|
||||
return "up"
|
||||
if trend_pct < -threshold:
|
||||
return "down"
|
||||
return "flat"
|
||||
|
||||
|
||||
def _compute_r2(values: list, pred_fn) -> float:
|
||||
"""拟合优度 R²(0~1),数据无波动时视为完全拟合"""
|
||||
ybar = sum(values) / len(values)
|
||||
ss_tot = sum((v - ybar) ** 2 for v in values)
|
||||
if ss_tot == 0:
|
||||
return 1.0
|
||||
ss_res = sum((v - pred_fn(i)) ** 2 for i, v in enumerate(values))
|
||||
return max(0.0, 1.0 - ss_res / ss_tot)
|
||||
|
||||
|
||||
def compute_confidence(n: int, model: str, r2: Optional[float] = None,
|
||||
cv: Optional[float] = None) -> str:
|
||||
"""置信度诚实标注:数据量基数 + 拟合优度修正
|
||||
|
||||
- 数据量:n>=12 → 3分;n>=6 → 2分;否则 1分
|
||||
- linear:R²>=0.7 +1;R²<0.3 -1
|
||||
- moving_average:CV<0.3 +1(低波动更可信);CV>0.6 -1
|
||||
"""
|
||||
score = 3 if n >= 12 else (2 if n >= 6 else 1)
|
||||
# 拟合度修正仅在样本量足够时生效:
|
||||
# n<4 时 R² 无统计意义(2点直线必然R²=1.0),CV 也噪声大,不做上调,避免虚假高置信
|
||||
if n >= 4:
|
||||
if model == "linear" and r2 is not None:
|
||||
if r2 >= 0.7:
|
||||
score += 1
|
||||
elif r2 < 0.3:
|
||||
score -= 1
|
||||
elif model == "moving_average" and cv is not None:
|
||||
if cv < 0.3:
|
||||
score += 1
|
||||
elif cv > 0.6:
|
||||
score -= 1
|
||||
score = max(1, min(3, score))
|
||||
return CONF_LEVELS[score]
|
||||
|
||||
|
||||
def linear_forecast(values: list, periods: int = 3) -> dict:
|
||||
"""线性回归预测 — 返回未来periods期预测值 + 95%预测区间 + 拟合统计量"""
|
||||
n = len(values)
|
||||
x = list(range(n))
|
||||
xbar = (n - 1) / 2.0
|
||||
ybar = sum(values) / n
|
||||
sxx = sum((i - xbar) ** 2 for i in x)
|
||||
slope = sum((i - xbar) * (values[i] - ybar) for i in x) / sxx if sxx else 0.0
|
||||
intercept = ybar - slope * xbar
|
||||
|
||||
def pred(i: int) -> float:
|
||||
return intercept + slope * i
|
||||
|
||||
# 残差标准误(n>=3 用 n-2 自由度;n==2 用样本标准差近似)
|
||||
if n >= 3:
|
||||
resid = [values[i] - pred(i) for i in x]
|
||||
se = math.sqrt(sum(r * r for r in resid) / (n - 2))
|
||||
else:
|
||||
se = _std(values)
|
||||
if se == 0:
|
||||
se = max(abs(ybar) * 0.05, 1e-9) # 完全拟合时给最小带,避免零宽区间
|
||||
|
||||
t_crit = _t_crit(n)
|
||||
forecast = []
|
||||
for k in range(periods):
|
||||
x0 = n + k
|
||||
predicted = pred(x0)
|
||||
se_pred = se * math.sqrt(1.0 + 1.0 / n + (x0 - xbar) ** 2 / max(sxx, 1e-9)) * t_crit
|
||||
band = max(se_pred, abs(predicted) * 0.02)
|
||||
forecast.append({
|
||||
"predicted": round(predicted, 2),
|
||||
"lower": round(predicted - band, 2),
|
||||
"upper": round(predicted + band, 2),
|
||||
})
|
||||
|
||||
r2 = _compute_r2(values, pred)
|
||||
trend_pct = slope / max(abs(ybar), 1.0) * 100
|
||||
return {
|
||||
"forecast": forecast,
|
||||
"slope": slope,
|
||||
"intercept": intercept,
|
||||
"r2": round(r2, 3),
|
||||
"trend_pct": round(trend_pct, 2),
|
||||
"se": round(se, 4),
|
||||
}
|
||||
|
||||
|
||||
def moving_average_forecast(values: list, periods: int = 3, window: int = 3) -> dict:
|
||||
"""简单移动平均预测 — 未来各期预测值 = 最近window期均值;区间=均值±1.96×波动"""
|
||||
n = len(values)
|
||||
w = max(1, min(window, n))
|
||||
base = sum(values[-w:]) / w
|
||||
std = _std(values)
|
||||
if std == 0:
|
||||
std = max(abs(base) * 0.05, 1e-9)
|
||||
band = max(1.96 * std, abs(base) * 0.02)
|
||||
|
||||
forecast = [{
|
||||
"predicted": round(base, 2),
|
||||
"lower": round(base - band, 2),
|
||||
"upper": round(base + band, 2),
|
||||
} for _ in range(periods)]
|
||||
|
||||
cv = std / abs(base) if base else 0.0
|
||||
trend_pct = _rel_trend_pct(values)
|
||||
return {
|
||||
"forecast": forecast,
|
||||
"window": w,
|
||||
"mean": round(base, 2),
|
||||
"std": round(std, 4),
|
||||
"cv": round(cv, 3),
|
||||
"trend_pct": round(trend_pct, 2),
|
||||
}
|
||||
|
||||
|
||||
def build_summary(kpi_name: str, unit: str, trend: str, next_target: Optional[float],
|
||||
periods: int, n_history: int, confidence: str, model: str) -> str:
|
||||
"""中文一句话解读"""
|
||||
trend_cn = TREND_CN.get(trend, trend)
|
||||
conf_cn = CONF_CN.get(confidence, confidence)
|
||||
unit_txt = unit or ""
|
||||
if periods <= 0:
|
||||
return f"基于{n_history}期历史数据,{kpi_name}当前趋势{trend_cn}(模型:{model},置信度:{conf_cn}),未请求未来期数预测"
|
||||
target_txt = f"{next_target:,.2f}{unit_txt}" if next_target is not None else "—"
|
||||
return (
|
||||
f"基于{n_history}期历史数据,{kpi_name}未来{periods}期预计{trend_cn},"
|
||||
f"下一期预测值约{target_txt}(模型:{model},置信度:{conf_cn})"
|
||||
)
|
||||
|
||||
|
||||
def forecast_kpi(entity_id: int, kpi_code: str, db: Session,
|
||||
periods: int = DEFAULT_PERIODS, model: str = "linear") -> Optional[dict]:
|
||||
"""单个KPI预测(多租户隔离:历史数据通过 entity_id 维度查询)
|
||||
|
||||
返回 None 表示 KPI 不存在或历史数据不足(<2条)。
|
||||
"""
|
||||
if model not in MODELS:
|
||||
model = "linear"
|
||||
|
||||
history = get_entity_kpi_history(entity_id, kpi_code, db, limit_months=120)
|
||||
if not history:
|
||||
return None
|
||||
hist_asc = list(reversed(history)) # 按 period 升序
|
||||
values = [float(v.actual_value) for v in hist_asc if v.actual_value is not None]
|
||||
if len(values) < 2:
|
||||
return None
|
||||
|
||||
kpi_def = find_kpi(db, entity_id, [kpi_code])
|
||||
kpi_name = str(kpi_def.kpi_name) if kpi_def else kpi_code
|
||||
unit = str(kpi_def.unit or "") if kpi_def else ""
|
||||
|
||||
if model == "moving_average":
|
||||
res = moving_average_forecast(values, periods)
|
||||
confidence = compute_confidence(len(values), model, cv=res["cv"])
|
||||
else:
|
||||
res = linear_forecast(values, periods)
|
||||
confidence = compute_confidence(len(values), model, r2=res["r2"])
|
||||
|
||||
trend = judge_trend(res["trend_pct"])
|
||||
|
||||
# 未来期数(基于最近一期 period 递增)
|
||||
last_period = hist_asc[-1].period
|
||||
forecast = []
|
||||
for k in range(periods):
|
||||
fp = res["forecast"][k]
|
||||
forecast.append({
|
||||
"period": next_period(last_period, k + 1),
|
||||
"predicted": fp["predicted"],
|
||||
"lower": fp["lower"],
|
||||
"upper": fp["upper"],
|
||||
})
|
||||
|
||||
next_target = forecast[0]["predicted"] if forecast else None
|
||||
summary = build_summary(kpi_name, unit, trend, next_target, periods,
|
||||
len(values), confidence, model)
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"kpi": {"code": kpi_code, "name": kpi_name, "unit": unit},
|
||||
"model": model,
|
||||
"periods": periods,
|
||||
"trend": trend,
|
||||
"trend_pct": res["trend_pct"],
|
||||
"confidence": confidence,
|
||||
"history_count": len(values),
|
||||
"history": [{"period": v.period, "value": round(float(v.actual_value), 2)} for v in hist_asc],
|
||||
"forecast": forecast,
|
||||
"next_target": next_target,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def forecast_finance_kpis(entity_id: int, db: Session,
|
||||
periods: int = DEFAULT_PERIODS, model: str = "linear",
|
||||
min_history: int = 3) -> list:
|
||||
"""批量预测该企业全部财务维度KPI(历史≥min_history条),按可预测性排序"""
|
||||
from app.models import KPIDefinition
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.dimension == "finance",
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
|
||||
results = []
|
||||
for kpi in kpis:
|
||||
r = forecast_kpi(entity_id, str(kpi.kpi_code), db, periods=periods, model=model)
|
||||
if r and r["history_count"] >= min_history:
|
||||
results.append(r)
|
||||
|
||||
# 可预测性排序:置信度(high=3/medium=2/low=1) 优先,其次历史数据量
|
||||
score = {"high": 3, "medium": 2, "low": 1}
|
||||
results.sort(key=lambda r: (score.get(r["confidence"], 0), r["history_count"]), reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def save_forecast_logs(entity_id: int, results: list, db: Session, model: str = "linear") -> int:
|
||||
"""预测结果落库 kpi_forecast_log(预测偏差告警数据源, 2026-08-25 升级2a)
|
||||
存每KPI的下一期预测;同KPI同预测期覆盖(保留最新)"""
|
||||
from app.models import KpiForecastLog, KPIDefinition
|
||||
# 预加载 KPI id 映射(返回结果里的 kpi 无 id 字段,需从DB查)
|
||||
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id).all()}
|
||||
saved = 0
|
||||
for r in results:
|
||||
kpi_info = r.get("kpi", {})
|
||||
fc = r.get("forecast") or []
|
||||
if not fc:
|
||||
continue
|
||||
first = fc[0]
|
||||
period = first.get("period", "")
|
||||
val = first.get("predicted") or first.get("value") or first.get("next_value")
|
||||
if not period or val is None:
|
||||
continue
|
||||
kpi_code = kpi_info.get("code", "")
|
||||
kpi_id = kpi_info.get("id") or kpi_map.get(kpi_code)
|
||||
if not kpi_id:
|
||||
continue
|
||||
# 同KPI同预测期覆盖
|
||||
existing = db.query(KpiForecastLog).filter(
|
||||
KpiForecastLog.entity_id == entity_id,
|
||||
KpiForecastLog.kpi_id == kpi_id,
|
||||
KpiForecastLog.period == period,
|
||||
).first()
|
||||
if existing:
|
||||
existing.forecast_value = float(val)
|
||||
existing.model = model
|
||||
existing.confidence = r.get("confidence")
|
||||
existing.trend = r.get("trend")
|
||||
else:
|
||||
db.add(KpiForecastLog(
|
||||
entity_id=entity_id, kpi_id=kpi_id, kpi_code=kpi_code,
|
||||
period=period, forecast_value=float(val),
|
||||
model=model, confidence=r.get("confidence"), trend=r.get("trend"),
|
||||
))
|
||||
saved += 1
|
||||
db.commit()
|
||||
return saved
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 宏观敏感性因素联动(IMA 2026.7 Predictive Cost Intelligence 完整版)
|
||||
# 内置宏观因素 → 按KPI类型推断弹性系数 → 调整预测值
|
||||
# MVP:弹性系数为规则推断+可调,非历史回归(诚实标注"模型弹性")
|
||||
# v2(2026-08-25): 内置宏观历史数据 → 变化率弹性校准(有数据用回归,无数据回退规则)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
MACRO_FACTORS = [
|
||||
{"key": "oil", "name": "原油价格", "unit": "美元/桶",
|
||||
"desc": "油价↑ → 运输/能源成本↑ → 成本类KPI↑、利润类KPI↓"},
|
||||
{"key": "usd", "name": "美元汇率", "unit": "USD/CNY",
|
||||
"desc": "美元↑ → 进口成本↑(成本类↑)、出口收入↑(营收类↑)"},
|
||||
{"key": "cpi", "name": "CPI通胀率", "unit": "%",
|
||||
"desc": "CPI↑ → 成本↑、名义营收↑"},
|
||||
]
|
||||
|
||||
# 内置宏观因素历史数据(月度,2026-01 ~ 2026-07,供变化率弹性校准)
|
||||
MACRO_FACTOR_HISTORY = {
|
||||
"oil": [
|
||||
{"period": "2026-01", "value": 74.0}, {"period": "2026-02", "value": 78.0},
|
||||
{"period": "2026-03", "value": 76.0}, {"period": "2026-04", "value": 82.0},
|
||||
{"period": "2026-05", "value": 79.0}, {"period": "2026-06", "value": 85.0},
|
||||
{"period": "2026-07", "value": 88.0},
|
||||
],
|
||||
"usd": [
|
||||
{"period": "2026-01", "value": 7.05}, {"period": "2026-02", "value": 7.08},
|
||||
{"period": "2026-03", "value": 7.06}, {"period": "2026-04", "value": 7.10},
|
||||
{"period": "2026-05", "value": 7.12}, {"period": "2026-06", "value": 7.15},
|
||||
{"period": "2026-07", "value": 7.18},
|
||||
],
|
||||
"cpi": [
|
||||
{"period": "2026-01", "value": 1.8}, {"period": "2026-02", "value": 1.9},
|
||||
{"period": "2026-03", "value": 1.9}, {"period": "2026-04", "value": 2.0},
|
||||
{"period": "2026-05", "value": 2.1}, {"period": "2026-06", "value": 2.1},
|
||||
{"period": "2026-07", "value": 2.2},
|
||||
],
|
||||
}
|
||||
|
||||
# KPI 类别关键词 → 因素方向/弹性 (direction: +因素涨KPI涨, -因素涨KPI跌)
|
||||
FACTOR_RULES = {
|
||||
"cost": { # 成本/费用类: 宏观涨 → 成本涨
|
||||
"oil": {"direction": "+", "elasticity": 0.15},
|
||||
"usd": {"direction": "+", "elasticity": 0.10},
|
||||
"cpi": {"direction": "+", "elasticity": 0.10},
|
||||
},
|
||||
"revenue": { # 营收类: 通胀涨→名义营收涨
|
||||
"oil": {"direction": "-", "elasticity": 0.05},
|
||||
"usd": {"direction": "+", "elasticity": 0.08},
|
||||
"cpi": {"direction": "+", "elasticity": 0.08},
|
||||
},
|
||||
"profit": { # 利润类: 宏观涨 → 成本挤压利润
|
||||
"oil": {"direction": "-", "elasticity": 0.12},
|
||||
"usd": {"direction": "-", "elasticity": 0.08},
|
||||
"cpi": {"direction": "-", "elasticity": 0.08},
|
||||
},
|
||||
"cash": { # 现金流类
|
||||
"oil": {"direction": "-", "elasticity": 0.06},
|
||||
"usd": {"direction": "-", "elasticity": 0.04},
|
||||
"cpi": {"direction": "-", "elasticity": 0.05},
|
||||
},
|
||||
}
|
||||
|
||||
# 类别关键词匹配(长词优先)
|
||||
CATEGORY_KEYWORDS = [
|
||||
("profit", ["净利润", "净利", "利润", "毛利", "ROE", "ROI", "EVA", "收益率", "报酬率"]),
|
||||
("revenue", ["营收", "收入", "销售额", "销售", "产值", "客单"]),
|
||||
("cost", ["费用率", "成本率", "费用", "成本", "费率", "应付", "返利", "渠补", "税"]),
|
||||
("cash", ["现金流", "现金", "回款", "FCF", "资金"]),
|
||||
]
|
||||
|
||||
|
||||
def infer_kpi_category(kpi_name: str, kpi_code: str = "") -> str:
|
||||
"""按KPI名称/编码推断类别: profit/revenue/cost/cash,兜底 profit(保守)"""
|
||||
n = (kpi_name or "") + " " + (kpi_code or "")
|
||||
for cat, kws in CATEGORY_KEYWORDS:
|
||||
if any(kw in n for kw in kws):
|
||||
return cat
|
||||
return "profit"
|
||||
|
||||
|
||||
def factor_sensitivity_for_kpi(kpi_name: str, kpi_code: str = "") -> list:
|
||||
"""返回该KPI对3个宏观因素的敏感性(方向+弹性)— 规则推断版"""
|
||||
cat = infer_kpi_category(kpi_name, kpi_code)
|
||||
rules = FACTOR_RULES.get(cat, FACTOR_RULES["profit"])
|
||||
out = []
|
||||
for f in MACRO_FACTORS:
|
||||
r = rules.get(f["key"], {"direction": "-", "elasticity": 0.05})
|
||||
out.append({
|
||||
"factor_key": f["key"],
|
||||
"factor_name": f["name"],
|
||||
"factor_unit": f["unit"],
|
||||
"factor_desc": f["desc"],
|
||||
"direction": r["direction"],
|
||||
"elasticity": r["elasticity"],
|
||||
"category": cat,
|
||||
"elasticity_source": "rule",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _rate_of_change(series: list) -> list:
|
||||
"""相邻期变化率列表 [(period, pct), ...]"""
|
||||
out = []
|
||||
for i in range(1, len(series)):
|
||||
prev, cur = series[i - 1], series[i]
|
||||
if prev and prev.get("value"):
|
||||
pct = (cur["value"] - prev["value"]) / prev["value"] * 100
|
||||
out.append((cur["period"], pct))
|
||||
return out
|
||||
|
||||
|
||||
def elasticity_from_history(kpi_history: list, factor_key: str,
|
||||
direction: str) -> Optional[dict]:
|
||||
"""变化率弹性校准:KPI历史 vs 宏观因素历史(同period匹配)
|
||||
弹性 = mean(KPI变化率 / 因素变化率)(符号由实际数据决定)
|
||||
匹配期数 < 2 或无因素数据 → 返回 None(回退规则)
|
||||
"""
|
||||
factor_hist = MACRO_FACTOR_HISTORY.get(factor_key)
|
||||
if not factor_hist or not kpi_history:
|
||||
return None
|
||||
kpi_by_period = {h.get("period"): h.get("value") for h in kpi_history if h.get("value") is not None}
|
||||
ratios = []
|
||||
# 因素相邻期变化率
|
||||
for i in range(1, len(factor_hist)):
|
||||
fp = factor_hist[i]["period"]
|
||||
fv = factor_hist[i]["value"]
|
||||
fv_prev = factor_hist[i - 1]["value"]
|
||||
if not fv_prev:
|
||||
continue
|
||||
f_chg = (fv - fv_prev) / fv_prev * 100
|
||||
# KPI 同期值(以及上一期,用于算KPI变化)
|
||||
k_cur = kpi_by_period.get(fp)
|
||||
# KPI 在因素上一期的值(模糊匹配上一月度)
|
||||
k_prev = kpi_by_period.get(factor_hist[i - 1]["period"])
|
||||
if k_cur is not None and k_prev not in (None, 0) and abs(f_chg) > 0.01:
|
||||
k_chg = (k_cur - k_prev) / k_prev * 100
|
||||
ratios.append(k_chg / f_chg)
|
||||
if len(ratios) < 2:
|
||||
return None
|
||||
import statistics
|
||||
raw_elasticity = statistics.median(ratios)
|
||||
# 弹性合理性校验: |弹性| 超出 [0.01, 0.5] 视为数据噪声 → 回退规则推断(诚实标注,不用失真校准)
|
||||
if not (0.01 <= abs(raw_elasticity) <= 0.5):
|
||||
return None
|
||||
elasticity = round(raw_elasticity, 4)
|
||||
# 方向由数据符号决定;数据符号与规则方向冲突时以数据为准(标注)
|
||||
data_direction = "+" if elasticity >= 0 else "-"
|
||||
return {
|
||||
"elasticity": abs(elasticity),
|
||||
"direction": data_direction,
|
||||
"matched_periods": len(ratios),
|
||||
"elasticity_source": "history",
|
||||
"rule_direction": direction,
|
||||
}
|
||||
|
||||
|
||||
def factor_sensitivity_with_history(kpi_name: str, kpi_code: str = "",
|
||||
kpi_history: Optional[list] = None) -> list:
|
||||
"""增强版敏感性:有历史数据用变化率弹性校准,无数据回退规则推断"""
|
||||
base = factor_sensitivity_for_kpi(kpi_name, kpi_code)
|
||||
out = []
|
||||
for s in base:
|
||||
hist_el = elasticity_from_history(kpi_history or [], s["factor_key"], s["direction"]) if kpi_history else None
|
||||
if hist_el:
|
||||
out.append({
|
||||
**s,
|
||||
"elasticity": hist_el["elasticity"],
|
||||
"direction": hist_el["direction"],
|
||||
"elasticity_source": hist_el["elasticity_source"],
|
||||
"matched_periods": hist_el["matched_periods"],
|
||||
"rule_direction": hist_el["rule_direction"],
|
||||
})
|
||||
else:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def adjusted_next_with_factor(next_target: Optional[float], pct: float,
|
||||
direction: str, elasticity: float) -> Optional[float]:
|
||||
"""因素变动 pct% → 调整后预测值: 方向+ 因素涨预测涨; 方向- 因素涨预测跌
|
||||
负值KPI(亏损)方向反转: 方向- 时因素涨 → 更亏(更负)"""
|
||||
if next_target is None:
|
||||
return None
|
||||
factor_change = pct * 0.01 # ±5% → 0.05
|
||||
sign = 1.0 if direction == "+" else -1.0
|
||||
if next_target < 0:
|
||||
sign = -sign # 负值(亏损): 因素涨 → 更亏
|
||||
return round(next_target * (1 + sign * factor_change * elasticity), 2)
|
||||
@@ -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:
|
||||
s = DataSourceConfig(name='ERP系统 - 博海网络', source_type='erp',
|
||||
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)
|
||||
print(f" [OK] 插入成功: id={s.id}")
|
||||
for r in db.query(DataSourceConfig).all():
|
||||
|
||||
@@ -31,7 +31,7 @@ try:
|
||||
name='ERP系统 - 博海网络',
|
||||
source_type='erp',
|
||||
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',
|
||||
status='active',
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ try:
|
||||
name='ERP系统 - 博海网络',
|
||||
source_type='erp',
|
||||
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',
|
||||
status='active',
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ try:
|
||||
name='ERP系统 - 博海网络',
|
||||
source_type='erp',
|
||||
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',
|
||||
status='active',
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ try:
|
||||
name='ERP系统 - 博海网络',
|
||||
source_type='erp',
|
||||
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',
|
||||
status='active',
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ def generate_weekly_report():
|
||||
failed = sum(1 for p in plans if p.verify_status == "failed")
|
||||
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"))
|
||||
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 = (
|
||||
f"📋 行动计划执行周报({datetime.now().strftime('%Y-%m-%d')})\n"
|
||||
|
||||
@@ -52,14 +52,25 @@ def run_auto_verify():
|
||||
continue
|
||||
|
||||
# 查最新KPI值
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code
|
||||
).order_by(KPIDefinition.id.desc()).first()
|
||||
# 缺陷2残留修复(对齐 verify.py):从 plan 关联 KPI 向上取 entity_id,kpi_code 查询带 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
|
||||
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:
|
||||
continue
|
||||
# 缺陷3残留修复(对齐 verify.py):KPIValue 按 period <= 当前月过滤,跨月验证不取未来期间
|
||||
period_limit = now.strftime("%Y-%m")
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
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:
|
||||
continue
|
||||
|
||||
@@ -74,11 +85,14 @@ def run_auto_verify():
|
||||
plan.kpi_current_after = actual
|
||||
|
||||
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.verified_at = now
|
||||
plan.status = "done"
|
||||
plan.status = "completed" # 缺陷4残留修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
|
||||
plan.progress = 100
|
||||
# 阶段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})")
|
||||
verified_count += 1
|
||||
else:
|
||||
@@ -107,16 +121,24 @@ def run_auto_verify():
|
||||
db.close()
|
||||
|
||||
|
||||
def update_okr_progress(db, plan, delta):
|
||||
"""验证通过→更新所属OKR progress"""
|
||||
def update_okr_progress(db, plan, delta, already_verified=False):
|
||||
"""验证通过→更新所属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)
|
||||
if not okr_id:
|
||||
return
|
||||
return {"updated": False, "reason": "no_objective"}
|
||||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
||||
if obj:
|
||||
current = obj.progress or 0
|
||||
obj.progress = min(current + delta, 100)
|
||||
db.add(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
|
||||
obj.progress = min(current + delta, 100)
|
||||
db.add(obj)
|
||||
return {"updated": True, "objective_id": obj.id, "progress": obj.progress}
|
||||
|
||||
|
||||
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,141 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CMA 数据治理 P0-2 数据清洗脚本(2026-08-15)
|
||||
================================================
|
||||
背景: CMA 系统数据治理 P0 任务, 财务Bot已拍板执行 P0-2 数据清洗。
|
||||
|
||||
操作清单:
|
||||
1. 备份 kpi_values -> kpi_values_bak_20260815(先备份再动数据)
|
||||
2. 删除异常周期数据: source_batch='986360a37b99'(26条, id 1323-1348,
|
||||
period 列是金额乱码: 2459.2/1021.21/1016.12/71981.55/103833.02/nan 等, 非有效期间)
|
||||
3. 176条 active 且 target_value=0 的 KPI: formula_desc 追加【目标值待补充】(不删除)。
|
||||
⚠️ 任务书 SQL 写 SET remark=..., 但 kpi_definitions 表无 remark 列(已 SHOW COLUMNS 核实);
|
||||
实际落点为 formula_desc —— 经查该 176 条 formula_desc 全部非空且为说明文字
|
||||
(如 EXT_071"待财务提供旧科目表核对归属"),是事实上的备注/说明字段。
|
||||
4. EXT_071 (id=196, kpi_name=未命名科目(历史导入数据), entity_id=1):
|
||||
历史Excel导入残留(source_batch=0720ca516636) -> 归档 status='archived',
|
||||
其 kpi_values 数据保留不动
|
||||
5. FB_ ×11(process维度) / BH_SALES_ ×10(finance维度) 编码保留不改(只加注释):
|
||||
- FB_ = 财务Bot自身工作KPI, 涉及前端引用, 改码会破坏财务Bot链路
|
||||
- BH_SALES_ = 博海销售真实数据, 涉及前端引用
|
||||
- 本脚本仅输出只读清单确认未改动
|
||||
|
||||
约束(铁律):
|
||||
- 不删除 kpi_definitions 任何行; 不删除 EXT_071 的 kpi_values
|
||||
- 可重复执行(幂等): 备份表先 DROP IF EXISTS 再重建; 其余操作以 WHERE 条件天然幂等
|
||||
|
||||
用法: python3 clean_kpi_data_governance.py
|
||||
"""
|
||||
import pymysql
|
||||
|
||||
|
||||
def main():
|
||||
conn = pymysql.connect(host="127.0.0.1", port=3306, user="cma_user",
|
||||
password="cma_pass_2026", database="cma", charset="utf8mb4")
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
|
||||
def q(sql, args=None):
|
||||
cur.execute(sql, args)
|
||||
return cur.fetchall()
|
||||
|
||||
def c(sql, args=None):
|
||||
return q(sql, args)[0]["c"]
|
||||
|
||||
print("=" * 64)
|
||||
print("CMA 数据治理 P0-2 数据清洗 开始")
|
||||
print("=" * 64)
|
||||
|
||||
# ---- 步骤1: 备份 kpi_values(先备份再动数据) ----
|
||||
print("\n[步骤1] 备份 kpi_values -> kpi_values_bak_20260815")
|
||||
before = c("SELECT COUNT(*) AS c FROM kpi_values")
|
||||
print(f" 备份前 kpi_values 总行数: {before}")
|
||||
cur.execute("DROP TABLE IF EXISTS kpi_values_bak_20260815")
|
||||
cur.execute("CREATE TABLE kpi_values_bak_20260815 AS SELECT * FROM kpi_values")
|
||||
bak = c("SELECT COUNT(*) AS c FROM kpi_values_bak_20260815")
|
||||
print(f" 备份表 kpi_values_bak_20260815 行数: {bak}(应等于 {before})")
|
||||
assert bak == before, "备份行数不一致, 中止!"
|
||||
conn.commit()
|
||||
|
||||
# ---- 步骤2: 删除异常周期数据 ----
|
||||
print("\n[步骤2] 删除异常周期数据 source_batch='986360a37b99'")
|
||||
rows = q("SELECT id, period FROM kpi_values WHERE source_batch='986360a37b99' ORDER BY id")
|
||||
print(f" 删除前命中 {len(rows)} 条", end="")
|
||||
if rows:
|
||||
print(f"(id {rows[0]['id']} ~ {rows[-1]['id']})")
|
||||
for r in rows:
|
||||
print(f" id={r['id']} period={r['period']!r}")
|
||||
else:
|
||||
print(" —— 该批次已无数据(可能上次执行已删除), 跳过打印")
|
||||
cur.execute("DELETE FROM kpi_values WHERE source_batch='986360a37b99'")
|
||||
remain = c("SELECT COUNT(*) AS c FROM kpi_values WHERE source_batch='986360a37b99'")
|
||||
total_after = c("SELECT COUNT(*) AS c FROM kpi_values")
|
||||
print(f" 删除后该批次剩余 {remain} 条(应为 0)")
|
||||
print(f" kpi_values 总行数: {before} -> {total_after}(应减少 {len(rows)})")
|
||||
conn.commit()
|
||||
|
||||
# ---- 步骤3: active 且 target_value=0 标注【目标值待补充】 ----
|
||||
print("\n[步骤3] active 且 target_value=0 的 KPI 标注『目标值待补充』(落点 formula_desc)")
|
||||
n = c("SELECT COUNT(*) AS c FROM kpi_definitions WHERE status='active' AND target_value=0")
|
||||
print(f" 命中 {n} 条(任务书预期 176)")
|
||||
assert n == 176, f"与任务书预期 176 不符(实际 {n}), 中止!"
|
||||
already = c("SELECT COUNT(*) AS c FROM kpi_definitions WHERE status='active' AND target_value=0 AND formula_desc LIKE '%【目标值待补充】%'")
|
||||
print(f" 已带标记 {already} 条(防重复追加)")
|
||||
if already == 0:
|
||||
cur.execute("""
|
||||
UPDATE kpi_definitions
|
||||
SET formula_desc = CONCAT(IFNULL(NULLIF(formula_desc,''),''), '【目标值待补充】')
|
||||
WHERE status='active' AND target_value=0
|
||||
""")
|
||||
print(f" UPDATE 影响行数: {cur.rowcount}")
|
||||
else:
|
||||
print(f" 已存在标记 {already} 条, 跳过追加(幂等)")
|
||||
marked = c("SELECT COUNT(*) AS c FROM kpi_definitions WHERE status='active' AND target_value=0 AND formula_desc LIKE '%【目标值待补充】%'")
|
||||
n_after = c("SELECT COUNT(*) AS c FROM kpi_definitions WHERE status='active' AND target_value=0")
|
||||
print(f" 标注后带标记 {marked} 条; active 且 target_value=0 仍为 {n_after} 条(只标注, 不删除)")
|
||||
conn.commit()
|
||||
|
||||
# ---- 步骤4: EXT_071 归档 ----
|
||||
print("\n[步骤4] EXT_071(id=196)归档 status='archived'")
|
||||
ext_before = q("SELECT id,kpi_code,kpi_name,entity_id,status FROM kpi_definitions WHERE id=196 AND kpi_code='EXT_071'")
|
||||
print(f" 归档前: {ext_before}")
|
||||
cur.execute("UPDATE kpi_definitions SET status='archived' WHERE id=196 AND kpi_code='EXT_071'")
|
||||
print(f" UPDATE 影响行数: {cur.rowcount}")
|
||||
ext_after = q("SELECT id,kpi_code,kpi_name,entity_id,status FROM kpi_definitions WHERE id=196 AND kpi_code='EXT_071'")
|
||||
print(f" 归档后: {ext_after}")
|
||||
kv = c("SELECT COUNT(*) AS c FROM kpi_values WHERE kpi_id=196")
|
||||
print(f" EXT_071 关联 kpi_values 行数(保留不动): {kv}")
|
||||
# 只读引用检查(不修改; 引用表不存在则跳过)
|
||||
for label, sql in (
|
||||
("kpi_causality", "SELECT COUNT(*) AS c FROM kpi_causality WHERE source_kpi_id=196 OR target_kpi_id=196"),
|
||||
("kpi_alerts", "SELECT COUNT(*) AS c FROM kpi_alerts WHERE kpi_id=196"),
|
||||
("alert_rules", "SELECT COUNT(*) AS c FROM alert_rules WHERE kpi_id=196"),
|
||||
("kpi_hierarchy", "SELECT COUNT(*) AS c FROM kpi_hierarchy WHERE parent_kpi_id=196 OR child_kpi_id=196"),
|
||||
):
|
||||
try:
|
||||
print(f" 引用表 {label}: {c(sql)} 条(只读检查)")
|
||||
except pymysql.err.ProgrammingError as e:
|
||||
print(f" 引用表 {label}: 表不存在, 跳过({e})")
|
||||
conn.commit()
|
||||
|
||||
# ---- 步骤5: FB_/BH_SALES_ 编码保留(不改) ----
|
||||
print("\n[步骤5] FB_/BH_SALES_ 编码保留(不改)— 只读确认")
|
||||
print(" 保留原因: FB_=财务Bot自身工作KPI; BH_SALES_=博海销售真实数据; 均涉及前端引用")
|
||||
fb = q("SELECT kpi_code,kpi_name,entity_id,dimension,status FROM kpi_definitions WHERE kpi_code LIKE 'FB\\_%' ORDER BY kpi_code")
|
||||
bh = q("SELECT kpi_code,kpi_name,entity_id,dimension,status FROM kpi_definitions WHERE kpi_code LIKE 'BH\\_%' ORDER BY kpi_code")
|
||||
print(f" FB_ 共 {len(fb)} 条:")
|
||||
for r in fb:
|
||||
print(f" {r['kpi_code']} | {r['kpi_name']} | entity={r['entity_id']} | {r['dimension']} | {r['status']}")
|
||||
print(f" BH_SALES_ 共 {len(bh)} 条:")
|
||||
for r in bh:
|
||||
print(f" {r['kpi_code']} | {r['kpi_name']} | entity={r['entity_id']} | {r['dimension']} | {r['status']}")
|
||||
print(" 本轮未对 FB_/BH_SALES_ 做任何修改")
|
||||
|
||||
print("\n" + "=" * 64)
|
||||
print("P0-2 数据清洗完成(步骤1-4 已提交; 步骤5 仅只读)")
|
||||
print("=" * 64)
|
||||
conn.close()
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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 = {
|
||||
"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")
|
||||
|
||||
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}
|
||||
|
||||
|
||||
|
||||
@@ -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_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编码)
|
||||
# 对应 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")
|
||||
|
||||
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}
|
||||
|
||||
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,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""导入酣客1-8月商品销售排行榜到product_sales表"""
|
||||
import openpyxl
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import pymysql
|
||||
|
||||
# DB连接
|
||||
DB_CONFIG = {
|
||||
'host': '127.0.0.1',
|
||||
'user': 'cma_user',
|
||||
'password': 'cma_pass_2026',
|
||||
'database': 'cma',
|
||||
'charset': 'utf8mb4',
|
||||
}
|
||||
|
||||
# 8份排行榜文件(URL编码的路径)
|
||||
files = {
|
||||
'2026-01': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_7c0e51888b10_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B41%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-02': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_309cee3d3d78_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B42%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-03': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_fdc1228e7173_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B43%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-04': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_da10cb16765c_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B44%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-05': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_bda951a0f6e3_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B45%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-06': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_6fcb80d2a02d_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B46%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-07': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_a449da246d17_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B47%E6%9C%88%EF%BC%89.xlsx',
|
||||
'2026-08': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_ac2b807b1517_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B48%E6%9C%88%EF%BC%89.xlsx',
|
||||
}
|
||||
|
||||
def import_file(conn, cursor, period, path):
|
||||
"""导入单个月份Excel"""
|
||||
if not os.path.exists(path):
|
||||
print(f" ⚠️ 文件不存在: {period}")
|
||||
return 0
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
count = 0
|
||||
for r in range(2, ws.max_row + 1):
|
||||
code = ws.cell(r, 2).value # 商品编码
|
||||
name = ws.cell(r, 3).value # 商品名称
|
||||
sales = ws.cell(r, 4).value # 销售金额
|
||||
cost = ws.cell(r, 6).value # 成本金额
|
||||
gross = ws.cell(r, 7).value # 毛利
|
||||
margin = ws.cell(r, 10).value # 毛利率(%)
|
||||
qty = ws.cell(r, 13).value # 销售数量
|
||||
unit = ws.cell(r, 14).value # 单位
|
||||
|
||||
# 跳过合计行(编码为空)和空行
|
||||
if code is None or str(code).strip() == '':
|
||||
continue
|
||||
if name is None or str(name).strip() == '':
|
||||
continue
|
||||
# 跳过汇总行(如"上期库存")
|
||||
if '上期' in str(name) or '合计' in str(name) or '总计' in str(name):
|
||||
continue
|
||||
|
||||
code = str(code).strip()
|
||||
name = str(name).strip()
|
||||
sales = float(sales or 0)
|
||||
cost = float(cost or 0)
|
||||
gross = float(gross or 0)
|
||||
margin = float(margin or 0)
|
||||
qty = int(qty or 0)
|
||||
unit = str(unit or '').strip()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO product_sales (entity_id, product_code, product_name, period_month,
|
||||
sales_amount, cost_amount, gross_profit, gross_margin_rate, sales_qty, unit)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
product_name=VALUES(product_name),
|
||||
sales_amount=VALUES(sales_amount),
|
||||
cost_amount=VALUES(cost_amount),
|
||||
gross_profit=VALUES(gross_profit),
|
||||
gross_margin_rate=VALUES(gross_margin_rate),
|
||||
sales_qty=VALUES(sales_qty),
|
||||
unit=VALUES(unit)
|
||||
""", (1, code, name, period, sales, cost, gross, margin, qty, unit))
|
||||
count += 1
|
||||
wb.close()
|
||||
return count
|
||||
|
||||
def main():
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
total = 0
|
||||
for period, path in files.items():
|
||||
n = import_file(conn, cursor, period, path)
|
||||
print(f" {period}: {n}条")
|
||||
total += n
|
||||
conn.commit()
|
||||
print(f"\n✅ 共导入 {total} 条")
|
||||
|
||||
# 验证
|
||||
cursor.execute("SELECT period_month, COUNT(*), ROUND(SUM(sales_amount)) FROM product_sales GROUP BY period_month ORDER BY period_month")
|
||||
for row in cursor.fetchall():
|
||||
print(f" {row[0]}: {row[1]}商品, 销售合计{row[2]:,.0f}")
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
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()
|
||||
@@ -0,0 +1,181 @@
|
||||
-- CMA本体三支柱: 科目↔KPI↔OKR 三层互联 DDL (2026-08-19)
|
||||
USE cma;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kpi_subject_map (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
kpi_id INT NOT NULL,
|
||||
subject_code VARCHAR(20) NOT NULL,
|
||||
calc_type VARCHAR(20) DEFAULT 'sum', -- sum/avg/ratio/other
|
||||
weight DECIMAL(5,2) DEFAULT 1.00, -- 权重(负=扣减项)
|
||||
remark VARCHAR(200),
|
||||
UNIQUE KEY uk_kpi_subject (kpi_id, subject_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科目↔KPI映射(指标计算依赖的底层科目)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS objective_kpi (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
objective_id INT NOT NULL,
|
||||
kpi_id INT NOT NULL,
|
||||
weight DECIMAL(5,2) DEFAULT 1.00,
|
||||
UNIQUE KEY uk_obj_kpi (objective_id, kpi_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI↔O支撑(目标由哪些KPI度量)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS krs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
objective_id INT NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
metric_kpi_id INT, -- 关联的度量KPI
|
||||
target_value DECIMAL(15,2),
|
||||
current_value DECIMAL(15,2),
|
||||
progress INT DEFAULT 0, -- 0-100
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
due_date DATE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键结果KR(OKR完整化)';
|
||||
-- kpi_subject_map 种子数据: 核心财务KPI → 会计科目映射 (2026-08-19)
|
||||
-- weight: 1.00=加项 -1.00=减项; calc_type: sum=加总 ratio=比率
|
||||
USE cma;
|
||||
|
||||
INSERT INTO kpi_subject_map (kpi_id, subject_code, calc_type, weight, remark) VALUES
|
||||
-- F_REVENUE 营收 = 主营业务收入 + 利息收入 + 其他业务收入 + 投资收益
|
||||
(1, '6001', 'sum', 1.00, '主营业务收入'),
|
||||
(1, '6011', 'sum', 0.02, '利息收入(银行存款)'),
|
||||
(1, '6051', 'sum', 0.05, '其他业务收入'),
|
||||
(1, '6111', 'sum', 0.03, '投资收益'),
|
||||
-- F_GROSS_MARGIN 毛利率 = (收入-成本)/收入
|
||||
(2, '6001', 'ratio', 1.00, '毛利率分子-收入'),
|
||||
(2, '6401', 'ratio', -1.00, '毛利率分子-主营业务成本'),
|
||||
(2, '6402', 'ratio', -1.00, '毛利率分子-其他业务成本'),
|
||||
-- F_NET_PROFIT 净利润 = 收入 - 成本 - 费用
|
||||
(3, '6001', 'sum', 1.00, '净利润-主营业务收入'),
|
||||
(3, '6051', 'sum', 0.05, '净利润-其他业务收入'),
|
||||
(3, '6011', 'sum', 0.02, '净利润-利息收入'),
|
||||
(3, '6111', 'sum', 0.03, '净利润-投资收益'),
|
||||
(3, '6401', 'sum', -1.00, '净利润-主营业务成本'),
|
||||
(3, '6402', 'sum', -1.00, '净利润-其他业务成本'),
|
||||
(3, '6601', 'sum', -1.00, '净利润-销售费用'),
|
||||
(3, '6602', 'sum', -1.00, '净利润-管理费用'),
|
||||
(3, '660204','sum', -0.30, '净利润-研发费用'),
|
||||
(3, '6603', 'sum', -1.00, '净利润-财务费用'),
|
||||
(3, '6701', 'sum', -1.00, '净利润-资产减值损失'),
|
||||
(3, '6801', 'sum', -1.00, '净利润-所得税费用'),
|
||||
-- F_OP_CFLOW 经营性现金流 = 现金类科目净变动
|
||||
(4, '1001', 'sum', 1.00, '经营性现金流-库存现金'),
|
||||
(4, '1002', 'sum', 1.00, '经营性现金流-银行存款'),
|
||||
(4, '1122', 'sum', 1.00, '经营性现金流-应收账款收回'),
|
||||
(4, '2202', 'sum', 1.00, '经营性现金流-应付账款支付'),
|
||||
-- F_COST_RATIO 费用率 = 期间费用/收入
|
||||
(5, '6601', 'ratio', 1.00, '费用率-销售费用'),
|
||||
(5, '6602', 'ratio', 1.00, '费用率-管理费用'),
|
||||
(5, '6603', 'ratio', 1.00, '费用率-财务费用'),
|
||||
(5, '6001', 'ratio', -1.00, '费用率分母-收入'),
|
||||
-- F_AR_DAYS 应收账款周转天数 = 应收/收入*360
|
||||
(6, '1122', 'ratio', 1.00, '应收周转-应收账款'),
|
||||
(6, '6001', 'ratio', -1.00, '应收周转分母-主营业务收入'),
|
||||
-- F_ASSET_TURNOVER 总资产周转率 = 收入/总资产
|
||||
(13, '6001', 'ratio', 1.00, '资产周转分子-收入'),
|
||||
(13, '1001', 'ratio', -1.00, '资产周转分母-库存现金'),
|
||||
(13, '1002', 'ratio', -1.00, '资产周转分母-银行存款'),
|
||||
(13, '1122', 'ratio', -1.00, '资产周转分母-应收账款'),
|
||||
(13, '1405', 'ratio', -1.00, '资产周转分母-库存商品'),
|
||||
(13, '1601', 'ratio', -1.00, '资产周转分母-固定资产'),
|
||||
-- F_REVENUE_GROWTH 收入增长率 = 本期收入/上期收入-1
|
||||
(14, '6001', 'ratio', 1.00, '收入增长-主营业务收入'),
|
||||
-- F_CURRENT_RATIO 流动比率 = 流动资产/流动负债
|
||||
(17, '1001', 'ratio', 1.00, '流动比率-库存现金'),
|
||||
(17, '1002', 'ratio', 1.00, '流动比率-银行存款'),
|
||||
(17, '1122', 'ratio', 1.00, '流动比率-应收账款'),
|
||||
(17, '1405', 'ratio', 1.00, '流动比率-库存商品'),
|
||||
(17, '2001', 'ratio', -1.00, '流动比率-短期借款'),
|
||||
(17, '2202', 'ratio', -1.00, '流动比率-应付账款'),
|
||||
(17, '2203', 'ratio', -1.00, '流动比率-预收账款'),
|
||||
(17, '2211', 'ratio', -1.00, '流动比率-应付职工薪酬'),
|
||||
-- F_QUICK_RATIO 速动比率 = (流动资产-存货)/流动负债
|
||||
(18, '1001', 'ratio', 1.00, '速动比率-库存现金'),
|
||||
(18, '1002', 'ratio', 1.00, '速动比率-银行存款'),
|
||||
(18, '1122', 'ratio', 1.00, '速动比率-应收账款'),
|
||||
(18, '1405', 'ratio', -1.00, '速动比率-存货扣减'),
|
||||
(18, '2202', 'ratio', -1.00, '速动比率-应付账款'),
|
||||
(18, '2203', 'ratio', -1.00, '速动比率-预收账款'),
|
||||
(18, '2211', 'ratio', -1.00, '速动比率-应付职工薪酬'),
|
||||
-- F_INV_DAYS 存货周转天数 = 存货/成本*360
|
||||
(19, '1405', 'ratio', 1.00, '存货周转-库存商品'),
|
||||
(19, '6401', 'ratio', -1.00, '存货周转分母-主营业务成本'),
|
||||
-- F_ROI 总资产报酬率 = 利润/资产
|
||||
(20, '4103', 'ratio', 1.00, 'ROI分子-本年利润'),
|
||||
(20, '4001', 'ratio', -1.00, 'ROI分母-实收资本'),
|
||||
(20, '4002', 'ratio', -1.00, 'ROI分母-资本公积'),
|
||||
-- F_DEBT_RATIO 资产负债率 = 负债/资产
|
||||
(30, '2001', 'ratio', 1.00, '负债率-短期借款'),
|
||||
(30, '2202', 'ratio', 1.00, '负债率-应付账款'),
|
||||
(30, '2203', 'ratio', 1.00, '负债率-预收账款'),
|
||||
(30, '2211', 'ratio', 1.00, '负债率-应付职工薪酬'),
|
||||
(30, '2221', 'ratio', 1.00, '负债率-应交税费'),
|
||||
(30, '2241', 'ratio', 1.00, '负债率-其他应付款'),
|
||||
(30, '2501', 'ratio', 1.00, '负债率-长期借款'),
|
||||
(30, '2502', 'ratio', 1.00, '负债率-应付债券'),
|
||||
(30, '4001', 'ratio', -1.00, '负债率分母-实收资本'),
|
||||
(30, '4002', 'ratio', -1.00, '负债率分母-资本公积'),
|
||||
-- F_INTEREST_COVER 利息保障倍数 = 利润/利息支出
|
||||
(31, '4103', 'ratio', 1.00, '利息保障分子-本年利润'),
|
||||
(31, '660301','ratio', -1.00, '利息保障分母-利息支出(借款)'),
|
||||
-- F_EVA 经济增加值 = 利润 - 资本成本
|
||||
(32, '4103', 'sum', 1.00, 'EVA-本年利润'),
|
||||
(32, '4001', 'sum', -0.06, 'EVA-资本成本(实收资本×6%)'),
|
||||
-- F_FCF 自由现金流
|
||||
(45, '1002', 'sum', 1.00, 'FCF-银行存款'),
|
||||
(45, '2202', 'sum', 1.00, 'FCF-应付账款'),
|
||||
-- F_OP_PROFIT_MARGIN 经营利润率 = (收入-成本-期间费用)/收入
|
||||
(183, '6001', 'ratio', 1.00, '经营利润率-收入'),
|
||||
(183, '6401', 'ratio', -1.00, '经营利润率-主营业务成本'),
|
||||
(183, '6601', 'ratio', -1.00, '经营利润率-销售费用'),
|
||||
(183, '6602', 'ratio', -1.00, '经营利润率-管理费用'),
|
||||
(183, '6603', 'ratio', -1.00, '经营利润率-财务费用'),
|
||||
-- F_ROE 净资产收益率 = 净利润/净资产
|
||||
(44, '4103', 'ratio', 1.00, 'ROE分子-本年利润'),
|
||||
(44, '4001', 'ratio', -1.00, 'ROE分母-实收资本'),
|
||||
(44, '4002', 'ratio', -1.00, 'ROE分母-资本公积'),
|
||||
-- P_COST_CUT 招待费砍半
|
||||
(54, '6601', 'sum', 1.00, '招待费-销售费用(业务招待子目)'),
|
||||
-- P_CHANNEL_NEG 渠补谈判完成率(以渠道收入为基数)
|
||||
(48, '6001', 'ratio', 1.00, '渠补谈判-主营业务收入'),
|
||||
(48, '6051', 'ratio', 1.00, '渠补谈判-其他业务收入'),
|
||||
-- C_MARKET_SHARE 市场份额(以收入为口径)
|
||||
(33, '6001', 'ratio', 1.00, '市场份额-主营业务收入');
|
||||
-- objective_kpi + krs 种子数据 (2026-08-19)
|
||||
USE cma;
|
||||
|
||||
-- ① KPI↔O支撑: 现有3个O各关联5个KPI
|
||||
INSERT INTO objective_kpi (objective_id, kpi_id, weight) VALUES
|
||||
-- O11 优化成本结构——渠补谈判+管理费压缩
|
||||
(11, 5, 1.00), -- F_COST_RATIO 费用率
|
||||
(11, 54, 1.00), -- P_COST_CUT 招待费砍半
|
||||
(11, 183, 1.00), -- F_OP_PROFIT_MARGIN 经营利润率
|
||||
(11, 3, 1.00), -- F_NET_PROFIT 净利润
|
||||
(11, 48, 1.00), -- P_CHANNEL_NEG 渠补谈判完成率
|
||||
-- O12 保障现金流安全——应收催收+厂补确认
|
||||
(12, 4, 1.00), -- F_OP_CFLOW 经营性现金流
|
||||
(12, 6, 1.00), -- F_AR_DAYS 应收账款周转天数
|
||||
(12, 45, 1.00), -- F_FCF 自由现金流
|
||||
(12, 408, 1.00), -- F_FACTORY_REBATE_RATE 上游厂补率
|
||||
(12, 17, 1.00), -- F_CURRENT_RATIO 流动比率
|
||||
-- O13 渠道关系改善——用数据谈判渠补
|
||||
(13, 46, 1.00), -- C_REBATE_RATE 渠补率
|
||||
(13, 48, 1.00), -- P_CHANNEL_NEG 渠补谈判完成率
|
||||
(13, 407, 1.00), -- F_REBATE_RATE 返利率
|
||||
(13, 7, 1.00), -- C_SATISFACTION 客户满意度
|
||||
(13, 24, 1.00); -- C_RETENTION_RATE 客户保留率
|
||||
|
||||
-- ② krs: 现有3个O各配3个KR
|
||||
INSERT INTO krs (objective_id, title, metric_kpi_id, target_value, current_value, progress, status, due_date) VALUES
|
||||
-- O11 优化成本结构
|
||||
(11, '完成渠补谈判,渠道B渠补率降至72%', 48, 100.00, 40.00, 40, 'in_progress', '2026-09-30'),
|
||||
(11, '管理费用率压缩至15%以内', 5, 15.00, 18.00, 60, 'in_progress', '2026-09-30'),
|
||||
(11, '经营利润率提升至8%', 183, 8.00, 5.20, 40, 'in_progress', '2026-09-30'),
|
||||
-- O12 保障现金流安全
|
||||
(12, '应收账款周转天数降至45天', 6, 45.00, 62.00, 30, 'in_progress', '2026-09-30'),
|
||||
(12, '经营性现金流季度回正至100万元', 4, 100.00, 40.00, 35, 'in_progress', '2026-09-30'),
|
||||
(12, '上游厂补率确认至90%', 408, 90.00, 60.00, 40, 'in_progress', '2026-09-30'),
|
||||
-- O13 渠道关系改善
|
||||
(13, '渠道渠补率降至70%以下', 46, 70.00, 78.00, 50, 'in_progress', '2026-09-30'),
|
||||
(13, '完成10场数据化渠补谈判', 48, 10.00, 2.00, 20, 'in_progress', '2026-09-30'),
|
||||
(13, '客户满意度提升至90分', 7, 90.00, 85.00, 33, 'in_progress', '2026-09-30');
|
||||
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.
@@ -35,6 +35,38 @@ TEST_ENGINE = create_engine(
|
||||
)
|
||||
TEST_SESSION_LOCAL = sessionmaker(autocommit=False, autoflush=False, bind=TEST_ENGINE)
|
||||
|
||||
|
||||
# MySQL-only 的 date_format() 在 SQLite 下注册等价实现(仅测试库)
|
||||
# 生产用 MySQL 原生函数;此处仅为让测试能跑通 expenses 月度累计校验/stats 统计
|
||||
def _sqlite_date_format(dt_val, fmt):
|
||||
if dt_val is None:
|
||||
return None
|
||||
import datetime as _dt
|
||||
if isinstance(dt_val, str):
|
||||
for f in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y-%m"):
|
||||
try:
|
||||
dt_val = _dt.datetime.strptime(str(dt_val)[:19], f)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
return None
|
||||
if isinstance(dt_val, _dt.datetime):
|
||||
d = dt_val
|
||||
elif isinstance(dt_val, _dt.date):
|
||||
d = _dt.datetime(dt_val.year, dt_val.month, dt_val.day)
|
||||
else:
|
||||
return None
|
||||
return {
|
||||
"%Y": f"{d.year:04d}",
|
||||
"%Y-%m": f"{d.year:04d}-{d.month:02d}",
|
||||
"%Y-%m-%d": f"{d.year:04d}-{d.month:02d}-{d.day:02d}",
|
||||
}.get(fmt)
|
||||
|
||||
|
||||
from sqlalchemy import event # noqa: E402
|
||||
event.listen(TEST_ENGINE, "connect", lambda dbapi_conn, rec: dbapi_conn.create_function("date_format", 2, _sqlite_date_format))
|
||||
|
||||
# 替换 database 模块的全局引擎
|
||||
db_module._engine = TEST_ENGINE
|
||||
db_module._SessionLocal = TEST_SESSION_LOCAL
|
||||
@@ -50,9 +82,23 @@ import hashlib
|
||||
@pytest.fixture(autouse=True)
|
||||
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)
|
||||
yield
|
||||
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
|
||||
@@ -69,6 +115,13 @@ def db() -> Generator[Session, None, None]:
|
||||
def client(db) -> Generator[TestClient, None, None]:
|
||||
"""提供测试 HTTP 客户端"""
|
||||
from app.main import app
|
||||
from app.models import Entity
|
||||
|
||||
# 账套模式:确保测试库存在 entity_id=1 的active实体
|
||||
ent = db.query(Entity).filter(Entity.id == 1).first()
|
||||
if not ent:
|
||||
db.add(Entity(id=1, name="测试企业", short_name="测试", status="active"))
|
||||
db.commit()
|
||||
|
||||
# 重写依赖,使用测试数据库
|
||||
app.dependency_overrides[db_module.get_db] = lambda: db
|
||||
@@ -98,12 +151,16 @@ def create_test_user(db: Session, **kwargs) -> User:
|
||||
|
||||
|
||||
def get_token_for_user(client: TestClient, username: str = "testadmin", password: str = "admin123") -> str:
|
||||
"""获取测试用户的token"""
|
||||
"""获取测试用户的token(账套模式:需entity_id)"""
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"entity_id": 1,
|
||||
})
|
||||
return resp.json()["token"]
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"登录失败: {resp.status_code} {resp.text[:300]}")
|
||||
data = resp.json()
|
||||
return data.get("token") or data.get("access_token")
|
||||
|
||||
|
||||
def auth_header(token: str) -> dict:
|
||||
|
||||
@@ -6,7 +6,32 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
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:
|
||||
@@ -109,7 +134,7 @@ class TestActionPlans:
|
||||
assert data["data"][0]["title"] == "进行中"
|
||||
|
||||
def test_filter_by_keyword(self, client: TestClient, db: Session):
|
||||
"""关键词搜索"""
|
||||
"""关键词搜索:keyword 应过滤 title(期望行为,2026-08-27 TDD示范)"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
@@ -119,8 +144,9 @@ class TestActionPlans:
|
||||
resp = client.get("/api/cma/action-plans?keyword=营收", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# API当前未实现keyword过滤,返回全部2条
|
||||
assert len(data["data"]) == 2
|
||||
# 期望行为:keyword 过滤生效,只返回标题含"营收"的1条
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["title"] == "提升营收计划"
|
||||
|
||||
def test_update_plan(self, client: TestClient, db: Session):
|
||||
"""更新行动计划"""
|
||||
@@ -226,3 +252,90 @@ class TestActionPlans:
|
||||
assert data["pending"] == 1
|
||||
assert data["in_progress"] == 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
|
||||
@@ -17,6 +17,7 @@ class TestAuth:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": "admin123",
|
||||
"entity_id": 1,
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -78,6 +79,7 @@ class TestAuth:
|
||||
login_resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "newuser",
|
||||
"password": "newpass123",
|
||||
"entity_id": 1,
|
||||
})
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,370 @@
|
||||
"""BOT桥接层测试 — CMA供财务/研学Bot调用的主通道(X-BOT-KEY鉴权)
|
||||
|
||||
覆盖 bot_bridge.py 全部18个端点:
|
||||
ping / overview / kpis / kpis{id}/history / strategic-maps / alerts /
|
||||
budget/plans / cost/standard / cost/actual / actions / organization /
|
||||
data-sources / users / query / import / okr/create / okr/list / nlp
|
||||
"""
|
||||
import io
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
User, KPIDefinition, KPIValue, KPIAlert, StrategicMap, MapObjective,
|
||||
ActionPlan, OrgNode, DataSourceConfig, Objective,
|
||||
)
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
|
||||
|
||||
|
||||
def _make_excel(kpi_code: str, period: str, value: float) -> bytes:
|
||||
"""生成Excel导入文件(列: kpi_code, period, actual_value)"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["kpi_code", "period", "actual_value"])
|
||||
ws.append([kpi_code, period, value])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _seed_kpi(db: Session, **kwargs) -> KPIDefinition:
|
||||
defaults = dict(
|
||||
kpi_code="BH_REVENUE",
|
||||
kpi_name="营业收入",
|
||||
dimension="finance",
|
||||
status="active",
|
||||
target_value=100.0,
|
||||
unit="万元",
|
||||
frequency="monthly",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
kpi = KPIDefinition(**defaults)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
class TestPingAndAuth:
|
||||
def test_ping_no_key(self, client: TestClient):
|
||||
"""ping 无需鉴权"""
|
||||
resp = client.get("/api/cma/bot/ping")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
def test_invalid_bot_key(self, client: TestClient):
|
||||
"""无效BOT Key → 401"""
|
||||
resp = client.get("/api/cma/bot/overview", headers={"X-BOT-KEY": "wrong-key"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_missing_bot_key(self, client: TestClient):
|
||||
"""缺BOT Key → 401"""
|
||||
resp = client.get("/api/cma/bot/overview")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestOverviewAndKpis:
|
||||
def test_overview_stats(self, client: TestClient, db: Session):
|
||||
"""总览统计:造数后计数正确"""
|
||||
_seed_kpi(db)
|
||||
db.add(KPIAlert(kpi_id=1, alert_level="red", alert_message="收入下滑", status="pending"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/overview", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bot"]["name"] == "财务BOT"
|
||||
assert data["stats"]["kpis_total"] == 1
|
||||
assert data["stats"]["alerts_open"] == 1
|
||||
|
||||
def test_kpis_filter_by_dimension(self, client: TestClient, db: Session):
|
||||
"""KPI列表:按维度过滤 + 关联最新实际值"""
|
||||
k1 = _seed_kpi(db, kpi_code="BH_REVENUE", dimension="finance")
|
||||
_seed_kpi(db, kpi_code="BH_CUSTOMER", dimension="customer")
|
||||
db.add(KPIValue(kpi_id=k1.id, period="2026-06", actual_value=88.0, data_status="verified"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/kpis?dimension=finance", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["code"] == "BH_REVENUE"
|
||||
assert data["items"][0]["latest_value"] == 88.0
|
||||
assert data["items"][0]["latest_period"] == "2026-06"
|
||||
|
||||
def test_kpis_status_filter(self, client: TestClient, db: Session):
|
||||
"""KPI列表:status过滤(默认active,inactive被过滤)"""
|
||||
_seed_kpi(db, kpi_code="BH_ACTIVE")
|
||||
_seed_kpi(db, kpi_code="BH_INACTIVE", status="inactive")
|
||||
resp = client.get("/api/cma/bot/kpis", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
codes = {i["code"] for i in resp.json()["items"]}
|
||||
assert "BH_ACTIVE" in codes
|
||||
assert "BH_INACTIVE" not in codes
|
||||
|
||||
def test_kpi_history(self, client: TestClient, db: Session):
|
||||
"""KPI历史值"""
|
||||
k = _seed_kpi(db, kpi_code="BH_REVENUE")
|
||||
db.add(KPIValue(kpi_id=k.id, period="2026-07", actual_value=95.0, source_type="manual", data_status="verified"))
|
||||
db.add(KPIValue(kpi_id=k.id, period="2026-06", actual_value=88.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"/api/cma/bot/kpis/{k.id}/history", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi"]["code"] == "BH_REVENUE"
|
||||
# 按期间倒序,最新在前
|
||||
assert data["values"][0]["period"] == "2026-07"
|
||||
assert len(data["values"]) == 2
|
||||
|
||||
def test_kpi_history_not_found(self, client: TestClient):
|
||||
"""不存在的KPI → 404"""
|
||||
resp = client.get("/api/cma/bot/kpis/99999/history", headers=BOT_KEY)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestMapsAlertsBudgetCost:
|
||||
def test_strategic_maps(self, client: TestClient, db: Session):
|
||||
"""战略地图列表(含目标)"""
|
||||
m = StrategicMap(title="博海战略地图", version="v1.0", status="published",
|
||||
dimensions=[{"key": "finance", "name": "财务"}])
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
db.add(MapObjective(map_id=m.id, dimension_key="finance", name="提升收入"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/strategic-maps", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["title"] == "博海战略地图"
|
||||
assert data["items"][0]["objectives"]["finance"][0]["name"] == "提升收入"
|
||||
|
||||
def test_alerts_filter(self, client: TestClient, db: Session):
|
||||
"""预警列表:按状态/等级过滤"""
|
||||
_seed_kpi(db)
|
||||
db.add(KPIAlert(kpi_id=1, alert_level="red", alert_message="严重", status="pending"))
|
||||
db.add(KPIAlert(kpi_id=1, alert_level="yellow", alert_message="关注", status="resolved"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/alerts?status=pending&level=red", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["level"] == "red"
|
||||
assert data["items"][0]["message"] == "严重"
|
||||
|
||||
def test_budget_plans(self, client: TestClient, db: Session):
|
||||
"""预算计划(按年过滤)"""
|
||||
k = _seed_kpi(db)
|
||||
db.add(BudgetPlan(kpi_id=k.id, period="2026-06", budget_value=50000.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/budget/plans?year=2026", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["budget_value"] == 50000.0
|
||||
|
||||
def test_budget_plans_year_filter(self, client: TestClient, db: Session):
|
||||
"""预算计划:其他年份被过滤"""
|
||||
k = _seed_kpi(db)
|
||||
db.add(BudgetPlan(kpi_id=k.id, period="2025-12", budget_value=100.0,
|
||||
budget_year=2025, budget_month=12, status="active"))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/budget/plans?year=2024", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
def test_cost_standard(self, client: TestClient, db: Session):
|
||||
"""标准成本"""
|
||||
db.add(StandardCost(product_code="P001", product_name="产品A", cost_type="material",
|
||||
item_name="原料", standard_quantity=2.0, unit="kg",
|
||||
standard_price=10.0, standard_cost=20.0, status="active"))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/cost/standard", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["standard_cost"] == 20.0
|
||||
|
||||
def test_cost_actual_period(self, client: TestClient, db: Session):
|
||||
"""实际成本(按期间过滤)"""
|
||||
db.add(ActualCost(period="2026-06", product_code="P001", product_name="产品A",
|
||||
cost_type="material", item_name="原料",
|
||||
actual_quantity=3.0, actual_price=12.0, actual_cost=36.0))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/cost/actual?period=2026-06", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["actual_cost"] == 36.0
|
||||
|
||||
|
||||
class TestActionsOrgSourcesUsers:
|
||||
def test_actions(self, client: TestClient, db: Session):
|
||||
"""行动方案列表(按状态过滤)"""
|
||||
k = _seed_kpi(db)
|
||||
db.add(ActionPlan(kpi_id=k.id, title="提升收入", status="in_progress", priority="high", progress=50))
|
||||
db.add(ActionPlan(kpi_id=k.id, title="已关闭", status="completed", priority="low", progress=100))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/actions?status=in_progress", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["title"] == "提升收入"
|
||||
|
||||
def test_organization(self, client: TestClient, db: Session):
|
||||
"""组织架构"""
|
||||
db.add(OrgNode(name="测试组织", code="TEST_ORG_001", level=1, sort_order=1, enabled=1))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/organization", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
codes = [i["code"] for i in resp.json()["items"]]
|
||||
assert "TEST_ORG_001" in codes
|
||||
|
||||
def test_data_sources(self, client: TestClient, db: Session):
|
||||
"""数据源"""
|
||||
db.add(DataSourceConfig(name="ERP", source_type="erp", api_endpoint="http://erp",
|
||||
sync_type="batch", status="active"))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/data-sources", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["name"] == "ERP"
|
||||
|
||||
def test_users(self, client: TestClient, db: Session):
|
||||
"""用户列表(不返回密码等敏感字段)"""
|
||||
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)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] >= 1
|
||||
user = resp.json()["items"][0]
|
||||
assert "username" in user
|
||||
assert "password" not in user
|
||||
|
||||
|
||||
class TestUnifiedQuery:
|
||||
def test_query_overview(self, client: TestClient, db: Session):
|
||||
"""统一查询 overview"""
|
||||
_seed_kpi(db)
|
||||
resp = client.get("/api/cma/bot/query?q=overview", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["overview"]["kpis"] == 1
|
||||
|
||||
def test_query_kpis_and_budget(self, client: TestClient, db: Session):
|
||||
"""统一查询 kpis / budget"""
|
||||
k = _seed_kpi(db)
|
||||
db.add(BudgetPlan(kpi_id=k.id, period="2026-06", budget_value=10.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
db.commit()
|
||||
|
||||
r1 = client.get("/api/cma/bot/query?q=kpis", headers=BOT_KEY)
|
||||
assert r1.status_code == 200
|
||||
assert len(r1.json()["kpis"]) == 1
|
||||
|
||||
r2 = client.get("/api/cma/bot/query?q=budget", headers=BOT_KEY)
|
||||
assert r2.status_code == 200
|
||||
assert len(r2.json()["budget"]) == 1
|
||||
|
||||
def test_query_all(self, client: TestClient, db: Session):
|
||||
"""统一查询 all:返回全部分组"""
|
||||
_seed_kpi(db)
|
||||
resp = client.get("/api/cma/bot/query?q=all", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "overview" in data and "kpis" in data and "alerts" in data
|
||||
assert "maps" in data and "budget" in data and "costs" in data
|
||||
assert "actions" in data and "okr" in data
|
||||
|
||||
|
||||
class TestImport:
|
||||
def test_import_excel(self, client: TestClient, db: Session):
|
||||
"""Excel导入KPI实际值"""
|
||||
_seed_kpi(db, kpi_code="BH_REVENUE")
|
||||
files = {"file": ("kpi.xlsx", _make_excel("BH_REVENUE", "2026-08", 99.5),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["imported"] == 1
|
||||
|
||||
# 验证入库
|
||||
val = db.query(KPIValue).filter(KPIValue.period == "2026-08").first()
|
||||
assert val is not None and val.actual_value == 99.5
|
||||
|
||||
def test_import_excel_unknown_kpi(self, client: TestClient, db: Session):
|
||||
"""导入不存在的KPI编码 → 跳过并记录错误"""
|
||||
files = {"file": ("kpi.xlsx", _make_excel("NO_SUCH_KPI", "2026-08", 10.0),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["imported"] == 0
|
||||
assert data["errors"] == 1
|
||||
|
||||
def test_import_bad_file(self, client: TestClient):
|
||||
"""非Excel文件 → 400"""
|
||||
files = {"file": ("bad.txt", b"not an excel", "text/plain")}
|
||||
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_import_missing_value_col(self, client: TestClient, db: Session):
|
||||
"""缺少数值列 → 400"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["kpi_code"])
|
||||
ws.append(["BH_REVENUE"])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
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 == 400
|
||||
|
||||
|
||||
class TestOkrAndNlp:
|
||||
def test_okr_create_and_list(self, client: TestClient, db: Session):
|
||||
"""Bot创建OKR目标 + 列表"""
|
||||
resp = client.post("/api/cma/bot/okr/create?title=提升净利润&quarter=2026Q3&dimension=finance",
|
||||
headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
obj_id = resp.json()["id"]
|
||||
assert obj_id > 0
|
||||
|
||||
list_resp = client.get("/api/cma/bot/okr/list?quarter=2026Q3", headers=BOT_KEY)
|
||||
assert list_resp.status_code == 200
|
||||
assert list_resp.json()["total"] == 1
|
||||
assert list_resp.json()["items"][0]["title"] == "提升净利润"
|
||||
|
||||
def test_okr_create_missing_quarter(self, client: TestClient):
|
||||
"""缺quarter → 422"""
|
||||
resp = client.post("/api/cma/bot/okr/create?title=无季度目标", headers=BOT_KEY)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_nlp_intent_mapping(self, client: TestClient, db: Session):
|
||||
"""自然语言意图映射"""
|
||||
_seed_kpi(db)
|
||||
# 中文意图 → 映射到预算
|
||||
resp = client.get("/api/cma/bot/nlp?intent=预算", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert "budget" in resp.json()
|
||||
|
||||
resp2 = client.get("/api/cma/bot/nlp?intent=总览", headers=BOT_KEY)
|
||||
assert resp2.status_code == 200
|
||||
assert "overview" in resp2.json()
|
||||
@@ -10,7 +10,7 @@ from tests.conftest import (
|
||||
create_test_user, get_token_for_user, auth_header,
|
||||
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"
|
||||
|
||||
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)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="C_CHANNEL_REBATE")
|
||||
obj = create_test_objective(db, title="优化成本结构")
|
||||
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
||||
title="渠补率降到75%")
|
||||
# KR落krs表(KR完整修复后,ActionPlan不再是KR存储)
|
||||
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(
|
||||
f"/api/cma/okr/{obj.id}",
|
||||
@@ -353,15 +361,18 @@ class TestOKRFullLifecycle:
|
||||
assert kr_found, "KR应出现在OKR详情中"
|
||||
|
||||
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)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
obj = create_test_objective(db)
|
||||
kr1 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
||||
title="KR1", progress=80)
|
||||
kr2 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
||||
title="KR2", progress=40)
|
||||
from app.models import KR as KRModel
|
||||
kr1 = KRModel(entity_id=1, objective_id=obj.id, title="KR1",
|
||||
metric_kpi_id=kpi.id, progress=80, weight=50)
|
||||
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重算
|
||||
resp = client.patch(
|
||||
|
||||
+1030
-9
File diff suppressed because it is too large
Load Diff
@@ -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" # 规则覆盖关键词
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user