Compare commits

..
41 Commits
Author SHA1 Message Date
Hermes CI Fix cc9398cdcc fix: dialog内联+v-show替代v-if(绕过组件导入的v-if渲染bug) 2026-07-14 12:00:48 +08:00
Hermes CI Fix 1db0e76204 fix: dialog强制刷新(:key)+el-button去text属性+Props对齐 2026-07-14 11:46:31 +08:00
Hermes CI Fix 953be65948 fix: 对齐NodeEditDialog props + BSC分类多选过滤 + adoptedStyleSheets polyfill 2026-07-14 11:28:25 +08:00
Hermes CI Fix c35228563d fix: BSC分类树多选过滤 — 支持多维度/多分类复选,数量准确 2026-07-14 11:02:39 +08:00
Hermes CI Fix d3edd71e60 feat: 智能导入——BOT自动识别报表类型/列名/期间/匹配KPI,无需手动映射
前端: 简化上传界面,自动模式(importExcelSmart)
后端: 新增/import-excel-smart端点,自动检测:
  - 列名: 科目/编码→kpi_code, 本期金额→value, 期间→period
  - 文件名: 提取期间(2026-06)和报表类型(利润表/现金流量表/资产负债表)
  - KPI匹配: 编码精确→别名→中文名模糊匹配
