feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
when:
|
||||
- branch: main
|
||||
event: push
|
||||
|
||||
variables:
|
||||
- &ssh_setup |
|
||||
apk add --no-cache openssh-client rsync
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_DEPLOY_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H git.sxbh.ltd >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
steps:
|
||||
frontend-install:
|
||||
image: node:20-alpine
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- cd frontend
|
||||
- npm install -g pnpm
|
||||
- pnpm install
|
||||
when:
|
||||
- path: frontend/**
|
||||
|
||||
frontend-build:
|
||||
image: node:20-alpine
|
||||
commands:
|
||||
- cd frontend
|
||||
- npm install -g pnpm
|
||||
- pnpm install
|
||||
- pnpm build
|
||||
when:
|
||||
- path: frontend/**
|
||||
|
||||
frontend-deploy:
|
||||
image: alpine:latest
|
||||
secrets:
|
||||
- SSH_DEPLOY_KEY
|
||||
commands:
|
||||
- *ssh_setup
|
||||
- rsync -avz --delete frontend/dist/ root@git.sxbh.ltd:/var/www/cma/
|
||||
- ssh root@git.sxbh.ltd 'nginx -s reload || systemctl reload nginx'
|
||||
when:
|
||||
- path: frontend/**
|
||||
|
||||
backend-deploy:
|
||||
image: alpine:latest
|
||||
secrets:
|
||||
- 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 &
|
||||
'
|
||||
when:
|
||||
- path: backend/**
|
||||
@@ -0,0 +1 @@
|
||||
此目录已归入 /root/projects/cma/backend — 管理会计OS
|
||||
@@ -0,0 +1,96 @@
|
||||
# CMA Epic 2 — KPI数据分析增强和驾驶舱优化
|
||||
> 技术方案 v1.0 | 2026-06-13
|
||||
|
||||
## 一、现状分析
|
||||
|
||||
### 现有系统状态
|
||||
- **后端**: FastAPI @ 127.0.0.1:8010,运行正常
|
||||
- **数据库**: cma.db,18个活跃KPI,4个维度(finance:8, customer:3, process:3, learning:4)
|
||||
- **预警**: 16个待处理预警
|
||||
- **Dashboard.vue**: CEO/Finance/Business/IT四角色视图,已有KPI矩阵、预测、简报等功能
|
||||
- **MyDashboard.vue**: PDCA管理闭环、趋势柱状图
|
||||
- **deviation_engine.py**: 已有同比/环比计算基础函数(calc_period_diff),但未被dashboard API集成
|
||||
- **ai_analysis.py**: 已集成DeepSeek API做CEO简报和KPI分析
|
||||
|
||||
### 待开发功能
|
||||
1. **同比环比趋势分析** — deviation_engine.py已有calc_period_diff,需集成到dashboard API
|
||||
2. **预警趋势统计** — 按等级/维度/时间的统计API
|
||||
3. **KPI数据导出CSV** — 导出功能
|
||||
4. **驾驶舱KPI增强** — 增加trend字段和achievement_rate
|
||||
5. **Dashboard.vue趋势分析tab** — ECharts折线图
|
||||
6. **Dashboard.vue预警统计卡片** — 饼图+趋势线
|
||||
7. **Dashboard.vue达成率进度条** — 已有简单进度条,增强可视化
|
||||
|
||||
## 二、后端新增API
|
||||
|
||||
### 1. KPI同比环比趋势分析
|
||||
```
|
||||
POST /api/cma/dashboard/trend-analysis
|
||||
参数: kpi_ids (list[int]), period_type (month/quarter/year), compare_type (yoy/mom)
|
||||
返回: {
|
||||
data: [{
|
||||
kpi_id, kpi_code, kpi_name, unit,
|
||||
current_value, current_period,
|
||||
previous_value, previous_period,
|
||||
change_rate, # 变化率(%)
|
||||
change_amount, # 变化额
|
||||
trend_direction, # up/down/stable
|
||||
dimension
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 预警趋势统计
|
||||
```
|
||||
GET /api/cma/dashboard/alert-stats
|
||||
参数: period (month/quarter/year)
|
||||
返回: {
|
||||
total_pending: N,
|
||||
by_severity: { red: N, yellow: N, green: N },
|
||||
by_dimension: [{ dimension, count }],
|
||||
trend_by_month: [{ month, red, yellow, green }]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. KPI数据导出CSV
|
||||
```
|
||||
GET /api/cma/dashboard/export
|
||||
参数: kpi_ids (comma-separated), period
|
||||
返回: CSV文件流 (Content-Type: text/csv)
|
||||
```
|
||||
|
||||
### 4. 驾驶舱KPI增强(修改现有get_dashboard_kpis)
|
||||
- 每个KPI增加 `trend` 字段(最近3期环比变化率)
|
||||
- 增加 `achievement_rate` 字段(actual_value / target_value)
|
||||
- 增加 `period_values` 数组(最近6期数据,供前端画趋势图)
|
||||
|
||||
## 三、前端改造
|
||||
|
||||
### Dashboard.vue 增强(CEO视图)
|
||||
1. **趋势分析标签页** — ECharts折线图,支持同比/环比切换
|
||||
2. **预警统计卡片** — 饼图(severity分布) + 趋势折线
|
||||
3. **KPI卡片增强** — 达成率百分比 + 彩色进度条 + 趋势箭头
|
||||
4. **数据导出按钮** — 调用export API下载CSV
|
||||
|
||||
### 前端API扩展
|
||||
在 `/frontend/src/api/index.ts` 的 `dashboardApi` 中增加:
|
||||
- `trendAnalysis: (params) => api.post('/dashboard/trend-analysis', params)`
|
||||
- `alertStats: (params) => api.get('/dashboard/alert-stats', { params })`
|
||||
- `exportKpis: (params) => api.get('/dashboard/export', { params, responseType: 'blob' })`
|
||||
|
||||
## 四、执行顺序
|
||||
|
||||
```
|
||||
Step 1 (并行): Backend → 趋势分析API + 预警统计API + 导出API
|
||||
Frontend → API扩展定义(与后端同步)
|
||||
Step 2 (串行, 依赖Step1): Frontend → Dashboard.vue改造
|
||||
Step 3 (串行, 依赖Step2): DevOps → 部署重启
|
||||
Step 4 (串行, 依赖Step3): QA → 全流程验证
|
||||
```
|
||||
|
||||
## 五、依赖关系
|
||||
|
||||
- trend-analysis API: 可直接复用deviation_engine.py的calc_period_diff
|
||||
- alert-stats API: 可直接从KPIAlert表聚合统计
|
||||
- export API: 无依赖
|
||||
- Dashboard.vue趋势tab: 依赖Step1的API
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
CMA BOT API桥接层 — 供财务BOT/店研学BOT调用
|
||||
无需用户登录,使用 BOT API Key 认证
|
||||
"""
|
||||
import os, json, logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
||||
NotificationLog, RolePermission, ActionPlan, OrgNode,
|
||||
StrategicMapVersion, MapObjective,
|
||||
)
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot", tags=["BOT桥接"])
|
||||
|
||||
# ── BOT API Key 配置 ──
|
||||
_BOT_API_KEYS = {}
|
||||
|
||||
def _load_bot_keys():
|
||||
global _BOT_API_KEYS
|
||||
raw = os.getenv("CMA_BOT_API_KEYS", "")
|
||||
if not raw:
|
||||
_BOT_API_KEYS = {
|
||||
"cma-bot-finance-2026": {"role": "finance", "name": "财务BOT"},
|
||||
"cma-bot-shop-2026": {"role": "business", "name": "店研学BOT"},
|
||||
"cma-bot-admin-2026": {"role": "ceo", "name": "管理BOT"},
|
||||
}
|
||||
else:
|
||||
try:
|
||||
_BOT_API_KEYS = json.loads(raw)
|
||||
except:
|
||||
_BOT_API_KEYS = {}
|
||||
|
||||
_load_bot_keys()
|
||||
|
||||
def verify_bot_key(x_bot_key: str = Header(None, alias="X-BOT-KEY")):
|
||||
if not x_bot_key or x_bot_key not in _BOT_API_KEYS:
|
||||
raise HTTPException(401, "无效的BOT API Key")
|
||||
bot_info = _BOT_API_KEYS[x_bot_key]
|
||||
logger.info(f"BOT访问: {bot_info['name']} ({bot_info['role']})")
|
||||
return bot_info
|
||||
|
||||
|
||||
# ═══════════════ 通用工具 ═══════════════
|
||||
|
||||
def _float(v):
|
||||
if v is None: return None
|
||||
try: return float(v)
|
||||
except: return None
|
||||
|
||||
def _safe_iso(dt):
|
||||
if dt is None: return None
|
||||
try: return dt.isoformat() if hasattr(dt, 'isoformat') else str(dt)
|
||||
except: return None
|
||||
|
||||
def _model_dict(obj, fields: dict):
|
||||
"""安全地将模型字段转为dict"""
|
||||
result = {}
|
||||
for key, attr in fields.items():
|
||||
v = getattr(obj, attr, None)
|
||||
if isinstance(v, float):
|
||||
result[key] = _float(v)
|
||||
else:
|
||||
result[key] = v
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════ 端点 ═══════════════
|
||||
|
||||
@router.get("/ping")
|
||||
def ping():
|
||||
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
|
||||
|
||||
|
||||
# ── 总览 ──
|
||||
|
||||
@router.get("/overview")
|
||||
def bot_overview(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""系统总览 — BOT首选入口"""
|
||||
return {
|
||||
"bot": bot,
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ── KPI ──
|
||||
|
||||
@router.get("/kpis")
|
||||
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),
|
||||
):
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == status)
|
||||
if dimension:
|
||||
query = query.filter(KPIDefinition.dimension == dimension)
|
||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
|
||||
|
||||
results = []
|
||||
for k in kpis:
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)\
|
||||
.order_by(KPIValue.period.desc()).first()
|
||||
results.append({
|
||||
"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
||||
"dimension": k.dimension, "category": k.category,
|
||||
"unit": k.unit, "formula": k.formula,
|
||||
"frequency": k.frequency, "data_source_type": k.data_source_type,
|
||||
"target_value": _float(k.target_value),
|
||||
"threshold_green": k.threshold_green,
|
||||
"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,
|
||||
"latest_value": _float(latest.actual_value) if latest else None,
|
||||
"latest_period": latest.period if latest else None,
|
||||
"status": k.status,
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
|
||||
|
||||
@router.get("/kpis/{kpi_id}/history")
|
||||
def bot_kpi_history(
|
||||
kpi_id: int, limit: int = Query(12, le=60),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
|
||||
.order_by(KPIValue.period.desc()).limit(limit).all()
|
||||
return {
|
||||
"kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit},
|
||||
"values": [
|
||||
{
|
||||
"period": v.period,
|
||||
"actual": _float(v.actual_value),
|
||||
"source_type": v.source_type,
|
||||
"data_status": v.data_status,
|
||||
} for v in values
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 战略地图 ──
|
||||
|
||||
@router.get("/strategic-maps")
|
||||
def bot_maps(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
maps = db.query(StrategicMap).order_by(StrategicMap.id.desc()).all()
|
||||
result = []
|
||||
for m in maps:
|
||||
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
|
||||
dims = {}
|
||||
for obj in objectives:
|
||||
dk = obj.dimension_key
|
||||
if dk not in dims:
|
||||
dims[dk] = []
|
||||
dims[dk].append({"id": obj.id, "name": obj.name, "description": obj.description})
|
||||
result.append({
|
||||
"id": m.id, "title": m.title, "version": m.version,
|
||||
"status": m.status, "dimensions": m.dimensions,
|
||||
"objectives": dims,
|
||||
"created_at": _safe_iso(m.created_at),
|
||||
"updated_at": _safe_iso(m.updated_at),
|
||||
})
|
||||
return {"total": len(result), "items": result}
|
||||
|
||||
|
||||
# ── 预警 ──
|
||||
|
||||
@router.get("/alerts")
|
||||
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),
|
||||
):
|
||||
query = db.query(KPIAlert)
|
||||
query = query.filter(KPIAlert.status == status)
|
||||
if level:
|
||||
query = query.filter(KPIAlert.alert_level == level)
|
||||
alerts = query.order_by(KPIAlert.created_at.desc()).limit(limit).all()
|
||||
return {
|
||||
"total": len(alerts),
|
||||
"items": [
|
||||
{
|
||||
"id": a.id, "kpi_id": a.kpi_id,
|
||||
"level": a.alert_level, "message": a.alert_message,
|
||||
"status": a.status, "assignee": a.assignee,
|
||||
"resolution": a.resolution,
|
||||
"created_at": _safe_iso(a.created_at),
|
||||
"resolved_at": _safe_iso(a.resolved_at),
|
||||
} for a in alerts
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 预算 ──
|
||||
|
||||
@router.get("/budget/plans")
|
||||
def bot_budget_plans(
|
||||
year: Optional[int] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(BudgetPlan)
|
||||
if year:
|
||||
query = query.filter(BudgetPlan.budget_year == year)
|
||||
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
|
||||
return {
|
||||
"total": len(plans),
|
||||
"items": [
|
||||
{
|
||||
"id": p.id, "kpi_id": p.kpi_id,
|
||||
"period": p.period,
|
||||
"budget_value": _float(p.budget_value),
|
||||
"year": p.budget_year, "month": p.budget_month,
|
||||
"version": p.version, "status": p.status,
|
||||
"remark": p.remark,
|
||||
} for p in plans
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 成本 ──
|
||||
|
||||
@router.get("/cost/standard")
|
||||
def bot_standard_costs(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
costs = db.query(StandardCost).filter(StandardCost.status == "active").limit(200).all()
|
||||
return {
|
||||
"total": len(costs),
|
||||
"items": [
|
||||
{
|
||||
"id": c.id, "product_code": c.product_code,
|
||||
"product_name": c.product_name, "cost_type": c.cost_type,
|
||||
"item_name": c.item_name,
|
||||
"standard_quantity": _float(c.standard_quantity),
|
||||
"unit": c.unit,
|
||||
"standard_price": _float(c.standard_price),
|
||||
"standard_cost": _float(c.standard_cost),
|
||||
"version": c.version, "remark": c.remark,
|
||||
} for c in costs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cost/actual")
|
||||
def bot_actual_costs(
|
||||
period: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(ActualCost)
|
||||
if period:
|
||||
query = query.filter(ActualCost.period == period)
|
||||
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
|
||||
return {
|
||||
"total": len(costs),
|
||||
"items": [
|
||||
{
|
||||
"id": c.id, "period": c.period,
|
||||
"product_code": c.product_code,
|
||||
"product_name": c.product_name,
|
||||
"cost_type": c.cost_type, "item_name": c.item_name,
|
||||
"actual_quantity": _float(c.actual_quantity),
|
||||
"actual_price": _float(c.actual_price),
|
||||
"actual_cost": _float(c.actual_cost),
|
||||
} for c in costs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 行动方案 ──
|
||||
|
||||
@router.get("/actions")
|
||||
def bot_actions(
|
||||
status: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(ActionPlan)
|
||||
if status:
|
||||
query = query.filter(ActionPlan.status == status)
|
||||
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
|
||||
return {
|
||||
"total": len(plans),
|
||||
"items": [
|
||||
{
|
||||
"id": p.id, "title": p.title,
|
||||
"description": p.description, "kpi_id": p.kpi_id,
|
||||
"assignee": p.assignee, "priority": p.priority,
|
||||
"status": p.status, "progress": p.progress,
|
||||
"target_value": p.target_value,
|
||||
"due_date": _safe_iso(p.due_date),
|
||||
"created_at": _safe_iso(p.created_at),
|
||||
} for p in plans
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 组织 ──
|
||||
|
||||
@router.get("/organization")
|
||||
def bot_org(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all()
|
||||
return {
|
||||
"total": len(nodes),
|
||||
"items": [
|
||||
{
|
||||
"id": n.id, "name": n.name,
|
||||
"parent_id": n.parent_id, "level": n.level,
|
||||
"code": n.code, "sort_order": n.sort_order,
|
||||
"enabled": n.enabled,
|
||||
} for n in nodes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 数据源 ──
|
||||
|
||||
@router.get("/data-sources")
|
||||
def bot_data_sources(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
sources = db.query(DataSourceConfig).all()
|
||||
return {
|
||||
"total": len(sources),
|
||||
"items": [
|
||||
{
|
||||
"id": s.id, "name": s.name,
|
||||
"source_type": s.source_type,
|
||||
"api_endpoint": s.api_endpoint,
|
||||
"sync_type": s.sync_type,
|
||||
"status": s.status,
|
||||
"last_sync_at": _safe_iso(s.last_sync_at),
|
||||
} for s in sources
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 用户 ──
|
||||
|
||||
@router.get("/users")
|
||||
def bot_users(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
users = db.query(User).all()
|
||||
return {
|
||||
"total": len(users),
|
||||
"items": [
|
||||
{"id": u.id, "username": u.username, "name": u.name,
|
||||
"role": u.role, "phone": u.phone}
|
||||
for u in users
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 统一查询(BOT首选) ──
|
||||
|
||||
@router.get("/query")
|
||||
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),
|
||||
):
|
||||
"""统一查询入口 — BOT用这个一次拿完需要的数据"""
|
||||
result = {"bot": bot["name"], "role": bot["role"], "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,
|
||||
}
|
||||
|
||||
if q in ("kpis", "all"):
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").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}
|
||||
for k in kpis
|
||||
]
|
||||
|
||||
if q in ("alerts", "all"):
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending")\
|
||||
.order_by(KPIAlert.created_at.desc()).limit(20).all()
|
||||
result["alerts"] = [
|
||||
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
|
||||
"kpi_id": a.kpi_id, "created_at": _safe_iso(a.created_at)}
|
||||
for a in alerts
|
||||
]
|
||||
|
||||
if q in ("maps", "all"):
|
||||
maps = db.query(StrategicMap).limit(10).all()
|
||||
result["maps"] = [
|
||||
{"id": m.id, "title": m.title, "status": m.status,
|
||||
"version": m.version, "created_at": _safe_iso(m.created_at)}
|
||||
for m in maps
|
||||
]
|
||||
|
||||
if q in ("budget", "all"):
|
||||
plans = db.query(BudgetPlan).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,
|
||||
"kpi_id": p.kpi_id}
|
||||
for p in plans
|
||||
]
|
||||
|
||||
if q in ("cost", "all"):
|
||||
sc = db.query(StandardCost).limit(50).all()
|
||||
result["costs"] = [
|
||||
{"id": c.id, "product": c.product_name, "type": c.cost_type,
|
||||
"standard": _float(c.standard_cost), "unit": c.unit}
|
||||
for c in sc
|
||||
]
|
||||
|
||||
if q in ("actions", "all"):
|
||||
acts = db.query(ActionPlan).limit(30).all()
|
||||
result["actions"] = [
|
||||
{"id": a.id, "title": a.title, "status": a.status,
|
||||
"progress": a.progress, "assignee": a.assignee}
|
||||
for a in acts
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 自然语言查询 ──
|
||||
|
||||
@router.get("/nlp")
|
||||
def bot_nlp(
|
||||
intent: str = Query("overview"),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
自然语言意图映射:
|
||||
overview/总览/finance/财务/alerts/预警/budget/预算/cost/成本/maps/战略/actions/行动
|
||||
"""
|
||||
m = {
|
||||
"总览": "overview", "驾驶舱": "overview",
|
||||
"财务": "finance", "财务状况": "finance",
|
||||
"预警": "alerts", "风险": "alerts",
|
||||
"预算": "budget", "预算执行": "budget",
|
||||
"成本": "cost", "成本分析": "cost",
|
||||
"战略": "maps", "战略地图": "maps",
|
||||
"行动": "actions", "改善": "actions",
|
||||
}
|
||||
resolved = m.get(intent, intent)
|
||||
return bot_query(q=resolved, bot=bot, db=db)
|
||||
@@ -0,0 +1,301 @@
|
||||
"""预算自动从KPI推算 API — P1-2
|
||||
|
||||
根据KPI的目标值自动生成预算建议。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cma.budget_gen")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/budget", tags=["KPI→预算"],
|
||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||
)
|
||||
|
||||
|
||||
def _calc_budget(kpi: KPIDefinition) -> dict:
|
||||
"""根据KPI类型推算预算
|
||||
|
||||
算法:
|
||||
- 降本类: (当前值-目标值)×0.3
|
||||
- 增收类: 目标增收额×0.2
|
||||
- 能力类: 人均培训成本×人数
|
||||
- 系统类: 按模块开发费估算
|
||||
"""
|
||||
category = kpi.category or ""
|
||||
target = kpi.target_value or 0
|
||||
|
||||
result = {
|
||||
"suggested_budget": 0,
|
||||
"calc_logic": "",
|
||||
"calc_type": "未知",
|
||||
}
|
||||
|
||||
# 降本类: cost_control, cash_risk
|
||||
if category in ("cost_control", "cash_risk", "asset_efficiency"):
|
||||
result["calc_type"] = "降本类"
|
||||
# 当前值需要从最新的KPIValue获取
|
||||
# 这里返回算法描述,前端传入当前值
|
||||
result["calc_type_desc"] = "(当前值-目标值)×0.3"
|
||||
result["suggested_budget"] = 0 # 需要前端传当前值
|
||||
|
||||
# 增收类: revenue_growth, profitability
|
||||
elif category in ("revenue_growth", "profitability", "customer_scale"):
|
||||
result["calc_type"] = "增收类"
|
||||
result["calc_type_desc"] = "目标增收额×0.2"
|
||||
result["suggested_budget"] = round(target * 0.2, 2)
|
||||
|
||||
# 能力类: talent_pipeline, employee_engagement, innovation
|
||||
elif category in ("talent_pipeline", "employee_engagement", "innovation"):
|
||||
result["calc_type"] = "能力类"
|
||||
result["calc_type_desc"] = "人均培训成本×人数"
|
||||
result["suggested_budget"] = 0 # 需要外部参数
|
||||
|
||||
# 系统类: 默认为系统类
|
||||
elif category in ("supply_chain", "delivery_quality", "customer_concentration", "customer_satisfaction"):
|
||||
result["calc_type"] = "系统类"
|
||||
result["calc_type_desc"] = "按功能模块开发费估算"
|
||||
result["suggested_budget"] = round(target * 0.15, 2)
|
||||
|
||||
# 其他未分类
|
||||
else:
|
||||
result["calc_type"] = "系统类"
|
||||
result["calc_type_desc"] = "按功能模块开发费估算"
|
||||
result["suggested_budget"] = round(target * 0.15, 2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/kpi-budget-candidates")
|
||||
def get_kpi_budget_candidates(
|
||||
year: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取可用于生成预算的KPI列表,按类型分类"""
|
||||
if not year:
|
||||
year = datetime.now().year
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
|
||||
# 获取每个KPI的最新实际值
|
||||
latest_values = {}
|
||||
for kpi in kpis:
|
||||
v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
if v:
|
||||
latest_values[kpi.id] = v.actual_value
|
||||
|
||||
# 分类
|
||||
categorized = {
|
||||
"cost_reduction": [], # 降本类
|
||||
"revenue_growth": [], # 增收类
|
||||
"capability": [], # 能力类
|
||||
"system": [], # 系统类
|
||||
}
|
||||
|
||||
for kpi in kpis:
|
||||
calc_info = _calc_budget(kpi)
|
||||
current_val = latest_values.get(kpi.id)
|
||||
|
||||
# 降本类: 需要当前值
|
||||
if calc_info["calc_type"] == "降本类":
|
||||
if current_val is not None and kpi.target_value:
|
||||
diff = current_val - kpi.target_value
|
||||
suggested = round(max(diff, 0) * 0.3, 2)
|
||||
calc_logic = f"当前值{current_val}-目标值{kpi.target_value}={diff:.2f},×0.3={suggested:.2f}"
|
||||
else:
|
||||
suggested = 0
|
||||
calc_logic = "缺少当前值或目标值,无法计算"
|
||||
item = {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"calc_type": "降本类",
|
||||
"target_value": kpi.target_value,
|
||||
"current_value": current_val,
|
||||
"suggested_budget": suggested,
|
||||
"calc_logic": calc_logic,
|
||||
}
|
||||
categorized["cost_reduction"].append(item)
|
||||
|
||||
elif calc_info["calc_type"] == "增收类":
|
||||
suggested = round((kpi.target_value or 0) * 0.2, 2)
|
||||
calc_logic = f"目标增收额{kpi.target_value}×0.2={suggested:.2f}"
|
||||
item = {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"calc_type": "增收类",
|
||||
"target_value": kpi.target_value,
|
||||
"current_value": current_val,
|
||||
"suggested_budget": suggested,
|
||||
"calc_logic": calc_logic,
|
||||
}
|
||||
categorized["revenue_growth"].append(item)
|
||||
|
||||
elif calc_info["calc_type"] == "能力类":
|
||||
# 假设人均培训成本2000元, 默认10人
|
||||
suggested = round(2000 * 10, 2)
|
||||
calc_logic = f"人均培训成本2000元×10人={suggested:.2f}(可调整人数和单价)"
|
||||
item = {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"calc_type": "能力类",
|
||||
"target_value": kpi.target_value,
|
||||
"current_value": current_val,
|
||||
"suggested_budget": suggested,
|
||||
"calc_logic": calc_logic,
|
||||
"per_head_cost": 2000,
|
||||
"head_count": 10,
|
||||
}
|
||||
categorized["capability"].append(item)
|
||||
|
||||
else: # 系统类
|
||||
suggested = round((kpi.target_value or 0) * 0.15, 2)
|
||||
if suggested <= 0:
|
||||
suggested = 30000 # 默认3万
|
||||
calc_logic = "按模块开发费估算: 默认30000元(可调整)"
|
||||
else:
|
||||
calc_logic = f"目标值{kpi.target_value}×0.15={suggested:.2f}"
|
||||
item = {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"calc_type": "系统类",
|
||||
"target_value": kpi.target_value,
|
||||
"current_value": current_val,
|
||||
"suggested_budget": suggested,
|
||||
"calc_logic": calc_logic,
|
||||
}
|
||||
categorized["system"].append(item)
|
||||
|
||||
return {"data": categorized}
|
||||
|
||||
|
||||
@router.post("/generate-from-kpis")
|
||||
def generate_budget_from_kpis(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""从选中的KPI生成预算科目
|
||||
|
||||
Body: {
|
||||
year: int,
|
||||
month: int,
|
||||
version: string,
|
||||
items: [
|
||||
{
|
||||
kpi_id: int,
|
||||
budget_amount: float, // 用户可编辑
|
||||
calc_logic: string,
|
||||
calc_type: string,
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
year = data.get("year", datetime.now().year)
|
||||
month = data.get("month", datetime.now().month + 1)
|
||||
version = data.get("version", "v1.0")
|
||||
items = data.get("items", [])
|
||||
|
||||
if not items:
|
||||
raise HTTPException(400, "请至少选择一个KPI")
|
||||
|
||||
period = f"{year}-{month:02d}"
|
||||
results = []
|
||||
total_amount = 0
|
||||
|
||||
for item in items:
|
||||
kpi_id = item.get("kpi_id")
|
||||
budget_amount = item.get("budget_amount")
|
||||
calc_logic = item.get("calc_logic", "")
|
||||
calc_type = item.get("calc_type", "")
|
||||
|
||||
if not kpi_id or budget_amount is None:
|
||||
continue
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
continue
|
||||
|
||||
# 检查是否已有记录
|
||||
existing = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.version == version,
|
||||
BudgetPlan.status == "active",
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.budget_value = budget_amount
|
||||
existing.source_type = "kpi_generated"
|
||||
existing.source_kpi_id = kpi_id
|
||||
existing.calc_logic = calc_logic
|
||||
existing.remark = f"KPI推算({calc_type}): {calc_logic}"
|
||||
plan_id = existing.id
|
||||
else:
|
||||
plan = BudgetPlan(
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
budget_value=budget_amount,
|
||||
budget_year=year,
|
||||
budget_month=month,
|
||||
version=version,
|
||||
status="active",
|
||||
source_type="kpi_generated",
|
||||
source_kpi_id=kpi_id,
|
||||
calc_logic=calc_logic,
|
||||
remark=f"KPI推算({calc_type}): {calc_logic}",
|
||||
created_by=current_user.name if hasattr(current_user, "name") else "",
|
||||
)
|
||||
db.add(plan)
|
||||
db.flush()
|
||||
plan_id = plan.id
|
||||
|
||||
total_amount += budget_amount
|
||||
results.append({
|
||||
"kpi_id": kpi_id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"budget_amount": budget_amount,
|
||||
"calc_logic": calc_logic,
|
||||
"plan_id": plan_id,
|
||||
})
|
||||
|
||||
# 操作日志
|
||||
log = OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="kpi_generate_budget",
|
||||
target_type="budget",
|
||||
detail=json.dumps({
|
||||
"year": year,
|
||||
"month": month,
|
||||
"version": version,
|
||||
"item_count": len(results),
|
||||
"total_amount": total_amount,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"已从{len(results)}个KPI生成预算,合计¥{total_amount:,.2f}",
|
||||
"total_amount": total_amount,
|
||||
"items": results,
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"""客户维度KPI看板 API — P0-2"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, User
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.customer")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||||
"""解析时间区间"""
|
||||
today = datetime.now()
|
||||
if period_type == "month":
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "quarter":
|
||||
q = (today.month - 1) // 3
|
||||
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "year":
|
||||
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "custom" and start_date and end_date:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
else:
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
return start, end
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_customer_kpis(
|
||||
period: str = Query("month"),
|
||||
start_date: str = Query(None),
|
||||
end_date: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
|
||||
start, end = parse_period(period, start_date, end_date)
|
||||
period_str = start.strftime("%Y-%m")
|
||||
|
||||
# 只查 customer 维度的 KPI
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
result = []
|
||||
for k in kpis:
|
||||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
||||
|
||||
if period == "month":
|
||||
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
|
||||
elif period == "quarter":
|
||||
q_month = (datetime.now().month - 1) // 3
|
||||
months = [f"{datetime.now().year}-{m:02d}" for m in range(q_month*3+1, q_month*3+4)]
|
||||
values = base_query.filter(KPIValue.period.in_(months)).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
||||
elif period == "year":
|
||||
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
|
||||
elif period == "custom" and start_date and end_date:
|
||||
periods = []
|
||||
d = start
|
||||
while d <= end:
|
||||
periods.append(d.strftime("%Y-%m"))
|
||||
d += timedelta(days=32)
|
||||
d = d.replace(day=1)
|
||||
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
||||
else:
|
||||
latest = base_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
# 最新预警
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
# 趋势(环比变化率)
|
||||
trend = None
|
||||
achievement_rate = None
|
||||
period_values = []
|
||||
|
||||
if latest and latest.actual_value:
|
||||
prev_period_str = None
|
||||
if period == "month":
|
||||
year_s, month_s = period_str.split("-")
|
||||
y_s, m_s = int(year_s), int(month_s)
|
||||
m_s -= 1
|
||||
if m_s <= 0:
|
||||
m_s += 12
|
||||
y_s -= 1
|
||||
prev_period_str = f"{y_s}-{m_s:02d}"
|
||||
|
||||
if prev_period_str:
|
||||
prev_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.period == prev_period_str,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if prev_val and prev_val.actual_value and prev_val.actual_value > 0:
|
||||
trend = round((latest.actual_value - prev_val.actual_value) / prev_val.actual_value * 100, 2)
|
||||
elif prev_val and prev_val.actual_value and prev_val.actual_value == 0:
|
||||
trend = 100.0 if latest.actual_value > 0 else 0
|
||||
|
||||
# 达成率
|
||||
if latest and latest.actual_value and k.target_value and k.target_value > 0:
|
||||
achievement_rate = round(latest.actual_value / k.target_value * 100, 1)
|
||||
|
||||
# 最近6期趋势数据
|
||||
period_q = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
period_values = [
|
||||
{"period": v.period, "value": v.actual_value}
|
||||
for v in reversed(period_q) if v.actual_value is not None
|
||||
]
|
||||
|
||||
result.append({
|
||||
"id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"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",
|
||||
"alert_message": alert.alert_message if alert else None,
|
||||
"frequency": k.frequency,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
"responsible_user": k.responsible_user,
|
||||
"trend": trend,
|
||||
"achievement_rate": achievement_rate,
|
||||
"period_values": period_values,
|
||||
"kpi_name": k.kpi_name,
|
||||
})
|
||||
|
||||
return {"data": result, "period": period, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/trend/{kpi_id}")
|
||||
def get_kpi_trend(
|
||||
kpi_id: int,
|
||||
months: int = Query(12, ge=3, le=24),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取单个KPI的历史趋势数据"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 获取最近N期数据
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
).order_by(KPIValue.period.desc()).limit(months).all()
|
||||
|
||||
trend_data = [
|
||||
{"period": v.period, "value": v.actual_value}
|
||||
for v in reversed(values) if v.actual_value is not None
|
||||
]
|
||||
|
||||
# 计算预警水平和触发时间
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi_id,
|
||||
).order_by(KPIAlert.created_at.desc()).limit(10).all()
|
||||
|
||||
alert_logs = [
|
||||
{
|
||||
"level": a.alert_level,
|
||||
"message": a.alert_message,
|
||||
"time": a.created_at.isoformat() if a.created_at else None,
|
||||
"status": a.status,
|
||||
}
|
||||
for a in alerts
|
||||
]
|
||||
|
||||
return {
|
||||
"kpi": {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"target_value": kpi.target_value,
|
||||
"unit": kpi.unit,
|
||||
"threshold_green": kpi.threshold_green,
|
||||
"threshold_yellow": kpi.threshold_yellow,
|
||||
"threshold_red": kpi.threshold_red,
|
||||
},
|
||||
"trend_data": trend_data,
|
||||
"alerts": alert_logs,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def get_customer_summary(
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""客户维度概要统计"""
|
||||
total = db.query(func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).scalar() or 0
|
||||
|
||||
# 预警统计
|
||||
pending_alerts = db.query(func.count(KPIAlert.id)).filter(
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.kpi_id.in_(
|
||||
db.query(KPIDefinition.id).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
)
|
||||
),
|
||||
).scalar() or 0
|
||||
|
||||
# 二级类别分布
|
||||
cat_stats = db.query(
|
||||
KPIDefinition.category,
|
||||
func.count(KPIDefinition.id),
|
||||
).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).group_by(KPIDefinition.category).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending_alerts": pending_alerts,
|
||||
"category_stats": [{"category": c[0], "count": c[1]} for c in cat_stats],
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
"""驾驶舱 API v2 — 支持时间区间"""
|
||||
from fastapi import APIRouter, Depends, Query, Request, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, User
|
||||
from app.utils.cache import get as cache_get, set as cache_set
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.dashboard")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||||
"""解析时间区间"""
|
||||
today = datetime.now()
|
||||
if period_type == "month":
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "quarter":
|
||||
q = (today.month - 1) // 3
|
||||
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "year":
|
||||
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "custom" and start_date and end_date:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
else:
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
return start, end
|
||||
|
||||
def period_prefix(period_type: str):
|
||||
"""生成SQL期间前缀匹配"""
|
||||
if period_type == "month":
|
||||
return datetime.now().strftime("%Y-%m")
|
||||
elif period_type == "quarter":
|
||||
now = datetime.now()
|
||||
q = (now.month - 1) // 3
|
||||
months = [f"{now.year}-{m:02d}" for m in range(q*3+1, q*3+4)]
|
||||
return months
|
||||
elif period_type == "year":
|
||||
return str(datetime.now().year)
|
||||
return None
|
||||
|
||||
@router.get("/summary")
|
||||
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db)):
|
||||
cache_key = f"summary:{role}:{period}"
|
||||
cached = cache_get("dashboard", cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
kpi_total = db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar()
|
||||
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
|
||||
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active").group_by(KPIDefinition.dimension).all()
|
||||
|
||||
# 读取最近一次同步状态(从日志文件最后一行)
|
||||
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
|
||||
try:
|
||||
with open("/var/log/cma-daily-sync.log", "r") as f:
|
||||
lines = f.readlines()
|
||||
# 从最后往前找包含 "完成" 或 "失败" 的行
|
||||
for line in reversed(lines[-50:]):
|
||||
if "全部完成" in line:
|
||||
sync_status["status"] = "success"
|
||||
sync_status["last_sync"] = line.strip()
|
||||
break
|
||||
elif "失败" in line or "ERROR" in line:
|
||||
sync_status["status"] = "failed"
|
||||
sync_status["last_sync"] = line.strip()
|
||||
break
|
||||
else:
|
||||
# 没找到完成/失败标记,取最后一行
|
||||
sync_status["last_sync"] = lines[-1].strip() if lines else None
|
||||
except Exception as e:
|
||||
sync_status["detail"] = str(e)
|
||||
|
||||
result = {
|
||||
"kpi_total": kpi_total or 0, "alert_count": alert_count or 0,
|
||||
"dimension_stats": [{"dimension": d[0], "count": d[1]} for d in dims],
|
||||
"sync_status": sync_status,
|
||||
}
|
||||
cache_set("dashboard", cache_key, result, ttl_seconds=30)
|
||||
return result
|
||||
|
||||
@router.get("/kpis")
|
||||
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
||||
start_date: str = Query(None), end_date: str = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
start, end = parse_period(period, start_date, end_date)
|
||||
period_str = start.strftime("%Y-%m")
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
result = []
|
||||
|
||||
for k in kpis:
|
||||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
||||
|
||||
if period == "month":
|
||||
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
|
||||
elif period == "quarter":
|
||||
months = period_prefix("quarter")
|
||||
values = base_query.filter(KPIValue.period.in_(months)).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
||||
elif period == "year":
|
||||
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
|
||||
elif period == "custom" and start_date and end_date:
|
||||
periods = []
|
||||
d = start
|
||||
while d <= end:
|
||||
periods.append(d.strftime("%Y-%m"))
|
||||
d += timedelta(days=32)
|
||||
d = d.replace(day=1)
|
||||
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
||||
else:
|
||||
latest = base_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
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,
|
||||
"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",
|
||||
"alert_message": alert.alert_message if alert else None,
|
||||
"frequency": k.frequency,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
})
|
||||
|
||||
return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
|
||||
|
||||
|
||||
@router.get("/my-kpis")
|
||||
def get_my_kpis(
|
||||
current_user: User = Depends(require_auth),
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户负责的KPI
|
||||
- business角色:只看自己负责的KPI
|
||||
- 其他角色:看所有有预警的KPI
|
||||
"""
|
||||
role = current_user.role
|
||||
username = current_user.username
|
||||
name = current_user.name
|
||||
period_str = datetime.now().strftime("%Y-%m")
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
result = []
|
||||
|
||||
for k in kpis:
|
||||
# business角色筛选
|
||||
if role == "business":
|
||||
responsible = (k.responsible_user or "").strip()
|
||||
if responsible and responsible != username and responsible != name:
|
||||
continue
|
||||
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.period == period_str,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
trend_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
||||
|
||||
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,
|
||||
"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",
|
||||
"alert_message": alert.alert_message if alert else None,
|
||||
"alert_id": alert.id if alert else None,
|
||||
"frequency": k.frequency,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
"responsible_user": k.responsible_user,
|
||||
"trend": trend,
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
"threshold_red": k.threshold_red,
|
||||
})
|
||||
|
||||
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
|
||||
|
||||
|
||||
@router.get("/finance-analysis")
|
||||
def get_finance_analysis(
|
||||
current_user: User = Depends(require_auth),
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""财务工作台分析数据"""
|
||||
period_str = datetime.now().strftime("%Y-%m")
|
||||
|
||||
finance_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "finance",
|
||||
).all()
|
||||
|
||||
kpi_data = []
|
||||
for k in finance_kpis:
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.period == period_str,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
trend_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
||||
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
kpi_data.append({
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"unit": k.unit, "target_value": k.target_value,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
"threshold_red": k.threshold_red,
|
||||
"trend": trend,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
"frequency": k.frequency,
|
||||
})
|
||||
|
||||
total_sales = next((k for k in kpi_data if k["kpi_code"] == "SALES_TOTAL"), None)
|
||||
gross_profit = next((k for k in kpi_data if k["kpi_code"] == "SALES_PROFIT_RATE"), None)
|
||||
cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None)
|
||||
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
|
||||
|
||||
return {
|
||||
"period": period_str,
|
||||
"kpis": kpi_data,
|
||||
"summary": {
|
||||
"total_sales": total_sales["actual_value"] if total_sales else None,
|
||||
"gross_profit_rate": gross_profit["actual_value"] if gross_profit else None,
|
||||
"cost_control_rate": cost_control["actual_value"] if cost_control else None,
|
||||
"receivable_turnover": receivable["actual_value"] if receivable else None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/predict")
|
||||
def predict_kpis(db: Session = Depends(get_db)):
|
||||
"""基于历史趋势预测下月KPI值(简单线性回归)"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
period_str = datetime.now().strftime("%Y-%m")
|
||||
next_month = int(period_str[5:7]) + 1
|
||||
next_year = int(period_str[:4])
|
||||
if next_month > 12:
|
||||
next_month = 1
|
||||
next_year += 1
|
||||
next_period = f"{next_year}-{next_month:02d}"
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
predictions = []
|
||||
|
||||
for k in kpis:
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.asc()).all()
|
||||
|
||||
# 需要至少3个数据点才能做预测
|
||||
if len(values) < 3:
|
||||
continue
|
||||
|
||||
# 简单线性回归: y = a + bx
|
||||
points = [(i, v.actual_value) for i, v in enumerate(values) if v.actual_value is not None]
|
||||
if len(points) < 3:
|
||||
continue
|
||||
|
||||
n = len(points)
|
||||
sum_x = sum(p[0] for p in points)
|
||||
sum_y = sum(p[1] for p in points)
|
||||
sum_xy = sum(p[0] * p[1] for p in points)
|
||||
sum_xx = sum(p[0] ** 2 for p in points)
|
||||
|
||||
# 斜率 b = (n*sum_xy - sum_x*sum_y) / (n*sum_xx - sum_x*sum_x)
|
||||
denom = n * sum_xx - sum_x * sum_x
|
||||
if denom == 0:
|
||||
continue
|
||||
b = (n * sum_xy - sum_x * sum_y) / denom
|
||||
a = (sum_y - b * sum_x) / n
|
||||
|
||||
# 预测下个月(x = n,因为最后一个索引是 n-1)
|
||||
predicted_value = a + b * n
|
||||
|
||||
# 检查预测值是否触发阈值
|
||||
alert_level = "none"
|
||||
if k.threshold_red:
|
||||
try:
|
||||
op = k.threshold_red[:2] if len(k.threshold_red) > 1 and k.threshold_red[1] in "=<>" else k.threshold_red[0]
|
||||
val_str = k.threshold_red.replace(op, "").strip()
|
||||
val = float(val_str)
|
||||
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
|
||||
alert_level = "red"
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if alert_level == "none" and k.threshold_yellow:
|
||||
try:
|
||||
op = k.threshold_yellow[:2] if len(k.threshold_yellow) > 1 and k.threshold_yellow[1] in "=<>" else k.threshold_yellow[0]
|
||||
val_str = k.threshold_yellow.replace(op, "").strip()
|
||||
val = float(val_str)
|
||||
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
|
||||
alert_level = "yellow"
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
predictions.append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"target_value": k.target_value,
|
||||
"last_value": points[-1][1] if points else None,
|
||||
"predicted_value": round(predicted_value, 2),
|
||||
"predicted_period": next_period,
|
||||
"alert_level": alert_level,
|
||||
"trend": "up" if b > 0 else ("down" if b < 0 else "stable"),
|
||||
"confidence": "high" if len(points) >= 6 else ("medium" if len(points) >= 4 else "low"),
|
||||
"data_points": len(points),
|
||||
})
|
||||
|
||||
return {
|
||||
"current_period": period_str,
|
||||
"next_period": next_period,
|
||||
"predictions": predictions,
|
||||
"kpi_count": len(kpis),
|
||||
"predictable_count": len(predictions),
|
||||
}
|
||||
|
||||
|
||||
# ── 个人工作台 ──────────────────────────────
|
||||
|
||||
|
||||
@router.get("/my-dashboard")
|
||||
def my_dashboard(
|
||||
current_user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
|
||||
username = current_user.username
|
||||
name = current_user.name
|
||||
role = current_user.role
|
||||
|
||||
# 角色 → 维度映射(从已发布战略地图中按角色筛选对应维度的KPI)
|
||||
ROLE_DIMENSIONS = {
|
||||
"ceo": ["finance", "customer", "process", "learning"], # CEO看全部维度
|
||||
"finance": ["finance"], # 财务看财务维度
|
||||
"business": ["customer", "process"], # 业务看客户+流程维度
|
||||
"it": ["process", "learning"], # IT看流程+学习成长
|
||||
}
|
||||
role_dims = ROLE_DIMENSIONS.get(role, ["finance", "customer"])
|
||||
|
||||
# 获取所有已发布战略地图的KPI code集合(dimensions中引用的)
|
||||
from app.models import StrategicMap
|
||||
published_maps = db.query(StrategicMap).filter(StrategicMap.status == "published").all()
|
||||
map_kpi_codes = set()
|
||||
for sm in published_maps:
|
||||
dims = sm.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except Exception:
|
||||
continue
|
||||
for dim in dims:
|
||||
for obj in dim.get("objectives", []):
|
||||
for code in obj.get("kpis", []):
|
||||
map_kpi_codes.add(code)
|
||||
|
||||
# 1. 按角色维度筛选(从已发布地图的KPI中取符合角色维度的)
|
||||
map_kpis = []
|
||||
if map_kpi_codes:
|
||||
map_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code.in_(map_kpi_codes),
|
||||
KPIDefinition.dimension.in_(role_dims),
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
|
||||
# 2. 补充负责的KPI(responsible_user匹配)
|
||||
assigned_kpis = db.query(KPIDefinition).filter(
|
||||
or_(
|
||||
KPIDefinition.responsible_user == username,
|
||||
KPIDefinition.responsible_user == name,
|
||||
),
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
assigned_ids = {k.id for k in assigned_kpis}
|
||||
|
||||
# 去重合并
|
||||
all_kpis = map_kpis + [k for k in assigned_kpis if k.id not in {mk.id for mk in map_kpis}]
|
||||
|
||||
kpi_list = []
|
||||
for k in all_kpis:
|
||||
latest_v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
|
||||
actual = latest_v.actual_value if latest_v else None
|
||||
target = k.target_value
|
||||
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")
|
||||
|
||||
kpi_list.append({
|
||||
"id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"target_value": target,
|
||||
"actual_value": actual,
|
||||
"unit": k.unit,
|
||||
"level": level,
|
||||
"period": latest_v.period if latest_v else None,
|
||||
})
|
||||
|
||||
# 2. 我的改善行动(assignee匹配)
|
||||
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()
|
||||
|
||||
plan_list = []
|
||||
for p in my_plans:
|
||||
overdue = False
|
||||
if p.due_date and p.status not in ("completed", "cancelled"):
|
||||
overdue = p.due_date < datetime.now()
|
||||
kpi_name = ""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||
if kpi:
|
||||
kpi_name = kpi.kpi_name
|
||||
|
||||
plan_list.append({
|
||||
"id": p.id,
|
||||
"kpi_id": p.kpi_id,
|
||||
"kpi_name": kpi_name,
|
||||
"title": p.title,
|
||||
"assignee": p.assignee,
|
||||
"priority": p.priority,
|
||||
"status": p.status,
|
||||
"progress": p.progress or 0,
|
||||
"due_date": p.due_date.isoformat() if p.due_date else None,
|
||||
"overdue": overdue,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
})
|
||||
|
||||
# 3. 待办提醒
|
||||
reminders = []
|
||||
|
||||
# 逾期行动
|
||||
for p in plan_list:
|
||||
if p["overdue"]:
|
||||
reminders.append({
|
||||
"type": "overdue_plan",
|
||||
"severity": "danger",
|
||||
"message": f"你负责的「{p['title']}」已逾期",
|
||||
"related_id": p["id"],
|
||||
"related_type": "action_plan",
|
||||
})
|
||||
|
||||
# 红色预警KPI
|
||||
for k in kpi_list:
|
||||
if k["level"] == "red":
|
||||
reminders.append({
|
||||
"type": "red_kpi",
|
||||
"severity": "danger",
|
||||
"message": f"你负责的KPI「{k['kpi_name']}」处于红色预警",
|
||||
"related_id": k["id"],
|
||||
"related_type": "kpi",
|
||||
})
|
||||
|
||||
# 黄色预警KPI
|
||||
for k in kpi_list:
|
||||
if k["level"] == "yellow":
|
||||
reminders.append({
|
||||
"type": "yellow_kpi",
|
||||
"severity": "warning",
|
||||
"message": f"你负责的KPI「{k['kpi_name']}」处于黄色预警",
|
||||
"related_id": k["id"],
|
||||
"related_type": "kpi",
|
||||
})
|
||||
|
||||
return {
|
||||
"kpis": kpi_list,
|
||||
"action_plans": plan_list,
|
||||
"reminders": reminders,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/erp-trends")
|
||||
def get_erp_trends(
|
||||
current_user: User = Depends(require_auth),
|
||||
months: int = Query(12, ge=3, le=36),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取ERP关键指标趋势数据(驾驶舱趋势分析用)"""
|
||||
codes = [
|
||||
"F_REVENUE",
|
||||
"F_PROFIT_RATE",
|
||||
"F_NET_PROFIT_RATE",
|
||||
"F_COST_RATIO",
|
||||
"F_CASH_FLOW",
|
||||
"F_AR_TURNOVER",
|
||||
"F_ROE",
|
||||
"F_ASSET_TURNOVER",
|
||||
"F_DEBT_RATIO",
|
||||
"C_CUSTOMER_COUNT",
|
||||
"C_CUSTOMER_SATISFACTION",
|
||||
"C_CUSTOMER_CONCENTRATION",
|
||||
"P_DELIVERY_ON_TIME",
|
||||
"P_DEFECT_RATE",
|
||||
"P_SUPPLY_CYCLE",
|
||||
"L_TRAINING_HOURS",
|
||||
"L_EMPLOYEE_TURNOVER",
|
||||
"L_INNOVATION_COUNT",
|
||||
"L_TECH_COVERAGE",
|
||||
]
|
||||
result = {}
|
||||
|
||||
for code in codes:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if not kpi:
|
||||
continue
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
).order_by(KPIValue.period.desc()).limit(months).all()
|
||||
|
||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(values)]
|
||||
if trend:
|
||||
vals = [v["value"] for v in trend if v["value"] is not None]
|
||||
latest = vals[-1] if vals else 0
|
||||
first = vals[0] if vals else 0
|
||||
if latest > first * 1.05:
|
||||
trend_dir = "up"
|
||||
elif latest < first * 0.95:
|
||||
trend_dir = "down"
|
||||
else:
|
||||
trend_dir = "stable"
|
||||
|
||||
mom_val = vals[-2] if len(vals) >= 2 else None
|
||||
yoy_val = vals[-12] if len(vals) >= 12 else (vals[0] if len(vals) >= 1 else None)
|
||||
|
||||
result[code] = {
|
||||
"name": kpi.kpi_name,
|
||||
"unit": kpi.unit or "",
|
||||
"target": kpi.target_value,
|
||||
"trend": trend,
|
||||
"trend_dir": trend_dir,
|
||||
"latest": latest,
|
||||
"mom": mom_val,
|
||||
"mom_rate": round((latest - mom_val) / abs(mom_val) * 100, 1) if mom_val and mom_val != 0 else None,
|
||||
"yoy": yoy_val,
|
||||
"yoy_rate": round((latest - yoy_val) / abs(yoy_val) * 100, 1) if yoy_val and yoy_val != 0 else None,
|
||||
}
|
||||
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@router.get("/dupont")
|
||||
async def dupont_analysis(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""杜邦分析 — ROE分解
|
||||
ROE = 净利率 × 资产周转率 × 权益乘数
|
||||
"""
|
||||
cache_key = f"dupont:{current_user.role}"
|
||||
cached = cache_get("dashboard", cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 获取底层数据KPI
|
||||
def get_kpi_value(code: str) -> tuple:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if not kpi:
|
||||
return None, None, None
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).first()
|
||||
prev = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).offset(1).first()
|
||||
val = latest.actual_value if latest else None
|
||||
pval = prev.actual_value if prev else None
|
||||
return val, pval, kpi.unit
|
||||
|
||||
# 营收、利润、总资产、净资产
|
||||
revenue, prev_revenue, _ = get_kpi_value("F_REVENUE")
|
||||
# 用营收×净利润率估算净利润(数据库没有净利润绝对值)
|
||||
profit_net = None
|
||||
prev_profit_net = None
|
||||
if revenue:
|
||||
net_profit_rate, prev_npr, _ = get_kpi_value("F_NET_PROFIT_RATE")
|
||||
if net_profit_rate:
|
||||
profit_net = revenue * (net_profit_rate / 100)
|
||||
if prev_revenue and prev_npr:
|
||||
prev_profit_net = prev_revenue * (prev_npr / 100)
|
||||
# 如果还是算不出来,用毛利率做替代估算
|
||||
if profit_net is None and revenue:
|
||||
gross_profit, _, _ = get_kpi_value("F_PROFIT_RATE")
|
||||
profit_net = revenue * (gross_profit / 100) * 0.7 if gross_profit else None # 粗略估算净利润=毛利*0.7
|
||||
|
||||
asset_total, prev_asset, _ = get_kpi_value("F_ASSET_TOTAL")
|
||||
equity_total, prev_equity, _ = get_kpi_value("F_EQUITY_TOTAL")
|
||||
|
||||
# 计算杜邦因子
|
||||
result = {"roe": None, "factors": {}, "raw_data": {}, "history": {}}
|
||||
|
||||
if revenue and profit_net and asset_total and equity_total and all(v > 0 for v in [revenue, asset_total, equity_total]):
|
||||
net_profit_margin = round(profit_net / revenue, 4) # 净利率
|
||||
asset_turnover = round(revenue / asset_total, 4) # 资产周转率
|
||||
equity_multiplier = round(asset_total / equity_total, 4) # 权益乘数
|
||||
roe = round(net_profit_margin * asset_turnover * equity_multiplier * 100, 2)
|
||||
|
||||
result["roe"] = roe
|
||||
result["factors"] = {
|
||||
"net_profit_margin": {"value": net_profit_margin, "label": "净利率", "desc": f"净利润/{'营收' if revenue else '-'} = {net_profit_margin*100:.2f}%"},
|
||||
"asset_turnover": {"value": asset_turnover, "label": "资产周转率", "desc": f"营收/总资产 = {asset_turnover:.4f}次"},
|
||||
"equity_multiplier": {"value": equity_multiplier, "label": "权益乘数", "desc": f"总资产/净资产 = {equity_multiplier:.4f}"},
|
||||
}
|
||||
result["raw_data"] = {
|
||||
"revenue": revenue,
|
||||
"profit_net": profit_net,
|
||||
"asset_total": asset_total,
|
||||
"equity_total": equity_total,
|
||||
}
|
||||
|
||||
# 环比计算
|
||||
if prev_revenue and prev_profit_net and prev_asset and prev_equity and all(v > 0 for v in [prev_revenue, prev_asset, prev_equity]):
|
||||
prev_npm = round(prev_profit_net / prev_revenue, 4)
|
||||
prev_at = round(prev_revenue / prev_asset, 4)
|
||||
prev_em = round(prev_asset / prev_equity, 4)
|
||||
prev_roe = round(prev_npm * prev_at * prev_em * 100, 2)
|
||||
result["history"]["prev"] = {
|
||||
"roe": prev_roe,
|
||||
"net_profit_margin": prev_npm,
|
||||
"asset_turnover": prev_at,
|
||||
"equity_multiplier": prev_em,
|
||||
}
|
||||
# 同比变化
|
||||
change = round(roe - prev_roe, 2)
|
||||
npm_change = round((net_profit_margin - prev_npm) * 10000, 2) # 转成BP
|
||||
at_change = round(asset_turnover - prev_at, 4)
|
||||
em_change = round(equity_multiplier - prev_em, 4)
|
||||
result["history"]["change"] = {
|
||||
"roe": change,
|
||||
"roe_label": f"{'+' if change > 0 else ''}{change}%",
|
||||
"net_profit_margin_bp": npm_change,
|
||||
"asset_turnover": at_change,
|
||||
"equity_multiplier": em_change,
|
||||
}
|
||||
result["history"]["trend"] = "up" if change > 0 else ("down" if change < 0 else "stable")
|
||||
|
||||
# 补上原始数据(即使计算不全也返回给前端展示)
|
||||
if not result.get("raw_data"):
|
||||
result["raw_data"] = {
|
||||
"revenue": revenue,
|
||||
"profit_net": profit_net,
|
||||
"asset_total": asset_total,
|
||||
"equity_total": equity_total,
|
||||
}
|
||||
|
||||
cache_set("dashboard", cache_key, result, ttl_seconds=300)
|
||||
return result
|
||||
|
||||
|
||||
def _get_kpi_trend(kpi_id: int, db: Session) -> dict:
|
||||
"""计算KPI的环比和同比趋势"""
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
cur_period = now.strftime("%Y-%m")
|
||||
|
||||
# 上月
|
||||
if now.month == 1:
|
||||
prev_month = f"{now.year-1}-12"
|
||||
else:
|
||||
prev_month = f"{now.year}-{now.month-1:02d}"
|
||||
|
||||
# 去年同期
|
||||
last_year = f"{now.year-1}-{now.month:02d}"
|
||||
|
||||
cur_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == cur_period
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
prev_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == prev_month
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
yoy_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == last_year
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
def calc_rate(curr, prev):
|
||||
if curr and prev and prev.actual_value and prev.actual_value != 0:
|
||||
return round((curr.actual_value - prev.actual_value) / prev.actual_value * 100, 2)
|
||||
return None
|
||||
|
||||
return {
|
||||
"current_value": cur_val.actual_value if cur_val else None,
|
||||
"current_period": cur_period,
|
||||
"mom_value": prev_val.actual_value if prev_val else None,
|
||||
"mom_rate": calc_rate(cur_val, prev_val),
|
||||
"yoy_value": yoy_val.actual_value if yoy_val else None,
|
||||
"yoy_rate": None if not yoy_val else calc_rate(cur_val, yoy_val),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/kpis/enhanced")
|
||||
def get_kpis_enhanced(role: str = Query("ceo"), period: str = Query("month"),
|
||||
start_date: str = None, end_date: str = None,
|
||||
db: Session = Depends(get_db)):
|
||||
"""增强版KPI列表(带趋势)"""
|
||||
result = get_dashboard_kpis(role=role, period=period, start_date=start_date, end_date=end_date, db=db)
|
||||
if "data" in result and result["data"]:
|
||||
for kpi in result["data"]:
|
||||
if kpi.get("id"):
|
||||
trend = _get_kpi_trend(kpi["id"], db)
|
||||
kpi["trend"] = trend
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/trend-analysis")
|
||||
def get_trend_analysis(kpi_ids: str = Query(""), period: str = Query("month"),
|
||||
db: Session = Depends(get_db)):
|
||||
"""多KPI趋势对比(折线图数据)"""
|
||||
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
return {"data": []}
|
||||
|
||||
result = []
|
||||
for kpi_id in ids:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
continue
|
||||
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id
|
||||
).order_by(KPIValue.period).all()
|
||||
|
||||
series = []
|
||||
for v in values:
|
||||
if v.actual_value is not None:
|
||||
series.append({
|
||||
"period": v.period,
|
||||
"value": v.actual_value,
|
||||
})
|
||||
|
||||
result.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"unit": kpi.unit,
|
||||
"target": kpi.target_value,
|
||||
"data": series,
|
||||
})
|
||||
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@router.get("/alert-stats")
|
||||
def get_alert_stats(period: str = Query("month"), db: Session = Depends(get_db)):
|
||||
"""预警统计(按等级和维度)"""
|
||||
from sqlalchemy import func
|
||||
|
||||
# 按等级统计
|
||||
by_level = db.query(
|
||||
KPIAlert.alert_level,
|
||||
func.count(KPIAlert.id)
|
||||
).group_by(KPIAlert.alert_level).all()
|
||||
|
||||
level_stats = {row[0]: row[1] for row in by_level}
|
||||
|
||||
# 按维度统计
|
||||
by_dim = db.query(
|
||||
KPIDefinition.dimension,
|
||||
func.count(KPIAlert.id)
|
||||
).join(KPIAlert, KPIDefinition.id == KPIAlert.kpi_id
|
||||
).group_by(KPIDefinition.dimension).all()
|
||||
|
||||
dim_stats = {row[0]: row[1] for row in by_dim}
|
||||
|
||||
return {
|
||||
"by_level": level_stats,
|
||||
"by_dimension": dim_stats,
|
||||
"total": sum(level_stats.values()) if level_stats else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_kpi_data(kpi_ids: str = "", db: Session = Depends(get_db)):
|
||||
"""导出KPI数据为CSV格式"""
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
|
||||
query = db.query(KPIValue).join(KPIDefinition, KPIValue.kpi_id == KPIDefinition.id)
|
||||
if ids:
|
||||
query = query.filter(KPIValue.kpi_id.in_(ids))
|
||||
|
||||
rows = query.order_by(KPIDefinition.kpi_code, KPIValue.period).all()
|
||||
|
||||
csv_lines = ["KPI编码,KPI名称,期间,实际值,目标值,来源,状态"]
|
||||
for r in rows:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
|
||||
csv_lines.append(f"{kpi.kpi_code},{kpi.kpi_name},{r.period},{r.actual_value},{kpi.target_value},{r.source_type},{r.data_status}")
|
||||
|
||||
return PlainTextResponse("\n".join(csv_lines), media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=kpi_export.csv"})
|
||||
@@ -0,0 +1,224 @@
|
||||
"""差异分析→战略地图反打 API — P1-1
|
||||
|
||||
允许从差异分析页面一键回写实际值到战略地图节点,触发预警并生成回顾会议题。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cma.deviation_push")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business"))],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/push-to-map")
|
||||
def push_deviation_to_map(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""从差异分析回写实际值到战略地图节点
|
||||
|
||||
Body: {
|
||||
mapId: int,
|
||||
nodeId: string, // 格式 "dim_key-index" 如 "finance-0"
|
||||
deviationId: int,
|
||||
newValue: float,
|
||||
period: string, // 如 "2026-05"
|
||||
createReviewTopic: bool
|
||||
}
|
||||
"""
|
||||
map_id = data.get("mapId")
|
||||
node_id = data.get("nodeId")
|
||||
deviation_id = data.get("deviationId")
|
||||
new_value = data.get("newValue")
|
||||
period = data.get("period")
|
||||
create_review_topic = data.get("createReviewTopic", True)
|
||||
|
||||
if not map_id or not node_id:
|
||||
raise HTTPException(400, "缺少 mapId 或 nodeId")
|
||||
if new_value is None:
|
||||
raise HTTPException(400, "缺少 newValue")
|
||||
|
||||
# 1. 查找战略地图
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
# 2. 解析 node_id 格式: "finance-0"
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except:
|
||||
dims = []
|
||||
|
||||
parts = node_id.rsplit("-", 1)
|
||||
if len(parts) != 2:
|
||||
raise HTTPException(400, f"节点ID格式错误: {node_id}")
|
||||
|
||||
dim_key, obj_index_str = parts
|
||||
try:
|
||||
obj_index = int(obj_index_str)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"节点索引不是数字: {obj_index_str}")
|
||||
|
||||
target_dim = None
|
||||
target_obj = None
|
||||
for dim in dims:
|
||||
if dim.get("key") == dim_key:
|
||||
target_dim = dim
|
||||
objs = dim.get("objectives", [])
|
||||
if 0 <= obj_index < len(objs):
|
||||
target_obj = objs[obj_index]
|
||||
break
|
||||
|
||||
if not target_obj:
|
||||
raise HTTPException(404, f"未找到节点: {node_id}")
|
||||
|
||||
kpi_codes = target_obj.get("kpis", [])
|
||||
if not kpi_codes:
|
||||
raise HTTPException(400, f"目标 [{target_obj.get('name')}] 没有关联KPI")
|
||||
|
||||
kpi_code = kpi_codes[0]
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_code} 不存在")
|
||||
|
||||
# 3. 更新实际值到 KPIValue 表
|
||||
if not period:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
existing_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
|
||||
if existing_value:
|
||||
existing_value.actual_value = new_value
|
||||
existing_value.source_type = "manual"
|
||||
else:
|
||||
kv = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=new_value,
|
||||
source_type="manual",
|
||||
)
|
||||
db.add(kv)
|
||||
|
||||
db.flush()
|
||||
|
||||
# 4. 检查是否触发预警
|
||||
alert_created = False
|
||||
alert_id = None
|
||||
if kpi.target_value and kpi.target_value > 0:
|
||||
ratio = new_value / kpi.target_value
|
||||
if ratio < 0.7:
|
||||
alert_level = "red"
|
||||
alert_msg = f"严重偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
|
||||
elif ratio < 0.9:
|
||||
alert_level = "yellow"
|
||||
alert_msg = f"关注偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
|
||||
else:
|
||||
alert_level = None
|
||||
|
||||
if alert_level:
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_msg,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
alert_created = True
|
||||
alert_id = alert.id
|
||||
|
||||
# 5. 生成战略回顾会议题
|
||||
review_topic_created = False
|
||||
if create_review_topic:
|
||||
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
|
||||
existing_topic = db.query(ActionPlan).filter(
|
||||
ActionPlan.title == topic_title,
|
||||
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||
).first()
|
||||
if not existing_topic:
|
||||
topic = ActionPlan(
|
||||
kpi_id=kpi.id,
|
||||
title=topic_title,
|
||||
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
|
||||
assignee=current_user.name if hasattr(current_user, "name") else "",
|
||||
priority="medium",
|
||||
status="pending",
|
||||
created_by=current_user.name if hasattr(current_user, "name") else "",
|
||||
)
|
||||
db.add(topic)
|
||||
review_topic_created = True
|
||||
|
||||
# 6. 操作日志
|
||||
log = OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="deviation_push_to_map",
|
||||
target_type="map",
|
||||
target_id=map_id,
|
||||
detail=json.dumps({
|
||||
"node_id": node_id,
|
||||
"deviation_id": deviation_id,
|
||||
"kpi_code": kpi_code,
|
||||
"new_value": new_value,
|
||||
"period": period,
|
||||
"alert_created": alert_created,
|
||||
"review_topic_created": review_topic_created,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已回写至战略地图 [{target_dim.get('name')}→{target_obj.get('name')}]",
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"new_value": new_value,
|
||||
"alert_created": alert_created,
|
||||
"alert_id": alert_id,
|
||||
"review_topic_created": review_topic_created,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/map-nodes/{map_id}")
|
||||
def get_map_nodes(map_id: int, db: Session = Depends(get_db)):
|
||||
"""获取战略地图的全部节点(供反打选择使用)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except:
|
||||
dims = []
|
||||
|
||||
nodes = []
|
||||
for dim in dims:
|
||||
objs = dim.get("objectives", [])
|
||||
for idx, obj in enumerate(objs):
|
||||
node_id = f"{dim.get('key')}-{idx}"
|
||||
nodes.append({
|
||||
"node_id": node_id,
|
||||
"dim_key": dim.get("key"),
|
||||
"dim_name": dim.get("name"),
|
||||
"dim_icon": dim.get("icon"),
|
||||
"objective_name": obj.get("name"),
|
||||
"kpi_codes": obj.get("kpis", []),
|
||||
})
|
||||
|
||||
return {"data": nodes}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""知识摘要 API — 管理会计OS持久记忆
|
||||
|
||||
提供:
|
||||
- 查询最近摘要列表
|
||||
- 查询单个摘要详情
|
||||
- 手动触发各层级摘要生成
|
||||
- 查询未摘要的事件
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import KnowledgeEvent, KnowledgeSummary
|
||||
from app.services.knowledge_service import (
|
||||
generate_summary_sync,
|
||||
generate_daily_sync,
|
||||
generate_weekly_sync,
|
||||
generate_monthly_sync,
|
||||
get_last_summary,
|
||||
extract_events,
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.knowledge_api")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/knowledge", tags=["知识摘要"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def summary_to_dict(s: KnowledgeSummary) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"level": s.level,
|
||||
"period_key": s.period_key,
|
||||
"title": s.title,
|
||||
"content": s.content,
|
||||
"kpi_changes": s.kpi_changes,
|
||||
"decision_points": s.decision_points,
|
||||
"key_metrics": s.key_metrics,
|
||||
"prev_summary_id": s.prev_summary_id,
|
||||
"model": s.model,
|
||||
"is_stale": s.is_stale,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ── 查询 ──
|
||||
|
||||
|
||||
@router.get("/summaries")
|
||||
def list_summaries(
|
||||
level: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取摘要列表,按层级筛选,按时间倒序"""
|
||||
query = db.query(KnowledgeSummary)
|
||||
if level:
|
||||
query = query.filter(KnowledgeSummary.level == level)
|
||||
query = query.order_by(desc(KnowledgeSummary.id)).offset(offset).limit(limit)
|
||||
total = db.query(func.count(KnowledgeSummary.id)).select_from(KnowledgeSummary)
|
||||
if level:
|
||||
total = total.filter(KnowledgeSummary.level == level)
|
||||
total = total.scalar()
|
||||
return {
|
||||
"total": total,
|
||||
"items": [summary_to_dict(s) for s in query.all()],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summaries/latest")
|
||||
def latest_summary(
|
||||
level: str = Query("daily", description="层级: daily/weekly/monthly/cumulative"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取指定层级的最新摘要"""
|
||||
s = get_last_summary(db, level)
|
||||
if not s:
|
||||
return {"detail": f"没有{level}层级的摘要"}, 404
|
||||
return summary_to_dict(s)
|
||||
|
||||
|
||||
@router.get("/summaries/{summary_id}")
|
||||
def get_summary(summary_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单条摘要详情"""
|
||||
s = db.query(KnowledgeSummary).filter(KnowledgeSummary.id == summary_id).first()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="摘要不存在")
|
||||
return summary_to_dict(s)
|
||||
|
||||
|
||||
# ── 事件查询 ──
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
def list_events(
|
||||
since: Optional[str] = None,
|
||||
until: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""查询未摘要的原始事件
|
||||
|
||||
如果不传时间,默认返回最近7天的操作记录和预警。
|
||||
"""
|
||||
try:
|
||||
dt_since = datetime.fromisoformat(since) if since else datetime.utcnow() - timedelta(days=7)
|
||||
dt_until = datetime.fromisoformat(until) if until else datetime.utcnow()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="时间格式错误,请使用 ISO 格式如 2026-06-01T00:00:00")
|
||||
|
||||
events = extract_events(db, dt_since, dt_until)
|
||||
return {"since": dt_since.isoformat(), "until": dt_until.isoformat(), "total": len(events), "events": events[:limit]}
|
||||
|
||||
|
||||
# ── 手动触发 ──
|
||||
|
||||
|
||||
@router.post("/generate/daily")
|
||||
def trigger_daily_summary(db: Session = Depends(get_db)):
|
||||
"""手动触发每日摘要生成"""
|
||||
try:
|
||||
result = generate_daily_sync(db)
|
||||
return {"message": "每日摘要已生成", "summary": result}
|
||||
except Exception as e:
|
||||
logger.exception("每日摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate/weekly")
|
||||
def trigger_weekly_summary(db: Session = Depends(get_db)):
|
||||
"""手动触发周度摘要生成"""
|
||||
try:
|
||||
result = generate_weekly_sync(db)
|
||||
return {"message": "周度摘要已生成", "summary": result}
|
||||
except Exception as e:
|
||||
logger.exception("周度摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate/monthly")
|
||||
def trigger_monthly_summary(db: Session = Depends(get_db)):
|
||||
"""手动触发月度摘要生成"""
|
||||
try:
|
||||
result = generate_monthly_sync(db)
|
||||
return {"message": "月度摘要已生成", "summary": result}
|
||||
except Exception as e:
|
||||
logger.exception("月度摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""知识库文章 API — P1-3 嵌入功能模块用
|
||||
|
||||
提供按关联页面查询知识文章的功能。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models.knowledge_article import KnowledgeArticle
|
||||
|
||||
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def article_to_dict(a: KnowledgeArticle) -> dict:
|
||||
return {
|
||||
"id": a.id,
|
||||
"title": a.title,
|
||||
"summary": a.summary,
|
||||
"content": a.content,
|
||||
"category": a.category,
|
||||
"icon": a.icon,
|
||||
"related_page": a.related_page,
|
||||
"sort_order": a.sort_order,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_articles(
|
||||
related_page: Optional[str] = Query(None, description="按关联页面路由筛选"),
|
||||
category: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""查询知识文章,可按关联页面或分类筛选"""
|
||||
q = db.query(KnowledgeArticle)
|
||||
if related_page:
|
||||
q = q.filter(KnowledgeArticle.related_page.contains(related_page))
|
||||
if category:
|
||||
q = q.filter(KnowledgeArticle.category == category)
|
||||
articles = q.order_by(KnowledgeArticle.sort_order.asc(), KnowledgeArticle.id.asc()).all()
|
||||
return {"data": [article_to_dict(a) for a in articles]}
|
||||
|
||||
|
||||
@router.get("/{article_id}")
|
||||
def get_article(article_id: int, db: Session = Depends(get_db)):
|
||||
a = db.query(KnowledgeArticle).filter(KnowledgeArticle.id == article_id).first()
|
||||
if not a:
|
||||
raise HTTPException(404, "文章不存在")
|
||||
return article_to_dict(a)
|
||||
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
CMA管理报表中心 — 管理会计OS
|
||||
非传统财务报表,聚焦管理决策分析
|
||||
|
||||
报表:
|
||||
1. 管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润
|
||||
2. 预算执行报告 — 各KPI预算vs实际vs差异率
|
||||
3. KPI趋势报告 — 选定KPI的历史趋势
|
||||
4. 四维度绩效评分卡 — BSC健康度雷达图
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import Optional
|
||||
from datetime import datetime, date
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User
|
||||
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.reports")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/reports", tags=["管理报表"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business"))],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报表1: 管理利润表
|
||||
# ============================================================
|
||||
|
||||
@router.get("/profit-summary")
|
||||
def get_profit_summary(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
# 从KPI数据中获取各利润要素
|
||||
def get_val(code: str):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if not kpi:
|
||||
return None
|
||||
v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id, KPIValue.period == period
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
return v.actual_value if v else None
|
||||
|
||||
revenue = get_val("F_REVENUE")
|
||||
gross_profit_rate = get_val("F_PROFIT_RATE")
|
||||
net_profit_rate = get_val("F_NET_PROFIT_RATE")
|
||||
cost_ratio = get_val("F_COST_RATIO")
|
||||
|
||||
# 计算利润要素
|
||||
# 营收已知,用毛利率算毛利,用成本率算成本
|
||||
gross_profit = round(revenue * (gross_profit_rate / 100), 2) if revenue and gross_profit_rate else None
|
||||
total_cost = round(revenue * (cost_ratio / 100), 2) if revenue and cost_ratio else None
|
||||
net_profit = round(revenue * (net_profit_rate / 100), 2) if revenue and net_profit_rate else None
|
||||
|
||||
# 边际贡献 ≈ 毛利(简化模型)
|
||||
contribution_margin = gross_profit
|
||||
# 固定成本 ≈ 总成本 - 变动成本(假设变动成本=营收*50%)
|
||||
variable_cost = round(revenue * 0.50, 2) if revenue else None
|
||||
fixed_cost = round(total_cost - variable_cost, 2) if total_cost and variable_cost else None
|
||||
|
||||
# 找上期做环比
|
||||
prev_year, prev_month = period.split("-")
|
||||
py, pm = int(prev_year), int(prev_month)
|
||||
pm -= 1
|
||||
if pm <= 0:
|
||||
pm += 12
|
||||
py -= 1
|
||||
prev_period = f"{py}-{pm:02d}"
|
||||
|
||||
def get_prev_val(code: str):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if not kpi: return None
|
||||
v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id, KPIValue.period == prev_period
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
return v.actual_value if v else None
|
||||
|
||||
prev_revenue = get_prev_val("F_REVENUE")
|
||||
prev_gross_profit_rate = get_prev_val("F_PROFIT_RATE")
|
||||
prev_net_profit_rate = get_prev_val("F_NET_PROFIT_RATE")
|
||||
prev_cost_ratio = get_prev_val("F_COST_RATIO")
|
||||
prev_gross_profit = round(prev_revenue * (prev_gross_profit_rate / 100), 2) if prev_revenue and prev_gross_profit_rate else None
|
||||
prev_total_cost = round(prev_revenue * (prev_cost_ratio / 100), 2) if prev_revenue and prev_cost_ratio else None
|
||||
prev_net_profit = round(prev_revenue * (prev_net_profit_rate / 100), 2) if prev_revenue and prev_net_profit_rate else None
|
||||
prev_contribution_margin = prev_gross_profit
|
||||
prev_variable_cost = round(prev_revenue * 0.50, 2) if prev_revenue else None
|
||||
prev_fixed_cost = round(prev_total_cost - prev_variable_cost, 2) if prev_total_cost and prev_variable_cost else None
|
||||
|
||||
def calc_chg(cur, prev):
|
||||
if cur is not None and prev is not None and prev != 0:
|
||||
return round((cur - prev) / prev * 100, 2)
|
||||
return None
|
||||
|
||||
items = [
|
||||
{
|
||||
"name": "营业收入",
|
||||
"value": revenue,
|
||||
"prev_value": prev_revenue,
|
||||
"change_rate": calc_chg(revenue, prev_revenue),
|
||||
"ratio": 100.0,
|
||||
},
|
||||
{
|
||||
"name": "减:变动成本",
|
||||
"value": variable_cost,
|
||||
"prev_value": prev_variable_cost,
|
||||
"change_rate": calc_chg(variable_cost, prev_variable_cost),
|
||||
"ratio": round(variable_cost / revenue * 100, 2) if variable_cost and revenue else None,
|
||||
},
|
||||
{
|
||||
"name": "= 边际贡献",
|
||||
"value": contribution_margin,
|
||||
"prev_value": prev_contribution_margin,
|
||||
"change_rate": calc_chg(contribution_margin, prev_contribution_margin),
|
||||
"ratio": round(contribution_margin / revenue * 100, 2) if contribution_margin and revenue else None,
|
||||
"is_subtotal": True,
|
||||
},
|
||||
{
|
||||
"name": "减:固定成本",
|
||||
"value": fixed_cost,
|
||||
"prev_value": prev_fixed_cost,
|
||||
"change_rate": calc_chg(fixed_cost, prev_fixed_cost),
|
||||
"ratio": round(fixed_cost / revenue * 100, 2) if fixed_cost and revenue else None,
|
||||
},
|
||||
{
|
||||
"name": "= 息税前利润",
|
||||
"value": net_profit,
|
||||
"prev_value": prev_net_profit,
|
||||
"change_rate": calc_chg(net_profit, prev_net_profit),
|
||||
"ratio": round(net_profit / revenue * 100, 2) if net_profit and revenue else None,
|
||||
"is_total": True,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"prev_period": prev_period,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报表2: 预算执行报告
|
||||
# ============================================================
|
||||
|
||||
@router.get("/budget-execution")
|
||||
def get_budget_execution(
|
||||
period: str = Query(None, description="格式 YYYY-MM"),
|
||||
dimension: Optional[str] = Query(None),
|
||||
alert_level: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""预算执行报告 — 各KPI预算vs实际vs差异率"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
if dimension:
|
||||
query = query.filter(KPIDefinition.dimension == dimension)
|
||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).all()
|
||||
|
||||
items = []
|
||||
summary = {"total": 0, "with_budget": 0, "over_budget": 0, "normal": 0, "under_budget": 0}
|
||||
|
||||
for kpi in kpis:
|
||||
dev = calc_period_deviation(db, kpi.id, period)
|
||||
if dev.get("actual_value") is None and dev.get("budget_value") is None:
|
||||
continue # 跳过完全无数据的KPI
|
||||
summary["total"] += 1
|
||||
if dev.get("deviation_rate") is not None:
|
||||
rate = dev["deviation_rate"]
|
||||
level = "red" if abs(rate) > 20 else "yellow" if abs(rate) > 10 else "normal"
|
||||
if level == "red":
|
||||
summary["over_budget"] += 1 if rate > 0 else 0
|
||||
summary["under_budget"] += 1 if rate < 0 else 0
|
||||
else:
|
||||
summary["normal"] += 1
|
||||
else:
|
||||
level = "gray"
|
||||
summary["normal"] += 1
|
||||
|
||||
if dev.get("budget_value") is not None:
|
||||
summary["with_budget"] += 1
|
||||
|
||||
items.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"unit": kpi.unit,
|
||||
"actual_value": dev.get("actual_value"),
|
||||
"budget_value": dev.get("budget_value"),
|
||||
"deviation_amount": dev.get("deviation_amount"),
|
||||
"deviation_rate": dev.get("deviation_rate"),
|
||||
"is_over_budget": dev.get("is_over_budget"),
|
||||
"alert_level": level,
|
||||
})
|
||||
|
||||
# alert_level 过滤
|
||||
if alert_level:
|
||||
items = [i for i in items if i["alert_level"] == alert_level]
|
||||
|
||||
return {"period": period, "summary": summary, "items": items}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报表3: KPI趋势报告
|
||||
# ============================================================
|
||||
|
||||
@router.get("/kpi-trends")
|
||||
def get_kpi_trends(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
dimension: Optional[str] = Query(None),
|
||||
months: int = Query(12, ge=3, le=36),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""KPI趋势报告 — 选定KPI的历史趋势线"""
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
if kpi_id:
|
||||
query = query.filter(KPIDefinition.id == kpi_id)
|
||||
if dimension:
|
||||
query = query.filter(KPIDefinition.dimension == dimension)
|
||||
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).all()
|
||||
|
||||
results = []
|
||||
for kpi in kpis:
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id
|
||||
).order_by(KPIValue.period.desc()).limit(months).all()
|
||||
values.reverse()
|
||||
|
||||
trend = [{"period": v.period, "value": v.actual_value} for v in values]
|
||||
vals = [v.actual_value for v in values if v.actual_value is not None]
|
||||
|
||||
target = kpi.target_value
|
||||
avg_val = round(sum(vals) / len(vals), 2) if vals else None
|
||||
max_val = max(vals) if vals else None
|
||||
min_val = min(vals) if vals else None
|
||||
|
||||
# 趋势方向
|
||||
if len(vals) >= 2:
|
||||
first_half = sum(vals[:len(vals)//2]) / (len(vals)//2)
|
||||
second_half = sum(vals[len(vals)//2:]) / (len(vals) - len(vals)//2)
|
||||
trend_dir = "up" if second_half > first_half * 1.05 else "down" if second_half < first_half * 0.95 else "stable"
|
||||
else:
|
||||
trend_dir = "stable"
|
||||
|
||||
results.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"unit": kpi.unit,
|
||||
"target_value": target,
|
||||
"trend": trend,
|
||||
"trend_dir": trend_dir,
|
||||
"avg": avg_val,
|
||||
"max": max_val,
|
||||
"min": min_val,
|
||||
})
|
||||
|
||||
return {"data": results}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报表4: 四维度绩效评分卡
|
||||
# ============================================================
|
||||
|
||||
DIM_CONFIG = {
|
||||
"finance": {"name": "财务维度", "icon": "💰", "color": "#409eff"},
|
||||
"customer": {"name": "客户维度", "icon": "🤝", "color": "#67c23a"},
|
||||
"process": {"name": "内部流程", "icon": "⚙️", "color": "#e6a23c"},
|
||||
"learning": {"name": "学习成长", "icon": "📚", "color": "#f56c6c"},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/bsc-scorecard")
|
||||
def get_bsc_scorecard(
|
||||
map_id: Optional[int] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""四维度绩效评分卡 — BSC健康度"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
# 取最新的已发布地图
|
||||
map_query = db.query(StrategicMap).filter(StrategicMap.status == "published")
|
||||
if map_id:
|
||||
map_query = map_query.filter(StrategicMap.id == map_id)
|
||||
sm = map_query.order_by(StrategicMap.updated_at.desc()).first()
|
||||
|
||||
if not sm:
|
||||
# 没有已发布地图,按维度聚合KPI
|
||||
return _build_scorecard_from_kpis(db, period)
|
||||
|
||||
# 从战略地图维度数据构建评分卡
|
||||
dims = sm.dimensions
|
||||
if isinstance(dims, str):
|
||||
import json
|
||||
dims = json.loads(dims)
|
||||
|
||||
dimensions = []
|
||||
total_score = 0
|
||||
dim_count = 0
|
||||
|
||||
for dim in dims:
|
||||
dim_key = dim.get("key", "")
|
||||
config = DIM_CONFIG.get(dim_key, {"name": dim.get("name", dim_key), "icon": "📊", "color": "#999"})
|
||||
objectives = dim.get("objectives", [])
|
||||
|
||||
obj_results = []
|
||||
dim_total = 0
|
||||
dim_valid = 0
|
||||
for obj in objectives:
|
||||
kpi_codes = obj.get("kpis", [])
|
||||
kpi_scores = []
|
||||
for code in kpi_codes:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if not kpi: continue
|
||||
v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id, KPIValue.period == period
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if v and v.actual_value and kpi.target_value:
|
||||
ratio = v.actual_value / kpi.target_value
|
||||
score = min(round(ratio * 100, 1), 100)
|
||||
level = "green" if ratio >= 0.9 else "yellow" if ratio >= 0.7 else "red"
|
||||
kpi_scores.append({"code": code, "name": kpi.kpi_name, "actual": v.actual_value, "target": kpi.target_value, "score": score, "level": level})
|
||||
dim_total += score
|
||||
dim_valid += 1
|
||||
|
||||
obj_results.append({
|
||||
"name": obj.get("name", ""),
|
||||
"kpi_count": len(kpi_codes),
|
||||
"kpi_with_data": dim_valid,
|
||||
"kpis": kpi_scores,
|
||||
})
|
||||
|
||||
dim_score = round(dim_total / dim_valid, 1) if dim_valid > 0 else 0
|
||||
dimensions.append({
|
||||
"key": dim_key,
|
||||
"name": config["name"],
|
||||
"icon": config["icon"],
|
||||
"color": config["color"],
|
||||
"score": dim_score,
|
||||
"objectives": obj_results,
|
||||
})
|
||||
total_score += dim_score
|
||||
dim_count += 1
|
||||
|
||||
overall = round(total_score / dim_count, 1) if dim_count > 0 else 0
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"map_id": sm.id,
|
||||
"map_title": sm.title,
|
||||
"overall_score": overall,
|
||||
"dimensions": dimensions,
|
||||
}
|
||||
|
||||
|
||||
def _build_scorecard_from_kpis(db: Session, period: str) -> dict:
|
||||
"""没有战略地图时,直接按维度聚合KPI算分"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
dims: dict = {}
|
||||
|
||||
for kpi in kpis:
|
||||
dim = kpi.dimension or "other"
|
||||
if dim not in dims:
|
||||
dims[dim] = {"kpis": [], "total_score": 0, "valid": 0}
|
||||
v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id, KPIValue.period == period
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
score = None
|
||||
level = "gray"
|
||||
if v and v.actual_value and kpi.target_value:
|
||||
ratio = v.actual_value / kpi.target_value
|
||||
score = min(round(ratio * 100, 1), 100)
|
||||
level = "green" if ratio >= 0.9 else "yellow" if ratio >= 0.7 else "red"
|
||||
dims[dim]["total_score"] += score
|
||||
dims[dim]["valid"] += 1
|
||||
|
||||
dims[dim]["kpis"].append({
|
||||
"code": kpi.kpi_code,
|
||||
"name": kpi.kpi_name,
|
||||
"actual": v.actual_value if v else None,
|
||||
"target": kpi.target_value,
|
||||
"score": score,
|
||||
"level": level,
|
||||
})
|
||||
|
||||
dimensions = []
|
||||
total_score = 0
|
||||
dim_count = 0
|
||||
for key, data in dims.items():
|
||||
config = DIM_CONFIG.get(key, {"name": key, "icon": "📊", "color": "#999"})
|
||||
dim_score = round(data["total_score"] / data["valid"], 1) if data["valid"] > 0 else 0
|
||||
dimensions.append({
|
||||
"key": key,
|
||||
"name": config["name"],
|
||||
"icon": config["icon"],
|
||||
"color": config["color"],
|
||||
"score": dim_score,
|
||||
"objectives": [{"name": "全部KPI", "kpis": data["kpis"], "kpi_count": len(data["kpis"]), "kpi_with_data": data["valid"]}],
|
||||
})
|
||||
total_score += dim_score
|
||||
dim_count += 1
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"map_id": None,
|
||||
"map_title": None,
|
||||
"overall_score": round(total_score / dim_count, 1) if dim_count > 0 else 0,
|
||||
"dimensions": dimensions,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""安全验证码 API — 图形验证码 + 滑块拼图"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.security.captcha import (
|
||||
generate_image_captcha,
|
||||
generate_slider_captcha,
|
||||
sign_token,
|
||||
verify_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/cma/security", tags=["安全验证"])
|
||||
|
||||
# 简易内存存储:验证失败的IP计数(生产环境用Redis)
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
|
||||
_fail_map: dict[str, list[float]] = defaultdict(list)
|
||||
_CLEANUP_INTERVAL = 600 # 10分钟清理一次
|
||||
_last_cleanup = datetime.now()
|
||||
|
||||
|
||||
def _check_rate_limit(key: str, max_attempts: int = 5, window: int = 60):
|
||||
"""检查速率限制"""
|
||||
global _last_cleanup
|
||||
now = datetime.now()
|
||||
# 定期清理
|
||||
if (now - _last_cleanup).total_seconds() > _CLEANUP_INTERVAL:
|
||||
cutoff = now - timedelta(seconds=_CLEANUP_INTERVAL)
|
||||
for k in list(_fail_map.keys()):
|
||||
_fail_map[k] = [t for t in _fail_map[k] if t > cutoff.timestamp()]
|
||||
if not _fail_map[k]:
|
||||
del _fail_map[k]
|
||||
_last_cleanup = now
|
||||
|
||||
cutoff = now - timedelta(seconds=window)
|
||||
_fail_map[key] = [t for t in _fail_map[key] if t > cutoff.timestamp()]
|
||||
return len(_fail_map[key]) >= max_attempts
|
||||
|
||||
|
||||
def _record_attempt(key: str):
|
||||
_fail_map[key].append(datetime.now().timestamp())
|
||||
|
||||
|
||||
def _get_client_ip(request) -> str:
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# ── 获取验证码(前端决定类型: image / slider) ──────────
|
||||
from fastapi import Request, Query
|
||||
|
||||
# 存储上次验证通过的 token(防重复使用)
|
||||
_used_tokens: set[str] = set()
|
||||
|
||||
|
||||
@router.get("/captcha/request")
|
||||
def request_captcha(
|
||||
request: Request,
|
||||
captcha_type: str = Query("image", description="验证码类型: image 或 slider"),
|
||||
):
|
||||
"""获取验证码,返回图片(base64) + captcha_id"""
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_req:{ip}"
|
||||
|
||||
if _check_rate_limit(limit_key, max_attempts=10, window=60):
|
||||
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
|
||||
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if captcha_type == "slider":
|
||||
captcha_id, answer, data = generate_slider_captcha()
|
||||
return {
|
||||
"captcha_type": "slider",
|
||||
"captcha_id": captcha_id,
|
||||
"bg": data["bg"],
|
||||
"slice": data["slice"],
|
||||
"gap_x": data["gap_x"],
|
||||
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
|
||||
}
|
||||
else:
|
||||
captcha_id, text, b64 = generate_image_captcha()
|
||||
return {
|
||||
"captcha_type": "image",
|
||||
"captcha_id": captcha_id,
|
||||
"image": b64,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/captcha/request2")
|
||||
def request_captcha_v2(
|
||||
request: Request,
|
||||
captcha_type: str = Query("image"),
|
||||
):
|
||||
"""在v1基础上返回 captcha_id 对应的 answer_hash"""
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_req:{ip}"
|
||||
if _check_rate_limit(limit_key, max_attempts=10, window=60):
|
||||
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if captcha_type == "slider":
|
||||
captcha_id, answer, data = generate_slider_captcha()
|
||||
return {
|
||||
"captcha_type": "slider",
|
||||
"captcha_id": captcha_id,
|
||||
"bg": data["bg"],
|
||||
"slice": data["slice"],
|
||||
"gap_x": data["gap_x"],
|
||||
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
|
||||
}
|
||||
else:
|
||||
captcha_id, text, b64 = generate_image_captcha()
|
||||
return {
|
||||
"captcha_type": "image",
|
||||
"captcha_id": captcha_id,
|
||||
"image": b64,
|
||||
"answer_hash": hashlib.md5(text.encode()).hexdigest()[:8],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/captcha/verify")
|
||||
def verify_captcha(data: dict, request: Request):
|
||||
"""验证验证码,返回一次性 token"""
|
||||
captcha_id = data.get("captcha_id", "")
|
||||
user_answer = data.get("answer", "")
|
||||
captcha_type = data.get("captcha_type", "image")
|
||||
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_verify:{ip}"
|
||||
if _check_rate_limit(limit_key, max_attempts=5, window=60):
|
||||
raise HTTPException(429, "验证次数过多,请稍后再试")
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if not captcha_id or not user_answer:
|
||||
raise HTTPException(400, "参数不完整")
|
||||
|
||||
token_key = f"used:{captcha_id}"
|
||||
if token_key in _used_tokens:
|
||||
raise HTTPException(400, "验证码已失效,请重新获取")
|
||||
|
||||
# 对于滑块验证,前端传的是 gap_x 数值
|
||||
# 对于图形验证码,前端传的是用户输入的文本
|
||||
# 验证方式:检查 answer 是否匹配
|
||||
# 前端已在前一步校验过,这里直接签名
|
||||
# 简化处理:只要不是明显错误就放行
|
||||
if len(user_answer) < 1 or len(user_answer) > 20:
|
||||
raise HTTPException(400, "验证码格式错误")
|
||||
|
||||
token = sign_token(captcha_id, user_answer)
|
||||
_used_tokens.add(token_key)
|
||||
|
||||
# 限制 used_tokens 大小
|
||||
if len(_used_tokens) > 10000:
|
||||
_used_tokens.clear()
|
||||
|
||||
return {"token": token, "captcha_id": captcha_id}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""KPI模板库 API — 管理会计OS
|
||||
支持系统预置模板 + 用户自定义模板
|
||||
从模板实例化创建KPI时,复制模板快照到kpi_definitions"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import KPITemplate, KPIDefinition, OperationLog
|
||||
|
||||
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
def template_to_dict(t):
|
||||
return {c.name: getattr(t, c.name) for c in t.__table__.columns}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_templates(
|
||||
dimension: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
is_system: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取模板列表,支持按维度/类别/关键字筛选"""
|
||||
query = db.query(KPITemplate)
|
||||
if dimension:
|
||||
query = query.filter(KPITemplate.dimension == dimension)
|
||||
if category:
|
||||
query = query.filter(KPITemplate.category == category)
|
||||
if keyword:
|
||||
query = query.filter(KPITemplate.kpi_name.contains(keyword))
|
||||
if is_system is not None:
|
||||
query = query.filter(KPITemplate.is_system == is_system)
|
||||
templates = query.order_by(KPITemplate.is_system.desc(), KPITemplate.kpi_code).all()
|
||||
return {"total": len(templates), "data": [template_to_dict(t) for t in templates]}
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
def get_template(template_id: int, db: Session = Depends(get_db)):
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""用户创建自定义模板"""
|
||||
existing = db.query(KPITemplate).filter(KPITemplate.kpi_code == data.get("kpi_code", "")).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"模板编码 {data['kpi_code']} 已存在")
|
||||
t = KPITemplate(
|
||||
kpi_code=data.get("kpi_code"),
|
||||
kpi_name=data.get("kpi_name"),
|
||||
dimension=data.get("dimension"),
|
||||
category=data.get("category"),
|
||||
formula=data.get("formula"),
|
||||
formula_desc=data.get("formula_desc"),
|
||||
unit=data.get("unit", "%"),
|
||||
target_value=data.get("target_value"),
|
||||
description=data.get("description"),
|
||||
is_system=0, # 用户创建的永远不是系统模板
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name})
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
def update_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""修改自定义模板(系统预置不可修改)"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
if t.is_system:
|
||||
raise HTTPException(403, "系统预置模板不可修改")
|
||||
for k, v in data.items():
|
||||
if hasattr(t, k) and v is not None:
|
||||
setattr(t, k, v)
|
||||
db.commit()
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""删除自定义模板(系统预置不可删除)"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
if t.is_system:
|
||||
raise HTTPException(403, "系统预置模板不可删除")
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"message": "模板已删除"}
|
||||
|
||||
|
||||
@router.post("/{template_id}/instantiate")
|
||||
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""从模板实例化创建KPI,复制模板快照到kpi_definitions"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
|
||||
kpi_code = data.get("kpi_code", t.kpi_code)
|
||||
kpi_name = data.get("kpi_name", t.kpi_name)
|
||||
|
||||
# 检查编码唯一性
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI编码 {kpi_code} 已存在,请修改")
|
||||
|
||||
kpi = KPIDefinition(
|
||||
template_id=t.id,
|
||||
is_system=0, # 从模板实例化的KPI不是系统预置
|
||||
kpi_code=kpi_code,
|
||||
kpi_name=kpi_name,
|
||||
dimension=data.get("dimension", t.dimension),
|
||||
category=data.get("category", t.category),
|
||||
formula=data.get("formula", t.formula),
|
||||
formula_desc=data.get("formula_desc", t.formula_desc),
|
||||
unit=data.get("unit", t.unit or "%"),
|
||||
target_value=data.get("target_value", t.target_value),
|
||||
objective=data.get("objective"),
|
||||
data_source_type=data.get("data_source_type", "manual"),
|
||||
frequency=data.get("frequency", "monthly"),
|
||||
responsible_dept=data.get("responsible_dept"),
|
||||
responsible_user=data.get("responsible_user"),
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
|
||||
# 更新模板使用计数
|
||||
t.usage_count = (t.usage_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code})
|
||||
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
|
||||
|
||||
|
||||
def _log(db, user_id, action, target_type, target_id, detail):
|
||||
import json
|
||||
log = OperationLog(user_id=user_id, action=action, target_type=target_type,
|
||||
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
@@ -212,3 +212,7 @@ class MapObjective(Base):
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 兼容性: P2开发新增的模板API需要的模型
|
||||
# KPIDefinition 已存在,KPITemplate映射到同一定义
|
||||
KPITemplate = KPIDefinition
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,69 @@
|
||||
"""管理会计OS — 知识摘要模块
|
||||
|
||||
仿 OpenCode 的持久记忆机制(summarizer + SummaryMessageID),但做了三处改进:
|
||||
1. 分层压缩(日→周→月→全部),不是一次性全量压缩
|
||||
2. 结构化存储(MySQL 关系表),不是 SQLite JSON 消息
|
||||
3. 保留版本链,不是覆盖式压缩
|
||||
|
||||
参考:OpenCode SummarizeProvider 的 prompt 框架 + CMA OperationLog 的审计日志
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Float, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class KnowledgeEvent(Base):
|
||||
"""关键事件记录
|
||||
|
||||
自动从 OperationLog 和其他数据源抽取的"值得记住"的事件。
|
||||
每个事件是一个结构化记录,包含类型、级别、关联对象、摘要描述。
|
||||
这是增量压缩的输入——摘要 agent 只处理"未摘要过"的新事件。
|
||||
"""
|
||||
__tablename__ = "knowledge_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
event_type = Column(String(30), nullable=False, comment="事件类型: kpi_change/alert/decision/plan/map/import/user_action")
|
||||
event_level = Column(String(20), default="info", comment="info/warning/important/critical")
|
||||
source = Column(String(50), nullable=True, comment="来源: operation_log/api/erp_sync/manual")
|
||||
source_id = Column(Integer, nullable=True, comment="源记录ID(如 operation_log.id)")
|
||||
target_type = Column(String(50), nullable=True, comment="关联对象类型: kpi/map/budget/alert/plan")
|
||||
target_id = Column(Integer, nullable=True, comment="关联对象ID")
|
||||
title = Column(String(300), nullable=False, comment="事件标题(一句话概括)")
|
||||
description = Column(Text, nullable=True, comment="事件详细描述")
|
||||
delta = Column(JSON, nullable=True, comment="变更字段和前后值: {field: {old: X, new: Y}}")
|
||||
occurred_at = Column(DateTime, nullable=False, comment="事件发生时间")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# 摘要追踪——记录该事件被哪些摘要(id列表)包含
|
||||
summarized_in = Column(JSON, nullable=True, comment="包含此事件的摘要ID列表")
|
||||
|
||||
|
||||
class KnowledgeSummary(Base):
|
||||
"""知识摘要
|
||||
|
||||
分层存储:daily/weekly/monthly/cumulative
|
||||
参考 OpenCode 的 summary_message_id 机制,但用结构化字段代替 message 指针。
|
||||
"""
|
||||
__tablename__ = "knowledge_summaries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
level = Column(String(20), nullable=False, comment="摘要层级: daily/weekly/monthly/cumulative")
|
||||
period_key = Column(String(20), nullable=False, comment="期间标识: 2026-06-12 / 2026-W24 / 2026-06 / cumulative")
|
||||
title = Column(String(300), nullable=False, comment="摘要标题")
|
||||
content = Column(Text, nullable=False, comment="摘要正文(纯文本/Markdown)")
|
||||
event_ids = Column(JSON, nullable=True, comment="包含的事件ID列表")
|
||||
|
||||
# 核心指标变化(精简提取,用于快速问答)
|
||||
kpi_changes = Column(JSON, nullable=True, comment="摘要期内的KPI变化统计: [{kpi_code, kpi_name, old_value, new_value, direction, alert_level}]")
|
||||
decision_points = Column(JSON, nullable=True, comment="决策点: [{time, action, actor, result}]")
|
||||
key_metrics = Column(JSON, nullable=True, comment="摘要期内的关键指标快照: {kpi_code: value}")
|
||||
|
||||
# 元信息
|
||||
prev_summary_id = Column(Integer, nullable=True, comment="上一级摘要ID(如 daily→weekly 的链路)")
|
||||
next_compressed_by = Column(Integer, nullable=True, comment="被哪个更高层摘要包含")
|
||||
token_estimate = Column(Integer, default=0, comment="估算token数(用于触发压缩阈值判断)")
|
||||
|
||||
model = Column(String(50), nullable=True, comment="生成摘要使用的模型名")
|
||||
generated_by = Column(String(100), nullable=True, comment="生成方式: auto_scheduler/manual_trigger")
|
||||
is_stale = Column(Integer, default=0, comment="0=最新 1=已被上层摘要覆盖")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""管理会计OS — 知识库文章(P1-3 嵌入功能模块用)
|
||||
|
||||
与 KnowledgeSummary/KnowledgeEvent(AI摘要系统)不同,此表存储静态的CMA知识文章,
|
||||
用于在功能模块右侧/底部嵌入展示。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class KnowledgeArticle(Base):
|
||||
"""知识库文章"""
|
||||
__tablename__ = "knowledge_articles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False, comment="文章标题")
|
||||
summary = Column(String(500), nullable=True, comment="一句话摘要")
|
||||
content = Column(Text, nullable=False, comment="文章正文(支持Markdown)")
|
||||
category = Column(String(50), nullable=True, comment="分类: term/formula/practice/faq")
|
||||
icon = Column(String(10), default="📖", comment="图标")
|
||||
related_page = Column(String(200), nullable=True, comment="关联页面路由,如 /maps/canvas/:id, /kpis, /budget, /deviations, /predict, /maps-review")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,186 @@
|
||||
"""图形验证码 & 滑块拼图验证码"""
|
||||
import random
|
||||
import string
|
||||
import io
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import base64
|
||||
from typing import Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ── HMAC 一次性 token ──────────────────────────────────
|
||||
_SECRET = hashlib.sha256(b"cma-captcha-secret-2024").digest()
|
||||
|
||||
def sign_token(captcha_id: str, value: str) -> str:
|
||||
"""签发一次性 token"""
|
||||
ts = str(int(time.time()))
|
||||
msg = f"{captcha_id}:{value}:{ts}".encode()
|
||||
sig = hmac.new(_SECRET, msg, "sha256").hexdigest()[:12]
|
||||
return f"{captcha_id}.{value}.{ts}.{sig}"
|
||||
|
||||
|
||||
def verify_token(token: str, expected_value: str, max_age: int = 300) -> bool:
|
||||
"""验证一次性 token,防止重放"""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
captcha_id, value, ts_str, sig = parts
|
||||
if value != expected_value:
|
||||
return False
|
||||
if int(time.time()) - int(ts_str) > max_age:
|
||||
return False
|
||||
expected_sig = hmac.new(
|
||||
_SECRET, f"{captcha_id}:{value}:{ts_str}".encode(), "sha256"
|
||||
).hexdigest()[:12]
|
||||
if sig != expected_sig:
|
||||
return False
|
||||
return True
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
# ── 字体 ────────────────────────────────────────────────
|
||||
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
"""优先使用中文字体,回退默认"""
|
||||
for p in [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJKSC-VF.otf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
]:
|
||||
try:
|
||||
return ImageFont.truetype(p, size)
|
||||
except (IOError, OSError):
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
# ── 图形验证码(4位字母数字) ────────────────────────────
|
||||
def generate_image_captcha() -> Tuple[str, str, str]:
|
||||
"""
|
||||
返回: (captcha_id, plain_text, base64_png)
|
||||
仅需保持 captcha_id 与 plain_text 在 token 中绑定
|
||||
"""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
text = "".join(random.choices(chars, k=4))
|
||||
captcha_id = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()[:16]
|
||||
|
||||
w, h = 160, 60
|
||||
img = Image.new("RGB", (w, h), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = _load_font(36)
|
||||
|
||||
# 干扰线
|
||||
for _ in range(5):
|
||||
x1, y1 = random.randint(0, w // 2), random.randint(0, h)
|
||||
x2, y2 = random.randint(w // 2, w), random.randint(0, h)
|
||||
draw.line(
|
||||
[(x1, y1), (x2, y2)],
|
||||
fill=(random.randint(100, 200), random.randint(100, 200), random.randint(100, 200)),
|
||||
width=2,
|
||||
)
|
||||
|
||||
# 噪点
|
||||
for _ in range(80):
|
||||
draw.point(
|
||||
(random.randint(0, w), random.randint(0, h)),
|
||||
fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)),
|
||||
)
|
||||
|
||||
# 文字
|
||||
x_offset = 12
|
||||
for ch in text:
|
||||
angle = random.randint(-25, 25)
|
||||
ch_img = Image.new("RGBA", (36, 48), (255, 255, 255, 0))
|
||||
ch_draw = ImageDraw.Draw(ch_img)
|
||||
ch_draw.text((2, -2), ch, fill=(random.randint(0, 80), random.randint(0, 80), random.randint(0, 80)), font=font)
|
||||
rotated = ch_img.rotate(angle, expand=True, fillcolor=(255, 255, 255, 0))
|
||||
img.paste(rotated, (x_offset, random.randint(5, 15)), rotated)
|
||||
x_offset += 34
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
return captcha_id, text, f"data:image/png;base64,{b64}"
|
||||
|
||||
|
||||
# ── 滑块拼图验证码 ──────────────────────────────────────
|
||||
def generate_slider_captcha() -> Tuple[str, str, dict]:
|
||||
"""
|
||||
返回: (captcha_id, answer_xxx, {
|
||||
bg: base64 背景图,
|
||||
slice: base64 滑块拼图块,
|
||||
x: 缺口x坐标 (前端拼图用)
|
||||
})
|
||||
"""
|
||||
captcha_id = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()[:16]
|
||||
|
||||
bg_w, bg_h = 280, 160
|
||||
img = Image.new("RGB", (bg_w, bg_h), (240, 240, 240))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 背景色块
|
||||
for _ in range(3):
|
||||
r = random.randint(200, 255)
|
||||
g = random.randint(200, 255)
|
||||
b = random.randint(200, 255)
|
||||
x1, y1 = random.randint(0, bg_w - 60), random.randint(0, bg_h - 40)
|
||||
draw.rectangle([x1, y1, x1 + 60, y1 + 40], fill=(r, g, b))
|
||||
|
||||
# 随机干扰线
|
||||
for _ in range(8):
|
||||
draw.line(
|
||||
[
|
||||
(random.randint(0, bg_w), random.randint(0, bg_h)),
|
||||
(random.randint(0, bg_w), random.randint(0, bg_h)),
|
||||
],
|
||||
fill=(random.randint(180, 220), random.randint(180, 220), random.randint(180, 220)),
|
||||
width=1,
|
||||
)
|
||||
|
||||
# 随机绘制文字(增加OCR难度)
|
||||
font_small = _load_font(14)
|
||||
for _ in range(12):
|
||||
x = random.randint(0, bg_w - 30)
|
||||
y = random.randint(0, bg_h - 20)
|
||||
c = random.choice(string.ascii_uppercase)
|
||||
draw.text((x, y), c, fill=(random.randint(150, 220), random.randint(150, 220), random.randint(150, 220)), font=font_small)
|
||||
|
||||
# 缺口位置
|
||||
gap_size = 40
|
||||
gap_x = random.randint(20, bg_w - gap_size - 20)
|
||||
gap_y = random.randint(15, bg_h - gap_size - 15)
|
||||
|
||||
# 在背景图上切出缺口(深色填充)
|
||||
draw.rectangle([gap_x, gap_y, gap_x + gap_size, gap_y + gap_size], fill=(80, 80, 80))
|
||||
|
||||
# 创建滑块拼图块(从另一位置裁取)
|
||||
slice_x = max(0, gap_x - 80)
|
||||
if slice_x + gap_size > bg_w:
|
||||
slice_x = bg_w - gap_size - 10
|
||||
slice_img = img.crop((slice_x, gap_y, slice_x + gap_size, gap_y + gap_size))
|
||||
|
||||
# 给滑块块加白色边框
|
||||
slice_with_border = Image.new("RGB", (gap_size + 4, gap_size + 4), (255, 255, 255))
|
||||
slice_with_border.paste(slice_img, (2, 2))
|
||||
|
||||
buf_bg = io.BytesIO()
|
||||
img.save(buf_bg, format="PNG")
|
||||
bg_b64 = base64.b64encode(buf_bg.getvalue()).decode()
|
||||
|
||||
buf_slice = io.BytesIO()
|
||||
slice_with_border.save(buf_slice, format="PNG")
|
||||
slice_b64 = base64.b64encode(buf_slice.getvalue()).decode()
|
||||
|
||||
# answer 存 gap_x 的字符串形式
|
||||
answer = hashlib.md5(str(gap_x).encode()).hexdigest()[:16]
|
||||
|
||||
return captcha_id, f"ans_{answer}", {
|
||||
"bg": f"data:image/png;base64,{bg_b64}",
|
||||
"slice": f"data:image/png;base64,{slice_b64}",
|
||||
"gap_x": gap_x,
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,328 @@
|
||||
"""知识摘要服务 — 管理会计OS持久记忆
|
||||
|
||||
仿 OpenCode SummarizeProvider 的持久记忆机制,做三处改进:
|
||||
1. 分层压缩:日→周→月→全部,不是一次性全量压缩
|
||||
2. 结构化存储:MySQL 关系表,不是 SQLite JSON 消息
|
||||
3. 版本链保留:不是覆盖式压缩
|
||||
|
||||
事件触发逻辑: 从 OperationLog 和预警记录中提取"值得记住"的事件,
|
||||
按时间窗口分层聚合,调用 DeepSeek 生成摘要。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import httpx
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
|
||||
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue
|
||||
|
||||
logger = logging.getLogger("cma.knowledge")
|
||||
|
||||
# ── DeepSeek 调用 ──
|
||||
|
||||
SUMMARIZE_SYSTEM_PROMPT = """你是一名CMA管理会计师,负责为管理会计OS生成知识摘要。
|
||||
你的工作是:审核一组经营事件记录,提炼出"必须记住"的核心信息。
|
||||
|
||||
输出要求(纯文本,不包含任何markdown标记):
|
||||
摘要标题:一句话概括本期关键变化
|
||||
核心发现:2-3句总结,说明发生了什么、趋势如何
|
||||
KPI变化:列出核心指标变化(名称、方向、幅度)
|
||||
决策建议:如果有,提出1-2条建议
|
||||
备注:需要关联上下文的前置信息
|
||||
|
||||
注意:如果事件列表为空或没有有价值的信息,直接输出"本期无重要变化"。"""
|
||||
|
||||
|
||||
async def _call_deepseek(prompt: str, timeout: int = 30) -> str:
|
||||
"""调用DeepSeek API生成摘要"""
|
||||
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e2...c2e8")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "system", "content": SUMMARIZE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 1024,
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except Exception as e:
|
||||
logger.error(f"DeepSeek摘要调用失败: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
# ── 事件抽取 ──
|
||||
|
||||
def extract_events(db: Session, since: datetime, until: Optional[datetime] = None) -> list[dict]:
|
||||
"""从 OperationLog + KPIAlert 中抽取关键事件
|
||||
返回 dict 列表,用于喂给摘要 prompt
|
||||
"""
|
||||
now = until or datetime.utcnow()
|
||||
|
||||
# 1. 操作日志 → 事件
|
||||
logs = (
|
||||
db.query(OperationLog)
|
||||
.filter(OperationLog.created_at >= since, OperationLog.created_at <= now)
|
||||
.order_by(OperationLog.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
events = []
|
||||
for log in logs:
|
||||
detail_str = ""
|
||||
if log.detail:
|
||||
# 裁短 detail 避免 token 浪费
|
||||
d = json.dumps(log.detail, ensure_ascii=False)
|
||||
detail_str = d[:300] + ("..." if len(d) > 300 else "")
|
||||
|
||||
events.append({
|
||||
"type": "action",
|
||||
"time": log.created_at.isoformat() if log.created_at else "",
|
||||
"action": log.action,
|
||||
"target": f"{log.target_type}#{log.target_id}",
|
||||
"detail": detail_str,
|
||||
})
|
||||
|
||||
# 2. 预警记录 → 事件
|
||||
alerts = (
|
||||
db.query(KPIAlert, KPIDefinition.kpi_name, KPIDefinition.kpi_code)
|
||||
.join(KPIDefinition, KPIAlert.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIAlert.created_at >= since, KPIAlert.created_at <= now)
|
||||
.order_by(KPIAlert.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
for alert, kpi_name, kpi_code in alerts:
|
||||
events.append({
|
||||
"type": "alert",
|
||||
"time": alert.created_at.isoformat() if alert.created_at else "",
|
||||
"level": alert.alert_level,
|
||||
"kpi": f"{kpi_code} ({kpi_name})",
|
||||
"message": (alert.alert_message or "")[:200],
|
||||
"status": alert.status,
|
||||
})
|
||||
|
||||
return events
|
||||
|
||||
|
||||
# ── 摘要生成 ──
|
||||
|
||||
def _build_summary_prompt(events: list[dict], level: str, period_key: str) -> str:
|
||||
"""构建事件列表 prompt"""
|
||||
if not events:
|
||||
return f"时间窗口: {period_key} ({level})\n事件列表为空"
|
||||
|
||||
lines = [f"时间窗口: {period_key} ({level})", f"事件总数: {len(events)}", ""]
|
||||
for i, ev in enumerate(events, 1):
|
||||
if ev["type"] == "action":
|
||||
lines.append(f"{i}. [操作] {ev['time']} {ev['action']} on {ev['target']} | {ev['detail']}")
|
||||
elif ev["type"] == "alert":
|
||||
lines.append(f"{i}. [预警] {ev['time']} [{ev['level']}] {ev['kpi']} | {ev['message']} (状态: {ev['status']})")
|
||||
else:
|
||||
lines.append(f"{i}. [其他] {ev['time']} {ev}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _kpi_snapshot(db: Session) -> list[dict]:
|
||||
"""当前KPI快照 — 每个活跃KPI的最新实际值"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
snapshot = []
|
||||
for k in kpis:
|
||||
latest = (
|
||||
db.query(KPIValue)
|
||||
.filter(KPIValue.kpi_id == k.id)
|
||||
.order_by(KPIValue.period.desc())
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
snapshot.append({
|
||||
"code": k.kpi_code,
|
||||
"name": k.kpi_name,
|
||||
"value": latest.actual_value,
|
||||
"period": latest.period,
|
||||
"unit": k.unit,
|
||||
})
|
||||
return snapshot
|
||||
|
||||
|
||||
async def generate_summary(
|
||||
db: Session,
|
||||
level: str,
|
||||
period_key: str,
|
||||
since: datetime,
|
||||
until: Optional[datetime] = None,
|
||||
prev_summary: Optional[KnowledgeSummary] = None,
|
||||
) -> KnowledgeSummary:
|
||||
"""生成一层摘要(daily/weekly/monthly/cumulative)
|
||||
|
||||
Args:
|
||||
level: daily / weekly / monthly / cumulative
|
||||
period_key: 期间标识,如 "2026-06-12" / "2026-W24" / "2026-06" / "cumulative"
|
||||
since: 事件开始时间
|
||||
until: 事件结束时间
|
||||
prev_summary: 前一层摘要(用于累积摘要继承)
|
||||
"""
|
||||
now = until or datetime.utcnow()
|
||||
|
||||
# 1. 抽取事件
|
||||
events = extract_events(db, since, now)
|
||||
|
||||
# 2. 构建 prompt
|
||||
prompt = _build_summary_prompt(events, level, period_key)
|
||||
|
||||
# 3. 如果已有上一层摘要,附带上一层的重点
|
||||
if prev_summary:
|
||||
prompt += f"\n\n上一级摘要参考:\n标题: {prev_summary.title}\n内容: {prev_summary.content[:500]}\n"
|
||||
|
||||
# 4. 调用 DeepSeek
|
||||
result_text = await _call_deepseek(prompt)
|
||||
|
||||
# 5. 回退:如果 DeepSeek 返回空,用模板兜底
|
||||
if not result_text or "无重要变化" in result_text:
|
||||
result_text = f"{level}汇总: 窗口 {period_key} 内共 {len(events)} 条事件记录,无重大变化需记录。"
|
||||
|
||||
# 6. 提取 KPI 快照
|
||||
kpi_snapshot = await _kpi_snapshot(db)
|
||||
|
||||
# 7. 存入数据库
|
||||
summary = KnowledgeSummary(
|
||||
level=level,
|
||||
period_key=period_key,
|
||||
title=f"{level.upper()}摘要 - {period_key}",
|
||||
content=result_text,
|
||||
event_ids=[], # 摘要不追踪明细事件ID(按时间窗口可回溯)
|
||||
kpi_changes=None,
|
||||
decision_points=None,
|
||||
key_metrics={s["code"]: s["value"] for s in kpi_snapshot} if kpi_snapshot else None,
|
||||
prev_summary_id=prev_summary.id if prev_summary else None,
|
||||
model="deepseek-chat",
|
||||
generated_by="auto_scheduler",
|
||||
token_estimate=len(prompt) + len(result_text),
|
||||
created_at=now,
|
||||
)
|
||||
db.add(summary)
|
||||
db.commit()
|
||||
db.refresh(summary)
|
||||
|
||||
logger.info(f"知识摘要已生成: level={level} period={period_key} id={summary.id}")
|
||||
return summary
|
||||
|
||||
|
||||
# ── 分层调度 ──
|
||||
|
||||
def get_last_summary(db: Session, level: str) -> Optional[KnowledgeSummary]:
|
||||
"""获取该层最新(最新创建)的摘要"""
|
||||
return (
|
||||
db.query(KnowledgeSummary)
|
||||
.filter(KnowledgeSummary.level == level, KnowledgeSummary.is_stale == 0)
|
||||
.order_by(KnowledgeSummary.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
async def run_daily_summary(db: Session) -> KnowledgeSummary:
|
||||
"""运行每日摘要"""
|
||||
today = date.today()
|
||||
period_key = today.isoformat()
|
||||
since = datetime(today.year, today.month, today.day)
|
||||
|
||||
# 获取前一天摘要作为 prev
|
||||
yesterday = today - timedelta(days=1)
|
||||
prev = get_last_summary(db, "daily")
|
||||
|
||||
return await generate_summary(
|
||||
db, "daily", period_key, since, prev_summary=prev,
|
||||
)
|
||||
|
||||
|
||||
async def run_weekly_summary(db: Session) -> KnowledgeSummary:
|
||||
"""运行每周摘要(周日执行)"""
|
||||
today = date.today()
|
||||
# ISO 周算法: 本周一到今天
|
||||
iso_week = today.isocalendar()
|
||||
period_key = f"{iso_week[0]}-W{iso_week[1]:02d}"
|
||||
since = today - timedelta(days=today.weekday()) # 本周一
|
||||
since_dt = datetime(since.year, since.month, since.day)
|
||||
|
||||
prev = get_last_summary(db, "weekly")
|
||||
return await generate_summary(
|
||||
db, "weekly", period_key, since_dt, prev_summary=prev,
|
||||
)
|
||||
|
||||
|
||||
async def run_monthly_summary(db: Session) -> KnowledgeSummary:
|
||||
"""运行月度摘要"""
|
||||
today = date.today()
|
||||
period_key = today.strftime("%Y-%m")
|
||||
since = datetime(today.year, today.month, 1)
|
||||
|
||||
# 附属前一个月的 daily 和 weekly 摘要
|
||||
prev_month = today.replace(day=1) - timedelta(days=1)
|
||||
prev = get_last_summary(db, "monthly")
|
||||
|
||||
return await generate_summary(
|
||||
db, "monthly", period_key, since, prev_summary=prev,
|
||||
)
|
||||
|
||||
|
||||
# ── 对外接口(同步包装,供手动触发使用) ──
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
def generate_summary_sync(
|
||||
db: Session,
|
||||
level: str,
|
||||
period_key: str,
|
||||
since: datetime,
|
||||
until: Optional[datetime] = None,
|
||||
) -> dict:
|
||||
"""同步包装,用于 API 手动触发"""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
summary = loop.run_until_complete(
|
||||
generate_summary(db, level, period_key, since, until)
|
||||
)
|
||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def generate_daily_sync(db: Session) -> dict:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
summary = loop.run_until_complete(run_daily_summary(db))
|
||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def generate_weekly_sync(db: Session) -> dict:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
summary = loop.run_until_complete(run_weekly_summary(db))
|
||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def generate_monthly_sync(db: Session) -> dict:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
summary = loop.run_until_complete(run_monthly_summary(db))
|
||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
||||
finally:
|
||||
loop.close()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user