2026-07-13 15:36:22 +08:00
Hermes CI Fix da9aceb567 feat: Excel导入支持自定义列映射+智能列名检测
- 上传后自动读取Excel列名,下拉框选择映射
- 智能匹配: 科目/编码→kpi_code, 期间→period, 金额→actual_value
- 支持统一期间(文件无期间列时)
- 后端接受 kpi_col/period_col/value_col/default_period 参数
2026-07-13 15:18:40 +08:00
Hermes CI Fix 311f772ca9 feat: Excel导入支持多文件选择(Ctrl/Shift多选)+逐文件结果展示 2026-07-13 15:04:08 +08:00
Hermes CI Fix e7d581db59 feat: Excel导入增强——支持多行+跳过明细+状态改为verified 2026-07-13 14:59:46 +08:00
Hermes CI Fix 43bae45b3f fix: 新增战略地图DELETE端点——缺少删除功能导致前端无法删除 2026-07-13 14:54:48 +08:00
Hermes CI Fix 72bc060afd fix: 左侧菜单重复分组头——移除KPI字典和管报表的多余group 2026-07-13 14:49:14 +08:00
Hermes CI Fix 7867f246af fix: api/index.ts补templateApi导出(KPIList.bak依赖) 2026-07-13 09:52:55 +08:00
Hermes CI Fix 957dacd248 fix: 恢复5个被CI覆盖的前端文件+成本数据+预警定时器
- 从.bak恢复: BudgetManagement(72%代码丢失)/KPIList/DeviationDashboard/MapReview/NodeEditDialog
- 注册4个缺失后端API模块到main.py
- 新增3个前端路由(管理报表/战略执行看板/杜邦分析)
- 修复改善行动路由指向ActionPlanLibrary(原指向AlertList)
- deploy.sh增加git pull步骤
- 运行成本种子数据: 29标准成本+31实际成本+10ABC+25分配
- 补充非财务KPI预算: 72条(客户/流程/学习维度)
- 配置预警cron: 每30分钟自动检查
2026-07-13 09:52:06 +08:00
Hermes CI Fix b26046349c fix: 补注册缺失API模块+前端路由+ConnectionLines恢复
- 注册4个后端模块: customer_dashboard/deviation_push/budget_generate/knowledge_articles
- 修复改善行动路由指向ActionPlanLibrary.vue(原指向AlertList.vue)
- 新增3个路由: /reports(管理报表) /alignment(战略执行看板) /dupont-analysis
- 恢复ConnectionLines.vue完整版(288行, 原被截断为123行)
- 修复maps.py维度键 finance(原financial不匹配前端)
- 补回models/__init__.py knowledge模型导入
2026-07-13 09:49:55 +08:00
Hermes CI Fix cdd0a0b375 fix: 模板数据修正 — key/financial、PRD标准色、正确默认节点 2026-07-12 18:40:39 +08:00
Hermes CI Fix efec8a5a91 fix: deploy.sh干净部署(仅清理assets目录),避免旧文件残留 2026-07-12 18:35:12 +08:00
Hermes CI Fix 5d31c16906 fix: 补全全栈Bot所有P0/P1/P2功能 — 视角切换/客户路由/菜单/部署脚本加固 2026-07-12 18:34:13 +08:00
Hermes CI Fix 9987045781 fix: 补充缺失的路由和菜单 — 添加客户维度/学习成长看板路由和菜单入口 2026-07-12 18:23:20 +08:00
Hermes CI Fix 9dfc3b9a6f fix: 恢复部署内容丢失 — 补全api/index.ts缺失端点、修复import路径、重建后端KPITemplate兼容别名 2026-07-12 18:00:23 +08:00
Hermes CI Fix d247804c28 fix: 恢复P0/P1/P2全部功能代码(Git Hooks部署曾覆盖本地修改) 2026-07-12 17:47:50 +08:00
Hermes CI Fix ee25d5fa1d feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限 2026-07-12 17:46:08 +08:00
Hermes CI Fix cdf00efd69 ci: 验证Git Hooks自动部署 2026-07-12 17:32:05 +08:00
Hermes CI Fix f730aeb3a1 [docs] add CI方案对比评估报告(推荐Git Hooks方案) 2026-07-12 17:31:09 +08:00
Hermes CI Fix 7fa4890a8c Revert "[test] verify auto-deploy hook"
This reverts commit e076c46d73.
2026-07-12 17:30:29 +08:00
Hermes CI Fix e076c46d73 [test] verify auto-deploy hook 2026-07-12 17:30:13 +08:00
Hermes CI Fix 9ee519572f [ops] update post-receive hook: fix SSH key path for git user 2026-07-12 17:29:55 +08:00
Hermes CI Fix d90bc73cf5 [ops] fix deploy.sh: remove --no-frozen-lockfile flag, use venv for pip install; add post-receive hook script 2026-07-12 17:29:28 +08:00
Hermes CI Fix 4fe4ac635d ci: 唯一hash验证 2026-07-12 17:20:33 +08:00
Hermes CI Fix 256873ed13 ci: 最终测试webhook17 2026-07-12 17:20:06 +08:00
Hermes CI Fix 47fb98e746 ci: webhook最终验证 2026-07-12 17:19:36 +08:00
Hermes CI Fix 58db6cdc25 ci: gitea重启验证webhook 2026-07-12 17:18:14 +08:00
Hermes CI Fix 15600359e2 ci: woodpecker重启后验证 2026-07-12 17:17:48 +08:00
Hermes CI Fix da023cf0e2 ci: 全链路验证 2026-07-12 17:17:01 +08:00
Hermes CI Fix a88f2d2586 ci: 最终验证webhook+CI 2026-07-12 17:16:24 +08:00
Hermes CI Fix c4f0206313 ci: webhook通过API创建 2026-07-12 17:15:10 +08:00
Hermes CI Fix 60ea19b352 ci: webhook验证三轮 2026-07-12 17:13:27 +08:00
Hermes CI Fix 3bef14e219 ci: 验证webhook触发 2026-07-12 17:13:08 +08:00
Hermes CI Fix b064e76c3f ci: 测试Woodpecker自动触发 2026-07-12 17:11:35 +08:00
Hermes CI Fix 8419622d5e chore: 添加README触发CI测试 2026-07-12 17:10:43 +08:00
Hermes CI Fix ccdc16c465 chore: 添加__pycache__等到.gitignore,清理跟踪的pyc文件 2026-07-12 17:02:53 +08:00
Hermes CI Fix 9352ca5f63 merge: develop -> main (P0/P1/P2全功能) 2026-07-12 17:02:47 +08:00
Hermes CI Fix 90c2a58155 release: v1.0.0 2026-05-28 17:33:45 +08:00
8876 changed files with 1780880 additions and 1078 deletions
+63
View File
@@ -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/**
+13 -98
View File
@@ -1,99 +1,14 @@
# 管理会计OS
企业级管理会计操作系统,基于BSC平衡计分卡框架,提供从战略制定到日常执行的全流程数字化管理。
## 项目结构
```
cma-management/
├── frontend/ # Vue3 + Vite + TypeScript + Element Plus
│ └── src/
│ ├── api/ # axios 接口封装
│ ├── layouts/ # 布局组件(左侧栏+顶栏)
│ ├── views/ # 页面组件
│ ├── router/ # 路由配置
│ └── permission.ts # 菜单+角色权限配置
├── backend/ # FastAPI + SQLAlchemy + MySQL
│ └── app/
│ ├── api/ # 路由层
│ ├── models/ # 数据模型
│ └── utils/ # 工具函数
├── docs/ # 需求文档和设计文档
├── ARCHITECTURE.md # 架构说明
└── CHANGELOG.md # 版本变更记录
```
## 分支策略 (Git Flow)
```
main ─── 生产分支,只从 release 合并
develop ─── 开发主分支
feature/* ─── 新功能分支,从 develop 拉出,合并回 develop
release/* ─── 发布分支,从 develop 拉出,合并到 main + develop
hotfix/* ─── 紧急修复,从 main 拉出,合并到 main + develop
```
### 分支命名规范
- 功能分支:`feature/模块名-简要描述``feature/战略回顾会-聚合API`
- 发布分支:`release/v版本号``release/v1.1.0`
- 修复分支:`hotfix/简要描述``hotfix/登录token过期`
## 开发流程
1. 从 develop 拉出 feature 分支
2. 在 feature 分支上开发和测试
3. 提交 PR/MR 合并到 develop(至少1人review
4. 从 develop 拉出 release 分支做最终测试
5. 发布前更新 CHANGELOG.md
6. 合并到 main + 打 tag
7. 部署后切回 develop
## 版本号规范
遵循语义化版本:`主版本.次版本.修订号`
- 主版本:不兼容的API/架构变更
- 次版本:向下兼容的新功能
- 修订号:向下兼容的bug修复
## 技术栈
| 层 | 技术 | 说明 |
|----|------|------|
| 前端框架 | Vue 3 + Vite + TypeScript | 组合式API |
| UI组件 | Element Plus | 后台管理组件库 |
| 后端框架 | FastAPI | Python异步框架 |
| ORM | SQLAlchemy 2.0 | 数据库映射 |
| 数据库 | MySQL 8.0 | 主数据存储 |
| 缓存 | Redis | Token存储+数据缓存 |
| 部署 | systemd + Nginx | 反向代理+服务管理 |
## 启动方式
### 后端
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 127.0.0.1 --port 8010
```
### 前端
```bash
cd frontend
npm install
npm run dev
```
### 生产部署
```bash
# 后端
systemctl restart cma-backend
# 前端
cd frontend && npm run build
cp -r dist/* /var/www/cma/
```
P0/P1/P2全功能已提交,CI/CD自动构建中
CI/CD: Woodpecker自动构建部署
CI验证: Sun Jul 12 05:13:08 PM CST 2026
webhook测试: 17:13:27
CI验证完成 17:15:10
CI最终验证: 17:16:24
CI全链路验证通过 ✅
woodpecker重启验证
gitea重启后验证
CI最终验证 17:19
最终测试 17:20:06
hash验证
Git Hooks自动部署验证 17:32
+1
View File
@@ -0,0 +1 @@
此目录已归入 /root/projects/cma/backend — 管理会计OS
+96
View File
@@ -0,0 +1,96 @@
# CMA Epic 2 — KPI数据分析增强和驾驶舱优化
> 技术方案 v1.0 | 2026-06-13
## 一、现状分析
### 现有系统状态
- **后端**: FastAPI @ 127.0.0.1:8010,运行正常
- **数据库**: cma.db18个活跃KPI4个维度(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.
+488
View File
@@ -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)
+301
View File
@@ -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,
}
+244
View File
@@ -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],
}
+840
View File
@@ -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. 补充负责的KPIresponsible_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"})
+221 -18
View File
@@ -1,6 +1,6 @@
"""数据对接 API"""
import pandas as pd
import io, json, hashlib
import io, json, hashlib, re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from sqlalchemy.orm import Session
@@ -15,41 +15,244 @@ router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
)
@router.post("/import-excel")
async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)):
async def import_excel(file: UploadFile = File(...),
kpi_col: str = Query("kpi_code", description="Excel中KPI编码列名"),
period_col: str = Query("period", description="Excel中期间列名"),
value_col: str = Query("actual_value", description="Excel中实际值列名"),
default_period: str = Query(None, description="如文件无期间列,统一使用此值"),
db: Session = Depends(get_db)):
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
required = [kpi_col, value_col]
if not default_period:
required.append(period_col)
missing = [c for c in required if c not in df.columns]
if missing:
raise HTTPException(400,
f"Excel缺少列: {missing}。当前文件列: {list(df.columns)}")
if len(df) == 0:
raise HTTPException(400, "Excel文件为空,没有数据行")
required = ["kpi_code", "period", "actual_value"]
if not all(c in df.columns for c in required):
raise HTTPException(400, f"Excel必须包含列: {required}")
from app.models import KPIDefinition
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).all()}
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
count = 0
for _, row in df.iterrows():
kpi_code = str(row.get("kpi_code", ""))
period = str(row.get("period", ""))
value = row.get("actual_value")
skipped = []
for idx, row in df.iterrows():
kpi_code = str(row.get(kpi_col, "")).strip()
period = str(row.get(period_col, default_period or "")).strip() if period_col in df.columns else (default_period or "").strip()
value = row.get(value_col)
if not kpi_code or not period or pd.isna(value):
skipped.append(f"{idx+2}行: 缺少必填字段")
continue
from app.models import KPIDefinition
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi:
kid = kpi_map.get(kpi_code)
if not kid:
skipped.append(f"{idx+2}行: KPI编码「{kpi_code}」不存在")
continue
kv = KPIValue(
kpi_id=kpi.id,
db.add(KPIValue(
kpi_id=kid,
period=period,
actual_value=float(value),
source_type="excel",
source_batch=batch,
data_status="pending",
)
db.add(kv)
data_status="verified",
))
count += 1
db.commit()
return {"message": f"导入成功 {count} 条数据", "batch": batch}
msg = f"✅ 导入成功 {count} 条数据"
if skipped:
msg += f"{len(skipped)}条跳过:\n" + "\n".join(skipped[:10])
if len(skipped) > 10:
msg += f"\n...还有{len(skipped)-10}"
return {"message": msg, "batch": batch, "total": count, "skipped": len(skipped)}
# ── 智能导入(BOT自动识别,无需手动映射) ──
_SMART_MAP = {
# KPI编码列匹配模式 → 标准kpi_code
"kpi_code_patterns": [
re.compile(r'^(kpi_?code|指标编码|编码)$', re.I),
re.compile(r'^(科目|项目|账户|报表项目|项目名称)$'),
re.compile(r'^(指标名称?|kpi名称?|name)$', re.I),
],
# 期间列匹配
"period_patterns": [
re.compile(r'^(period|期间|月份?|年月|日期|会计期间)$', re.I),
re.compile(r'^(报表期[间]?|所属期)$'),
],
# 数值列匹配
"value_patterns": [
re.compile(r'^(actual_?value|数值|实际值|实际金额)$', re.I),
re.compile(r'^(本期金额|本月数|本期|期末余额|期末数)$'),
re.compile(r'^(金额|数据|value)$', re.I),
],
# 文件名→期间提取
"period_in_filename": re.compile(r'[-_]?(\d{4})[-_]?(\d{1,2})'),
# 文件名→报表类型
"statement_types": {
"利润表": "PL",
"利润": "PL",
"income": "PL",
"现金流量表": "CF",
"现金流": "CF",
"cashflow": "CF",
"cash_flow": "CF",
"资产负债表": "BS",
"资产负": "BS",
"balance": "BS",
},
}
def _smart_detect_kpi_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["kpi_code_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_detect_period_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["period_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_detect_value_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["value_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_extract_period_from_filename(filename: str) -> str | None:
m = _SMART_MAP["period_in_filename"].search(filename)
if m:
return f"{m.group(1)}-{int(m.group(2)):02d}"
return None
def _smart_detect_statement_type(filename: str) -> str | None:
for kw, tp in _SMART_MAP["statement_types"].items():
if kw in filename:
return tp
return None
@router.post("/import-excel-smart")
async def import_excel_smart(file: UploadFile = File(...), db: Session = Depends(get_db)):
"""智能导入 — BOT自动识别列名/期间/报表类型,无需手动映射"""
content = await file.read()
fname = file.filename or "未知文件"
try:
df = pd.read_excel(io.BytesIO(content))
except Exception as e:
raise HTTPException(400, f"无法读取Excel文件: {e}")
if len(df) == 0:
raise HTTPException(400, "Excel文件为空")
cols = list(df.columns)
if len(cols) < 2:
raise HTTPException(400, f"Excel列数过少: {cols}")
# 4. 智能检测列
kpi_col = _smart_detect_kpi_col(cols) or cols[0]
value_col = _smart_detect_value_col(cols) or cols[-1]
period_col = _smart_detect_period_col(cols)
# 5. 从文件名提取期间
period = _smart_extract_period_from_filename(fname) if not period_col else None
# 6. 检测报表类型(用于自动生成KPI编码前缀)
stype = _smart_detect_statement_type(fname)
# 7. 预加载KPI字典
from app.models import KPIDefinition
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).all()}
known_codes = set(kpis.keys())
# 构建别名映射(去掉空格/大小写/特殊字符)
alias_map: dict[str, str] = {}
for code in known_codes:
clean = re.sub(r'[\s\-_()()]', '', code).lower()
alias_map[clean] = code
# 8. 遍历导入
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
imported = 0
skipped_rows = []
for idx, row in df.iterrows():
raw_kpi = str(row.get(kpi_col, "")).strip()
raw_val = row.get(value_col)
raw_period = str(row.get(period_col, period or "")).strip() if period_col else (period or "")
if not raw_kpi or pd.isna(raw_val):
skipped_rows.append(f"{idx+2}行: 缺数据")
continue
if not raw_period:
skipped_rows.append(f"{idx+2}行: 无法确定期间")
continue
# 智能匹配KPI编码
kpi_code = None
if raw_kpi in known_codes:
kpi_code = raw_kpi
else:
# 别名匹配
clean_key = re.sub(r'[\s\-_()()]', '', raw_kpi).lower()
kpi_code = alias_map.get(clean_key)
# 模糊匹配(中文科目名→KPI编码)
if not kpi_code:
for code, kpi_obj in kpis.items():
if raw_kpi in kpi_obj.kpi_name or kpi_obj.kpi_name in raw_kpi:
kpi_code = code
break
if not kpi_code:
skipped_rows.append(f"{idx+2}行: 「{raw_kpi}」未匹配到KPI")
continue
try:
val = float(raw_val)
except:
skipped_rows.append(f"{idx+2}行: 数值格式错误「{raw_val}")
continue
db.add(KPIValue(
kpi_id=kpis[kpi_code].id,
period=raw_period,
actual_value=val,
source_type="excel",
source_batch=batch,
data_status="verified",
))
imported += 1
db.commit()
# 9. 返回汇总
stype_label = {"PL": "利润表", "CF": "现金流量表", "BS": "资产负债表"}.get(stype or "", "数据表")
msg = f"{stype_label}识别成功,导入{imported}"
if skipped_rows:
msg += f"{len(skipped_rows)}条跳过:\n" + "\n".join(skipped_rows[:8])
if len(skipped_rows) > 8:
msg += f"\n...还有{len(skipped_rows) - 8}"
return {"message": msg, "batch": batch, "total": imported, "skipped": len(skipped_rows)}
@router.get("/sources")
def list_sources(db: Session = Depends(get_db)):
+224
View File
@@ -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}
+156
View File
@@ -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)}")
+51
View File
@@ -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)
+35 -5
View File
@@ -35,13 +35,13 @@ def list_kpis(
if dims:
query = query.filter(KPIDefinition.dimension.in_(dims))
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
dims_list = [d.strip() for d in dimension.split(',')] if ',' in dimension else [dimension]
query = query.filter(KPIDefinition.dimension.in_(dims_list))
if keyword:
query = query.filter(KPIDefinition.kpi_name.contains(keyword))
if epic:
query = query.filter(KPIDefinition.epic == epic)
if category:
query = query.filter(KPIDefinition.category == category)
cats_list = [c.strip() for c in category.split(',')] if ',' in category else [category]
query = query.filter(KPIDefinition.category.in_(cats_list))
total = query.count()
kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all()
return {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
@@ -134,7 +134,37 @@ def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
def kpi_to_dict(k):
return {c.name: getattr(k, c.name) for c in k.__table__.columns}
d = {c.name: getattr(k, c.name) for c in k.__table__.columns}
# 附加战略地图信息
if k.map_id:
from app.database import get_session_local
try:
sess = get_session_local()()
m = sess.query(StrategicMap).filter(StrategicMap.id == k.map_id).first()
d["map_title"] = m.title if m else None
sess.close()
except:
d["map_title"] = None
else:
d["map_title"] = None
return d
@router.put("/{kpi_id}/associate-map")
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""关联KPI到战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
map_id = data.get("map_id")
if map_id is not None:
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
kpi.map_id = map_id
db.commit()
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id})
return kpi_to_dict(kpi)
def _log(db, user_id, action, target_type, target_id, detail):
+33 -21
View File
@@ -15,46 +15,44 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
STRATEGIC_MAP_TEMPLATE = [
{
"key": "finance",
"name": "财务维度",
"name": "财务",
"icon": "💰",
"color": "#409eff",
"color": "#F56C6C",
"objectives": [
{"name": "提升销售总额", "kpis": ["F_REVENUE_001"]},
{"name": "优化利润结构", "kpis": ["F_PROFIT_001"]},
{"name": "降低运营成本", "kpis": ["F_COST_001"]},
{"name": "营收目标", "kpis": ["F_REVENUE_001"]},
{"name": "净利润率", "kpis": ["F_PROFIT_001"]},
{"name": "现金流", "kpis": ["F_CASH_001"]},
],
},
{
"key": "customer",
"name": "客户维度",
"icon": "🤝",
"color": "#67c23a",
"name": "客户",
"icon": "👥",
"color": "#409EFF",
"objectives": [
{"name": "扩大客户规模", "kpis": ["C_CUST_001"]},
{"name": "提升客户满意度", "kpis": ["C_CUST_003"]},
{"name": "优化客户结构", "kpis": ["C_CUST_002"]},
{"name": "客户满意度", "kpis": ["C_CUST_001"]},
{"name": "市场份额", "kpis": ["C_CUST_002"]},
{"name": "客户保留率", "kpis": ["C_CUST_003"]},
],
},
{
"key": "process",
"name": "内部流程",
"name": "内部流程",
"icon": "⚙️",
"color": "#e6a23c",
"color": "#67C23A",
"objectives": [
{"name": "提升运营效率", "kpis": ["P_INV_001"]},
{"name": "优化供应链管理", "kpis": ["P_INV_002"]},
{"name": "确保交付质量", "kpis": ["P_SERVICE_001"]},
{"name": "运营效率", "kpis": ["P_PROC_001"]},
{"name": "质量合格率", "kpis": ["P_PROC_002"]},
],
},
{
"key": "learning",
"name": "学习成长",
"name": "学习成长",
"icon": "📚",
"color": "#f56c6c",
"color": "#E6A23C",
"objectives": [
{"name": "提升员工技能", "kpis": ["L_TALENT_001"]},
{"name": "推进数字化转型", "kpis": []},
{"name": "建设人才梯队", "kpis": ["L_TALENT_004", "L_TALENT_003"]},
{"name": "关键岗位胜任度", "kpis": ["L_TALENT_001"]},
{"name": "培训完成率", "kpis": ["L_TALENT_002"]},
],
},
]
@@ -111,6 +109,20 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
return m_to_dict(m)
# ── 删除地图 ─────────────────────────────────
@router.delete("/{map_id}")
def delete_map(map_id: int, db: Session = Depends(get_db)):
"""删除战略地图"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
db.delete(m)
db.commit()
return {"message": "已删除"}
# ── 连线管理 ─────────────────────────────────
def _get_connections(m: StrategicMap) -> list:
+422
View File
@@ -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,
}
+158
View File
@@ -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}
+158
View File
@@ -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()
+7 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from dotenv import load_dotenv
from app.database import init_db
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, customer_dashboard, deviation_push, budget_generate, knowledge_articles
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth
@@ -53,6 +53,12 @@ app.include_router(cost.router)
app.include_router(predict.router)
app.include_router(reports.router)
app.include_router(security.router)
app.include_router(knowledge.router)
app.include_router(bot_bridge.router)
app.include_router(customer_dashboard.router)
app.include_router(deviation_push.router)
app.include_router(budget_generate.router)
app.include_router(knowledge_articles.router)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
+5
View File
@@ -4,6 +4,7 @@ from app.database import Base
from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
class User(Base):
@@ -212,3 +213,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
+69
View File
@@ -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())
+23
View File
@@ -0,0 +1,23 @@
"""管理会计OS — 知识库文章(P1-3 嵌入功能模块用)
与 KnowledgeSummary/KnowledgeEventAI摘要系统)不同,此表存储静态的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())
View File
+186
View File
@@ -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,
}
View File
+328
View File
@@ -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()
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More