Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eded3e4fe | ||
|
|
82919b1616 | ||
|
|
4b25f46124 | ||
|
|
46fa811d52 | ||
|
|
9afcea8f8d | ||
|
|
5594a572e9 | ||
|
|
fc40cb8a65 | ||
|
|
4cfada967f | ||
|
|
1a08e801f4 | ||
|
|
f821ee2c9d | ||
|
|
6218a7d2db | ||
|
|
c7d436ec7a | ||
|
|
29a09013d4 | ||
|
|
61d37bc31c | ||
|
|
966c9808ec | ||
|
|
17c7a35c77 | ||
|
|
cc9398cdcc | ||
|
|
1db0e76204 | ||
|
|
953be65948 | ||
|
|
c35228563d | ||
|
|
d3edd71e60 | ||
|
|
da9aceb567 | ||
|
|
311f772ca9 | ||
|
|
e7d581db59 | ||
|
|
43bae45b3f | ||
|
|
72bc060afd | ||
|
|
7867f246af | ||
|
|
957dacd248 | ||
|
|
b26046349c | ||
|
|
cdd0a0b375 | ||
|
|
efec8a5a91 | ||
|
|
5d31c16906 | ||
|
|
9987045781 | ||
|
|
9dfc3b9a6f | ||
|
|
d247804c28 | ||
|
|
ee25d5fa1d | ||
|
|
cdf00efd69 | ||
|
|
f730aeb3a1 | ||
|
|
7fa4890a8c | ||
|
|
e076c46d73 | ||
|
|
9ee519572f | ||
|
|
d90bc73cf5 | ||
|
|
4fe4ac635d | ||
|
|
256873ed13 | ||
|
|
47fb98e746 | ||
|
|
58db6cdc25 | ||
|
|
15600359e2 | ||
|
|
da023cf0e2 | ||
|
|
a88f2d2586 | ||
|
|
c4f0206313 | ||
|
|
60ea19b352 | ||
|
|
3bef14e219 | ||
|
|
b064e76c3f | ||
|
|
8419622d5e | ||
|
|
ccdc16c465 | ||
|
|
9352ca5f63 | ||
|
|
90c2a58155 |
@@ -4,3 +4,8 @@ dist
|
||||
.env
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
*.pyc
|
||||
__pycache__/
|
||||
venv/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
@@ -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/**
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
此目录已归入 /root/projects/cma/backend — 管理会计OS
|
||||
@@ -0,0 +1,96 @@
|
||||
# CMA Epic 2 — KPI数据分析增强和驾驶舱优化
|
||||
> 技术方案 v1.0 | 2026-06-13
|
||||
|
||||
## 一、现状分析
|
||||
|
||||
### 现有系统状态
|
||||
- **后端**: FastAPI @ 127.0.0.1:8010,运行正常
|
||||
- **数据库**: cma.db,18个活跃KPI,4个维度(finance:8, customer:3, process:3, learning:4)
|
||||
- **预警**: 16个待处理预警
|
||||
- **Dashboard.vue**: CEO/Finance/Business/IT四角色视图,已有KPI矩阵、预测、简报等功能
|
||||
- **MyDashboard.vue**: PDCA管理闭环、趋势柱状图
|
||||
- **deviation_engine.py**: 已有同比/环比计算基础函数(calc_period_diff),但未被dashboard API集成
|
||||
- **ai_analysis.py**: 已集成DeepSeek API做CEO简报和KPI分析
|
||||
|
||||
### 待开发功能
|
||||
1. **同比环比趋势分析** — deviation_engine.py已有calc_period_diff,需集成到dashboard API
|
||||
2. **预警趋势统计** — 按等级/维度/时间的统计API
|
||||
3. **KPI数据导出CSV** — 导出功能
|
||||
4. **驾驶舱KPI增强** — 增加trend字段和achievement_rate
|
||||
5. **Dashboard.vue趋势分析tab** — ECharts折线图
|
||||
6. **Dashboard.vue预警统计卡片** — 饼图+趋势线
|
||||
7. **Dashboard.vue达成率进度条** — 已有简单进度条,增强可视化
|
||||
|
||||
## 二、后端新增API
|
||||
|
||||
### 1. KPI同比环比趋势分析
|
||||
```
|
||||
POST /api/cma/dashboard/trend-analysis
|
||||
参数: kpi_ids (list[int]), period_type (month/quarter/year), compare_type (yoy/mom)
|
||||
返回: {
|
||||
data: [{
|
||||
kpi_id, kpi_code, kpi_name, unit,
|
||||
current_value, current_period,
|
||||
previous_value, previous_period,
|
||||
change_rate, # 变化率(%)
|
||||
change_amount, # 变化额
|
||||
trend_direction, # up/down/stable
|
||||
dimension
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 预警趋势统计
|
||||
```
|
||||
GET /api/cma/dashboard/alert-stats
|
||||
参数: period (month/quarter/year)
|
||||
返回: {
|
||||
total_pending: N,
|
||||
by_severity: { red: N, yellow: N, green: N },
|
||||
by_dimension: [{ dimension, count }],
|
||||
trend_by_month: [{ month, red, yellow, green }]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. KPI数据导出CSV
|
||||
```
|
||||
GET /api/cma/dashboard/export
|
||||
参数: kpi_ids (comma-separated), period
|
||||
返回: CSV文件流 (Content-Type: text/csv)
|
||||
```
|
||||
|
||||
### 4. 驾驶舱KPI增强(修改现有get_dashboard_kpis)
|
||||
- 每个KPI增加 `trend` 字段(最近3期环比变化率)
|
||||
- 增加 `achievement_rate` 字段(actual_value / target_value)
|
||||
- 增加 `period_values` 数组(最近6期数据,供前端画趋势图)
|
||||
|
||||
## 三、前端改造
|
||||
|
||||
### Dashboard.vue 增强(CEO视图)
|
||||
1. **趋势分析标签页** — ECharts折线图,支持同比/环比切换
|
||||
2. **预警统计卡片** — 饼图(severity分布) + 趋势折线
|
||||
3. **KPI卡片增强** — 达成率百分比 + 彩色进度条 + 趋势箭头
|
||||
4. **数据导出按钮** — 调用export API下载CSV
|
||||
|
||||
### 前端API扩展
|
||||
在 `/frontend/src/api/index.ts` 的 `dashboardApi` 中增加:
|
||||
- `trendAnalysis: (params) => api.post('/dashboard/trend-analysis', params)`
|
||||
- `alertStats: (params) => api.get('/dashboard/alert-stats', { params })`
|
||||
- `exportKpis: (params) => api.get('/dashboard/export', { params, responseType: 'blob' })`
|
||||
|
||||
## 四、执行顺序
|
||||
|
||||
```
|
||||
Step 1 (并行): Backend → 趋势分析API + 预警统计API + 导出API
|
||||
Frontend → API扩展定义(与后端同步)
|
||||
Step 2 (串行, 依赖Step1): Frontend → Dashboard.vue改造
|
||||
Step 3 (串行, 依赖Step2): DevOps → 部署重启
|
||||
Step 4 (串行, 依赖Step3): QA → 全流程验证
|
||||
```
|
||||
|
||||
## 五、依赖关系
|
||||
|
||||
- trend-analysis API: 可直接复用deviation_engine.py的calc_period_diff
|
||||
- alert-stats API: 可直接从KPIAlert表聚合统计
|
||||
- export API: 无依赖
|
||||
- Dashboard.vue趋势tab: 依赖Step1的API
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -143,3 +143,163 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(plan)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 功能5: COSO内控自检表 (CMA P1 - COSO五要素)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
COSO_CHECKLIST_DATA = {
|
||||
"hanke": {
|
||||
"entity_name": "陕西酣客(白酒经销)",
|
||||
"total_score": 46,
|
||||
"max_score": 100,
|
||||
"risk_level": "high", # high / medium / low
|
||||
"risk_label": "高风险",
|
||||
"elements": [
|
||||
{
|
||||
"id": "control_environment",
|
||||
"name": "控制环境",
|
||||
"name_en": "Control Environment",
|
||||
"score": 60,
|
||||
"max_score": 100,
|
||||
"status": "medium",
|
||||
"items": [
|
||||
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 任总亲自跟"},
|
||||
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务"},
|
||||
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 渠补无标准审批流程"},
|
||||
{"id": "ce_04", "text": "人事政策", "passed": False, "detail": "❌ 无定期轮岗"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "risk_assessment",
|
||||
"name": "风险评估",
|
||||
"name_en": "Risk Assessment",
|
||||
"score": 40,
|
||||
"max_score": 100,
|
||||
"status": "low",
|
||||
"items": [
|
||||
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 没有系统风险清单"},
|
||||
{"id": "ra_02", "text": "风险应对预案", "passed": False, "detail": "❌ 现金断流无预案"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "control_activities",
|
||||
"name": "控制活动",
|
||||
"name_en": "Control Activities",
|
||||
"score": 30,
|
||||
"max_score": 100,
|
||||
"status": "low",
|
||||
"items": [
|
||||
{"id": "ca_01", "text": "渠补审批流程", "passed": False, "detail": "❌ 口头谈,无记录"},
|
||||
{"id": "ca_02", "text": "费用审批流程", "passed": False, "detail": "❌ 超预算无拦截"},
|
||||
{"id": "ca_03", "text": "实物返利入账流程", "passed": False, "detail": "❌ 纯P&L不进系统"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "information_communication",
|
||||
"name": "信息与沟通",
|
||||
"name_en": "Information & Communication",
|
||||
"score": 70,
|
||||
"max_score": 100,
|
||||
"status": "medium",
|
||||
"items": [
|
||||
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
|
||||
{"id": "ic_02", "text": "系统数据互通", "passed": False, "detail": "❌ 进销存≠财务账"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "monitoring",
|
||||
"name": "监控",
|
||||
"name_en": "Monitoring",
|
||||
"score": 30,
|
||||
"max_score": 100,
|
||||
"status": "low",
|
||||
"items": [
|
||||
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
|
||||
{"id": "mo_02", "text": "异常追踪机制", "passed": False, "detail": "❌ 发现异常无跟踪"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"bohai": {
|
||||
"entity_name": "陕西博海科技(IT服务)",
|
||||
"total_score": 55,
|
||||
"max_score": 100,
|
||||
"risk_level": "medium",
|
||||
"risk_label": "中风险",
|
||||
"elements": [
|
||||
{
|
||||
"id": "control_environment",
|
||||
"name": "控制环境",
|
||||
"name_en": "Control Environment",
|
||||
"score": 70,
|
||||
"max_score": 100,
|
||||
"status": "medium",
|
||||
"items": [
|
||||
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 老板直接管"},
|
||||
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务≠技术"},
|
||||
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 部分项目无预算审批"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "risk_assessment",
|
||||
"name": "风险评估",
|
||||
"name_en": "Risk Assessment",
|
||||
"score": 50,
|
||||
"max_score": 100,
|
||||
"status": "low",
|
||||
"items": [
|
||||
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 无正式风险清单"},
|
||||
{"id": "ra_02", "text": "风险应对预案", "passed": True, "detail": "✅ 重点项目有预案"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "control_activities",
|
||||
"name": "控制活动",
|
||||
"name_en": "Control Activities",
|
||||
"score": 50,
|
||||
"max_score": 100,
|
||||
"status": "low",
|
||||
"items": [
|
||||
{"id": "ca_01", "text": "采购审批流程", "passed": True, "detail": "✅ 有标准流程"},
|
||||
{"id": "ca_02", "text": "项目交付流程", "passed": False, "detail": "❌ 验收流程不完善"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "information_communication",
|
||||
"name": "信息与沟通",
|
||||
"name_en": "Information & Communication",
|
||||
"score": 60,
|
||||
"max_score": 100,
|
||||
"status": "medium",
|
||||
"items": [
|
||||
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
|
||||
{"id": "ic_02", "text": "项目沟通机制", "passed": False, "detail": "❌ 跨部门信息滞后"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "monitoring",
|
||||
"name": "监控",
|
||||
"name_en": "Monitoring",
|
||||
"score": 40,
|
||||
"max_score": 100,
|
||||
"status": "low",
|
||||
"items": [
|
||||
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
|
||||
{"id": "mo_02", "text": "异常追踪机制", "passed": True, "detail": "✅ 项目延期有跟踪"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/coso-checklist")
|
||||
def get_coso_checklist(entity: str = "hanke"):
|
||||
"""COSO内控自检表 - CMA P1 COSO五要素"""
|
||||
data = COSO_CHECKLIST_DATA.get(entity)
|
||||
if not data:
|
||||
data = COSO_CHECKLIST_DATA["hanke"]
|
||||
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
|
||||
return data
|
||||
|
||||
+463
-60
@@ -1,76 +1,479 @@
|
||||
"""预警规则配置"""
|
||||
""""
|
||||
预警规则智能化 — 任务6
|
||||
后端组件: alert_rules 模型 + API + 预警引擎
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db, Base
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIAlert, KPIDefinition, KPIValue
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||
|
||||
logger = logging.getLogger("alert_rules")
|
||||
|
||||
# ============================================================
|
||||
# AlertRule 模型
|
||||
# ============================================================
|
||||
class AlertRule(Base):
|
||||
"""预警规则配置"""
|
||||
__tablename__ = "alert_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||
rule_type = Column(String(30), nullable=False, comment="static/dynamic/trend_up/trend_down")
|
||||
enabled = Column(Integer, default=1, comment="1启用 0禁用")
|
||||
params = Column(JSON, nullable=True, comment="规则参数")
|
||||
# static: {"green": ">=90", "yellow": ">=80", "red": "<80"}
|
||||
# dynamic: {"sensitivity": 1.0} — 阈值 = mean ± sensitivity * stddev, period_months=3
|
||||
# trend_up: {"threshold_pct": 10} — 环比上升超过 threshold_pct% 触发
|
||||
# trend_down: {"threshold_pct": 10} — 环比下降超过 threshold_pct% 触发
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class DynamicThresholdCache(Base):
|
||||
"""动态阈值缓存 — 存储近3个月历史统计"""
|
||||
__tablename__ = "dynamic_threshold_cache"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||
period = Column(String(20), nullable=False, comment="计算期间 2026-07")
|
||||
mean_value = Column(Float, nullable=True, comment="近3月均值")
|
||||
stddev_value = Column(Float, nullable=True, comment="近3月标准差")
|
||||
dynamic_green = Column(String(100), nullable=True, comment="动态绿灯阈值")
|
||||
dynamic_yellow = Column(String(100), nullable=True, comment="动态黄灯阈值")
|
||||
dynamic_red = Column(String(100), nullable=True, comment="动态红灯阈值")
|
||||
calculated_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/cma/alert-rules", tags=["预警规则"],
|
||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
@router.get("")
|
||||
def list_rules(kpi_id: int = None, db: Session = Depends(get_db)):
|
||||
"""获取预警规则(从KPI定义中读取阈值配置)"""
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
if kpi_id:
|
||||
query = query.filter(KPIDefinition.id == kpi_id)
|
||||
rules = []
|
||||
for k in query.all():
|
||||
if k.threshold_green or k.threshold_yellow or k.threshold_red:
|
||||
rules.append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_name": k.kpi_name,
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
"threshold_red": k.threshold_red,
|
||||
})
|
||||
return {"data": rules}
|
||||
|
||||
@router.post("/check/{kpi_id}")
|
||||
def check_alert(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""检查指定KPI是否需要触发预警"""
|
||||
# ============================================================
|
||||
# API Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_alert_rules(
|
||||
kpi_id: Optional[int] = None,
|
||||
rule_type: Optional[str] = None,
|
||||
enabled: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出所有预警规则"""
|
||||
query = db.query(AlertRule)
|
||||
if kpi_id:
|
||||
query = query.filter(AlertRule.kpi_id == kpi_id)
|
||||
if rule_type:
|
||||
query = query.filter(AlertRule.rule_type == rule_type)
|
||||
if enabled is not None:
|
||||
query = query.filter(AlertRule.enabled == enabled)
|
||||
|
||||
rules = query.order_by(AlertRule.id).all()
|
||||
result = []
|
||||
for r in rules:
|
||||
d = {c.name: getattr(r, c.name) for c in AlertRule.__table__.columns}
|
||||
# 关联KPI信息
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
|
||||
if kpi:
|
||||
d["kpi_code"] = kpi.kpi_code
|
||||
d["kpi_name"] = kpi.kpi_name
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}")
|
||||
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单个KPI的所有预警规则"""
|
||||
rules = db.query(AlertRule).filter(AlertRule.kpi_id == kpi_id).order_by(AlertRule.id).all()
|
||||
return {"data": [{c.name: getattr(r, c.name) for c in AlertRule.__table__.columns} for r in rules]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(require_role("ceo", "finance", "it"))):
|
||||
"""创建预警规则"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
rule_type = data.get("rule_type", "static")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if rule_type not in ("static", "dynamic", "trend_up", "trend_down"):
|
||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
||||
|
||||
rule = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
enabled=data.get("enabled", 1),
|
||||
params=data.get("params"),
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).first()
|
||||
if not latest or not latest.actual_value:
|
||||
return {"alert": False, "message": "无数据"}
|
||||
# 日志
|
||||
db.add(OperationLog(
|
||||
action="create_alert_rule", target_type="alert_rule",
|
||||
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
val = latest.actual_value
|
||||
level = "green"
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
|
||||
|
||||
@router.put("/{rule_id}")
|
||||
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""更新预警规则"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "预警规则不存在")
|
||||
|
||||
# 简单阈值判定
|
||||
red = kpi.threshold_red
|
||||
yellow = kpi.threshold_yellow
|
||||
|
||||
# 红灯判断: <3000000 表示低于300万触发红灯
|
||||
if red:
|
||||
if "<" in red:
|
||||
limit = float(red.split("<")[1].strip())
|
||||
if val < limit: level = "red"
|
||||
elif ">" in red:
|
||||
limit = float(red.split(">")[1].strip())
|
||||
if val > limit: level = "red"
|
||||
|
||||
# 黄灯判断(红灯未触发时)
|
||||
if level == "green" and yellow:
|
||||
if "<" in yellow:
|
||||
limit = float(yellow.split("<")[1].strip())
|
||||
if val < limit: level = "yellow"
|
||||
elif ">" in yellow:
|
||||
limit = float(yellow.split(">")[1].strip())
|
||||
if val > limit: level = "yellow"
|
||||
|
||||
if level != "green":
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi_id, kpi_value_id=latest.id,
|
||||
alert_level=level,
|
||||
alert_message=f"{kpi.kpi_name}当前值为{val},触发{level}预警",
|
||||
)
|
||||
db.add(alert)
|
||||
for field in ("rule_type", "enabled", "params"):
|
||||
if field in data:
|
||||
setattr(rule, field, data[field])
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
|
||||
"""删除预警规则"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||
if rule:
|
||||
db.delete(rule)
|
||||
db.commit()
|
||||
return {"alert": True, "level": level, "message": alert.alert_message}
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
||||
"""批量创建预警规则
|
||||
data.rules: [{"kpi_id": id, "rule_type": "static", "params": {...}}, ...]
|
||||
"""
|
||||
rules_data = data.get("rules", [])
|
||||
created = 0
|
||||
for rule_data in rules_data:
|
||||
kpi_id = rule_data.get("kpi_id")
|
||||
rule_type = rule_data.get("rule_type", "static")
|
||||
# 检查是否已存在相同类型的规则
|
||||
existing = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi_id,
|
||||
AlertRule.rule_type == rule_type,
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
rule = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
enabled=rule_data.get("enabled", 1),
|
||||
params=rule_data.get("params"),
|
||||
)
|
||||
db.add(rule)
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"批量创建完成: 新增{created}条", "created": created}
|
||||
|
||||
|
||||
@router.post("/generate-defaults")
|
||||
def generate_default_rules(db: Session = Depends(get_db)):
|
||||
"""为所有尚未配置预警规则的KPI生成默认规则"""
|
||||
# 找到所有active KPI
|
||||
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
|
||||
return {"alert": False, "level": "green", "message": "正常"}
|
||||
created = 0
|
||||
for kpi in all_kpis:
|
||||
# 检查是否已有任何规则
|
||||
existing = db.query(AlertRule).filter(AlertRule.kpi_id == kpi.id).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
kpi_id = kpi.id
|
||||
|
||||
# 1. 静态阈值规则(基于kpi_definitions的阈值)
|
||||
if kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red:
|
||||
rule = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type="static",
|
||||
enabled=1,
|
||||
params={
|
||||
"green": kpi.threshold_green,
|
||||
"yellow": kpi.threshold_yellow,
|
||||
"red": kpi.threshold_red,
|
||||
}
|
||||
)
|
||||
db.add(rule)
|
||||
created += 1
|
||||
|
||||
# 2. 动态趋势规则(所有KPI默认加 trend_down)
|
||||
rule2 = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type="trend_down",
|
||||
enabled=1,
|
||||
params={"threshold_pct": 10},
|
||||
)
|
||||
db.add(rule2)
|
||||
created += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"默认规则生成完成: 共{created}条", "created": created}
|
||||
|
||||
|
||||
@router.post("/check-all")
|
||||
def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
"""执行所有KPI的预警检查 — 生成新的预警记录"""
|
||||
rules = db.query(AlertRule).filter(AlertRule.enabled == 1).all()
|
||||
kpi_cache = {}
|
||||
value_cache = {}
|
||||
|
||||
alerts_generated = 0
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
kpi = kpi_cache.get(rule.kpi_id)
|
||||
if kpi is None:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
if kpi:
|
||||
kpi_cache[rule.kpi_id] = kpi
|
||||
|
||||
if not kpi:
|
||||
continue
|
||||
|
||||
# 获取最新值
|
||||
latest_value = value_cache.get(rule.kpi_id)
|
||||
if latest_value is None:
|
||||
latest_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == rule.kpi_id,
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
if latest_value:
|
||||
value_cache[rule.kpi_id] = latest_value
|
||||
|
||||
if not latest_value or latest_value.actual_value is None:
|
||||
continue
|
||||
|
||||
value = latest_value.actual_value
|
||||
period = latest_value.period
|
||||
params = rule.params or {}
|
||||
|
||||
alert_level = None
|
||||
alert_message = None
|
||||
|
||||
if rule.rule_type == "static":
|
||||
alert_level, alert_message = _check_static(value, params, kpi)
|
||||
elif rule.rule_type == "dynamic":
|
||||
alert_level, alert_message = _check_dynamic(kpi.id, value, params, db)
|
||||
elif rule.rule_type == "trend_up":
|
||||
alert_level, alert_message = _check_trend(kpi.id, value, "up", params, db)
|
||||
elif rule.rule_type == "trend_down":
|
||||
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
|
||||
|
||||
if alert_level and alert_level != "green":
|
||||
# 检查是否已有相同预警
|
||||
existing_alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == rule.kpi_id,
|
||||
KPIAlert.kpi_value_id == latest_value.id,
|
||||
KPIAlert.alert_level == alert_level,
|
||||
KPIAlert.alert_message == alert_message,
|
||||
KPIAlert.status == "pending",
|
||||
).first()
|
||||
if not existing_alert:
|
||||
alert = KPIAlert(
|
||||
kpi_id=rule.kpi_id,
|
||||
kpi_value_id=latest_value.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_message,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
alerts_generated += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"预警检查失败: rule_id={rule.id}, error={e}")
|
||||
continue
|
||||
|
||||
db.commit()
|
||||
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated}
|
||||
|
||||
|
||||
@router.get("/dynamic-thresholds")
|
||||
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
"""获取动态阈值缓存"""
|
||||
query = db.query(DynamicThresholdCache)
|
||||
if kpi_id:
|
||||
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
|
||||
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
|
||||
return {"data": [{c.name: getattr(c, c.name) for c in DynamicThresholdCache.__table__.columns} for c in cache]}
|
||||
|
||||
|
||||
@router.post("/calculate-dynamic")
|
||||
def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
||||
"""计算所有KPI的动态阈值(基于近3个月历史均值±标准差)"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
computed = 0
|
||||
for kpi in kpis:
|
||||
# 取近3个月的历史值(不含当月)
|
||||
from sqlalchemy import text as sa_text, func as sa_func
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
KPIValue.data_status.in_(["verified", "estimated"]),
|
||||
KPIValue.period < current_period,
|
||||
).order_by(KPIValue.period.desc()).limit(3).all()
|
||||
|
||||
if len(values) < 2:
|
||||
continue
|
||||
|
||||
vals = [v.actual_value for v in values if v.actual_value is not None]
|
||||
if len(vals) < 2:
|
||||
continue
|
||||
|
||||
mean_val = sum(vals) / len(vals)
|
||||
if len(vals) > 1:
|
||||
variance = sum((v - mean_val) ** 2 for v in vals) / len(vals)
|
||||
stddev = variance ** 0.5
|
||||
else:
|
||||
stddev = mean_val * 0.1 # 仅1个值时的合理估算
|
||||
|
||||
# 生成动态阈值(±1标准差)
|
||||
dynamic_green = f">={mean_val + stddev:.2f}"
|
||||
dynamic_yellow = f">={mean_val:.2f}"
|
||||
dynamic_red = f"<{mean_val:.2f}"
|
||||
|
||||
# 检查是否已有缓存
|
||||
existing = db.query(DynamicThresholdCache).filter(
|
||||
DynamicThresholdCache.kpi_id == kpi.id,
|
||||
DynamicThresholdCache.period == current_period,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.mean_value = mean_val
|
||||
existing.stddev_value = stddev
|
||||
existing.dynamic_green = dynamic_green
|
||||
existing.dynamic_yellow = dynamic_yellow
|
||||
existing.dynamic_red = dynamic_red
|
||||
else:
|
||||
cache = DynamicThresholdCache(
|
||||
kpi_id=kpi.id,
|
||||
period=current_period,
|
||||
mean_value=mean_val,
|
||||
stddev_value=stddev,
|
||||
dynamic_green=dynamic_green,
|
||||
dynamic_yellow=dynamic_yellow,
|
||||
dynamic_red=dynamic_red,
|
||||
)
|
||||
db.add(cache)
|
||||
computed += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"动态阈值计算完成: {computed}个KPI", "computed": computed}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 检查引擎
|
||||
# ============================================================
|
||||
|
||||
def _check_static(value: float, params: dict, kpi) -> tuple:
|
||||
"""静态阈值检查"""
|
||||
green = params.get("green")
|
||||
yellow = params.get("yellow")
|
||||
red = params.get("red")
|
||||
|
||||
# 从KPI定义获取阈值
|
||||
if not green and not yellow and not red:
|
||||
green = kpi.threshold_green
|
||||
yellow = kpi.threshold_yellow
|
||||
red = kpi.threshold_red
|
||||
|
||||
if _eval_threshold(value, green):
|
||||
return ("green", f"[静态] {kpi.kpi_name}={value}, 绿灯{green}")
|
||||
elif _eval_threshold(value, yellow):
|
||||
return ("yellow", f"[静态] {kpi.kpi_name}={value}, 黄灯{yellow}")
|
||||
elif red and _eval_threshold(value, red, invert=True):
|
||||
return ("red", f"[静态] {kpi.kpi_name}={value}, 红灯{red}")
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _check_dynamic(kpi_id: int, value: float, params: dict, db: Session) -> tuple:
|
||||
"""动态阈值检查 — 基于历史均值±标准差"""
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
cache = db.query(DynamicThresholdCache).filter(
|
||||
DynamicThresholdCache.kpi_id == kpi_id,
|
||||
DynamicThresholdCache.period == current_period,
|
||||
).first()
|
||||
|
||||
if not cache:
|
||||
return (None, None)
|
||||
|
||||
sensitivity = params.get("sensitivity", 1.0)
|
||||
mean_val = cache.mean_value or 0
|
||||
stddev_val = (cache.stddev_value or 0) * sensitivity
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi_name = kpi.kpi_name if kpi else f"KPI#{kpi_id}"
|
||||
|
||||
if value >= mean_val + stddev_val:
|
||||
return ("green", f"[动态] {kpi_name}={value}, 均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
|
||||
elif value >= mean_val:
|
||||
return ("yellow", f"[动态] {kpi_name}={value}, 均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
|
||||
else:
|
||||
return ("red", f"[动态] {kpi_name}={value}, 低于均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
|
||||
|
||||
|
||||
def _check_trend(kpi_id: int, value: float, direction: str, params: dict, db: Session) -> tuple:
|
||||
"""趋势检查 — 环比变化"""
|
||||
threshold_pct = params.get("threshold_pct", 10)
|
||||
|
||||
# 获取上月值
|
||||
prev_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).offset(1).limit(1).first()
|
||||
|
||||
if not prev_value or not prev_value.actual_value or prev_value.actual_value == 0:
|
||||
return (None, None)
|
||||
|
||||
change_pct = round((value - prev_value.actual_value) / abs(prev_value.actual_value) * 100, 2)
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi_name = kpi.kpi_name if kpi else f"KPI#{kpi_id}"
|
||||
|
||||
if direction == "up" and change_pct > threshold_pct:
|
||||
level = "yellow" if change_pct < threshold_pct * 2 else "red"
|
||||
return (level, f"[趋势↑] {kpi_name}环比上升{change_pct}%(阈值>{threshold_pct}%), 当前={value}, 上月={prev_value.actual_value}")
|
||||
elif direction == "down" and change_pct < -threshold_pct:
|
||||
level = "yellow" if abs(change_pct) < threshold_pct * 2 else "red"
|
||||
return (level, f"[趋势↓] {kpi_name}环比下降{abs(change_pct)}%(阈值>{threshold_pct}%), 当前={value}, 上月={prev_value.actual_value}")
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> bool:
|
||||
"""评估阈值: '>=90', '<80', '>5', '<=2' 等"""
|
||||
if not threshold_str:
|
||||
return False
|
||||
threshold_str = str(threshold_str).strip()
|
||||
|
||||
try:
|
||||
if threshold_str.startswith(">="):
|
||||
limit = float(threshold_str[2:])
|
||||
return value >= limit if not invert else value >= limit
|
||||
elif threshold_str.startswith("<="):
|
||||
limit = float(threshold_str[2:])
|
||||
return value <= limit if not invert else value <= limit
|
||||
elif threshold_str.startswith(">"):
|
||||
limit = float(threshold_str[1:])
|
||||
return value > limit if not invert else value > limit
|
||||
elif threshold_str.startswith("<"):
|
||||
limit = float(threshold_str[1:])
|
||||
return value < limit if not invert else value < limit
|
||||
else:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
@@ -4,6 +4,9 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIAlert, OperationLog
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.alerts")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
@@ -28,3 +31,216 @@ def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
from datetime import datetime; alert.resolved_at = datetime.now()
|
||||
db.commit()
|
||||
return {"message": "已处理", "assignee": alert.assignee}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 功能4: 风险矩阵热力图 (CMA P2 - ERM框架、风险识别四象限)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
RISK_MATRIX_DATA = {
|
||||
"hanke": {
|
||||
"entity_name": "陕西酣客(白酒经销)",
|
||||
"quadrants": [
|
||||
{
|
||||
"impact": "high",
|
||||
"probability": "high",
|
||||
"label": "高影响×高概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_001",
|
||||
"name": "流动性风险",
|
||||
"impact_label": "高",
|
||||
"probability_label": "高",
|
||||
"detail": "现金2.2万 vs 短债350万 → 断流风险",
|
||||
"impact_value": 90,
|
||||
"probability_value": 85,
|
||||
"type": "red",
|
||||
"measures": ["催收大额应收", "协商短期借款续贷"],
|
||||
"responsible": "任富海",
|
||||
"deadline": "7月底",
|
||||
},
|
||||
{
|
||||
"id": "risk_002",
|
||||
"name": "合规风险",
|
||||
"impact_label": "高",
|
||||
"probability_label": "高",
|
||||
"detail": "欠税426万 · 折旧违规",
|
||||
"impact_value": 95,
|
||||
"probability_value": 80,
|
||||
"type": "red",
|
||||
"measures": ["补缴欠税计划", "重新梳理折旧政策"],
|
||||
"responsible": "任富海",
|
||||
"deadline": "8月底",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "high",
|
||||
"probability": "medium",
|
||||
"label": "高影响×中概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_003",
|
||||
"name": "政策风险",
|
||||
"impact_label": "高",
|
||||
"probability_label": "中",
|
||||
"detail": "白酒消费税调整可能导致成本上升15-20%",
|
||||
"impact_value": 85,
|
||||
"probability_value": 50,
|
||||
"type": "orange",
|
||||
"measures": ["关注政策动向", "预留税务缓冲资金"],
|
||||
"responsible": "财务部",
|
||||
"deadline": "持续关注",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "medium",
|
||||
"probability": "high",
|
||||
"label": "中影响×高概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_004",
|
||||
"name": "运营风险",
|
||||
"impact_label": "中",
|
||||
"probability_label": "高",
|
||||
"detail": "Model C 成本模型未落地,成本核算偏差",
|
||||
"impact_value": 60,
|
||||
"probability_value": 80,
|
||||
"type": "yellow",
|
||||
"measures": ["推动Model C落地", "建立成本标准化流程"],
|
||||
"responsible": "财务部",
|
||||
"deadline": "8月中",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "medium",
|
||||
"probability": "medium",
|
||||
"label": "中影响×中概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_005",
|
||||
"name": "战略风险",
|
||||
"impact_label": "中",
|
||||
"probability_label": "中",
|
||||
"detail": "酒类零交易,新业务方向不确定",
|
||||
"impact_value": 55,
|
||||
"probability_value": 55,
|
||||
"type": "yellow",
|
||||
"measures": ["制定新业务评估框架", "定期战略复盘"],
|
||||
"responsible": "管理层",
|
||||
"deadline": "9月底",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "low",
|
||||
"probability": "low",
|
||||
"label": "低影响×低概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_006",
|
||||
"name": "市场风险",
|
||||
"impact_label": "低",
|
||||
"probability_label": "低",
|
||||
"detail": "行业需求波动,但酣客已基本退出市场",
|
||||
"impact_value": 25,
|
||||
"probability_value": 20,
|
||||
"type": "green",
|
||||
"measures": ["定期监控行业数据"],
|
||||
"responsible": "业务部",
|
||||
"deadline": "每季度",
|
||||
},
|
||||
{
|
||||
"id": "risk_007",
|
||||
"name": "人员风险",
|
||||
"impact_label": "低",
|
||||
"probability_label": "低",
|
||||
"detail": "核心团队稳定,短期内无流失风险",
|
||||
"impact_value": 20,
|
||||
"probability_value": 15,
|
||||
"type": "green",
|
||||
"measures": ["保持团队激励", "关键岗位备份"],
|
||||
"responsible": "人事部",
|
||||
"deadline": "持续",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"bohai": {
|
||||
"entity_name": "陕西博海科技(IT服务)",
|
||||
"quadrants": [
|
||||
{
|
||||
"impact": "high",
|
||||
"probability": "medium",
|
||||
"label": "高影响×中概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_b_001",
|
||||
"name": "现金流风险",
|
||||
"impact_label": "高",
|
||||
"probability_label": "中",
|
||||
"detail": "应收账款账期延长,现金流紧张",
|
||||
"impact_value": 85,
|
||||
"probability_value": 55,
|
||||
"type": "orange",
|
||||
"measures": ["加快应收催收", "建立信用管理制度"],
|
||||
"responsible": "任富海",
|
||||
"deadline": "7月底",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "medium",
|
||||
"probability": "high",
|
||||
"label": "中影响×高概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_b_002",
|
||||
"name": "项目交付风险",
|
||||
"impact_label": "中",
|
||||
"probability_label": "高",
|
||||
"detail": "多个项目并行,交付压力大",
|
||||
"impact_value": 65,
|
||||
"probability_value": 75,
|
||||
"type": "yellow",
|
||||
"measures": ["优化项目排期", "增加外包资源"],
|
||||
"responsible": "项目部",
|
||||
"deadline": "持续",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "low",
|
||||
"probability": "medium",
|
||||
"label": "低影响×中概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_b_003",
|
||||
"name": "技术迭代风险",
|
||||
"impact_label": "低",
|
||||
"probability_label": "中",
|
||||
"detail": "新技术跟踪不及时,可能落后",
|
||||
"impact_value": 30,
|
||||
"probability_value": 45,
|
||||
"type": "green",
|
||||
"measures": ["定期技术培训", "技术栈评估"],
|
||||
"responsible": "技术部",
|
||||
"deadline": "每季度",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/risk-matrix")
|
||||
def get_risk_matrix(entity: str = Query("hanke", description="hanke/bohai")):
|
||||
"""风险矩阵热力图数据 - CMA P2 ERM四象限"""
|
||||
data = RISK_MATRIX_DATA.get(entity)
|
||||
if not data:
|
||||
data = RISK_MATRIX_DATA["hanke"]
|
||||
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""BI报表集成 — 任务4
|
||||
分析模式 + 预置报表模板 + 报表保存/分享 + 导出
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
|
||||
|
||||
logger = logging.getLogger("bi-reports")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bi-reports", tags=["BI报表"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 预置模板
|
||||
# ============================================================
|
||||
|
||||
PRESET_TEMPLATES = [
|
||||
{
|
||||
"name": "四层指标总览",
|
||||
"report_type": "overview",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "展示财务/客户/流程/学习四层维度的关键KPI概览",
|
||||
"layout": "grid",
|
||||
"dimensions": ["finance", "customer", "process", "learning"],
|
||||
"metrics": ["count", "avg_value", "alert_count"],
|
||||
"chart_type": "gauge_card",
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "同比趋势分析",
|
||||
"report_type": "trend",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "各KPI近12个月趋势对比",
|
||||
"period": "monthly",
|
||||
"window_months": 12,
|
||||
"chart_type": "line",
|
||||
"show_compare": True,
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "实际vs预算对比",
|
||||
"report_type": "comparison",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "KPI实际值 vs 目标值的偏差分析",
|
||||
"chart_type": "bar",
|
||||
"show_deviation": True,
|
||||
"group_by": "dimension",
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "TOP N异常KPI",
|
||||
"report_type": "topn",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "排名前N的异常KPI(红/黄灯)",
|
||||
"top_n": 10,
|
||||
"sort_by": "deviation",
|
||||
"chart_type": "horizontal_bar",
|
||||
"show_threshold": True,
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "因果链推演",
|
||||
"report_type": "causality",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "基于KPI因果链的推演分析",
|
||||
"chart_type": "force_graph",
|
||||
"max_depth": 3,
|
||||
"min_strength": 0.3,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_report_templates(db: Session = Depends(get_db)):
|
||||
"""获取BI报表模板"""
|
||||
templates = db.query(BiReportTemplate).order_by(BiReportTemplate.id).all()
|
||||
return {"data": [{c.name: getattr(t, c.name) for c in BiReportTemplate.__table__.columns} for t in templates]}
|
||||
|
||||
|
||||
@router.post("/templates/seed")
|
||||
def seed_report_templates(db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""初始化预置模板(仅首次运行)"""
|
||||
created = 0
|
||||
for tpl in PRESET_TEMPLATES:
|
||||
existing = db.query(BiReportTemplate).filter(
|
||||
BiReportTemplate.name == tpl["name"],
|
||||
BiReportTemplate.is_system == 1,
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
t = BiReportTemplate(**tpl)
|
||||
db.add(t)
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"新增{created}个预置模板", "created": created}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
t = db.query(BiReportTemplate).filter(BiReportTemplate.id == template_id).first()
|
||||
if t:
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 用户报表
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_reports(db: Session = Depends(get_db)):
|
||||
"""获取用户保存的报表"""
|
||||
reports = db.query(BiReport).order_by(BiReport.updated_at.desc()).all()
|
||||
result = []
|
||||
for r in reports:
|
||||
d = {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
d["created_by_name"] = f"用户{r.created_by}" if r.created_by else "系统"
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/{report_id}")
|
||||
def get_report(report_id: int, db: Session = Depends(get_db)):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报表不存在")
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""保存BI报表"""
|
||||
r = BiReport(
|
||||
template_id=data.get("template_id"),
|
||||
name=data.get("name", "未命名报表"),
|
||||
config=data.get("config", {}),
|
||||
chart_type=data.get("chart_type", "auto"),
|
||||
is_shared=data.get("is_shared", 0),
|
||||
created_by=1,
|
||||
)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
db.add(OperationLog(action="create", target_type="bi_report", detail=f"创建报表: {r.name}"))
|
||||
db.commit()
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.put("/{report_id}")
|
||||
def update_report(report_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报表不存在")
|
||||
for field in ("name", "config", "chart_type", "is_shared"):
|
||||
if field in data:
|
||||
setattr(r, field, data[field])
|
||||
db.commit()
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.delete("/{report_id}")
|
||||
def delete_report(report_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if r:
|
||||
db.delete(r)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 分析引擎
|
||||
# ============================================================
|
||||
|
||||
@router.post("/analyze")
|
||||
def analyze_data(data: dict, db: Session = Depends(get_db)):
|
||||
"""分析引擎:按配置返回报表数据
|
||||
Body: {
|
||||
config: { dimensions, kpi_ids, period_start, period_end, group_by, metrics, ... },
|
||||
chart_type: str
|
||||
}
|
||||
"""
|
||||
config = data.get("config", {})
|
||||
chart_type = data.get("chart_type", "auto")
|
||||
|
||||
kpi_ids = config.get("kpi_ids", [])
|
||||
dimensions = config.get("dimensions", [])
|
||||
period_start = config.get("period_start")
|
||||
period_end = config.get("period_end")
|
||||
group_by = config.get("group_by")
|
||||
top_n = config.get("top_n", 10)
|
||||
|
||||
# 构建KPI查询
|
||||
kpi_query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
if kpi_ids:
|
||||
kpi_query = kpi_query.filter(KPIDefinition.id.in_(kpi_ids))
|
||||
if dimensions:
|
||||
kpi_query = kpi_query.filter(KPIDefinition.dimension.in_(dimensions))
|
||||
kpis = kpi_query.order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
# 获取每个KPI的最新值
|
||||
rows = []
|
||||
for kpi in kpis:
|
||||
val_query = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period_start:
|
||||
val_query = val_query.filter(KPIValue.period >= period_start)
|
||||
if period_end:
|
||||
val_query = val_query.filter(KPIValue.period <= period_end)
|
||||
|
||||
latest = val_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
# 获取趋势数据
|
||||
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
|
||||
|
||||
rows.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"unit": kpi.unit,
|
||||
"target_value": kpi.target_value,
|
||||
"threshold_green": kpi.threshold_green,
|
||||
"threshold_yellow": kpi.threshold_yellow,
|
||||
"threshold_red": kpi.threshold_red,
|
||||
"current_value": latest.actual_value if latest else None,
|
||||
"current_period": latest.period if latest else None,
|
||||
"trend": [{"period": v.period, "value": v.actual_value} for v in trend_values],
|
||||
})
|
||||
|
||||
# 统计汇总
|
||||
summary = {
|
||||
"total_kpis": len(rows),
|
||||
"dimensions": {},
|
||||
}
|
||||
for r in rows:
|
||||
dim = r["dimension"]
|
||||
if dim not in summary["dimensions"]:
|
||||
summary["dimensions"][dim] = {"count": 0, "values": []}
|
||||
summary["dimensions"][dim]["count"] += 1
|
||||
if r["current_value"] is not None:
|
||||
summary["dimensions"][dim]["values"].append(r["current_value"])
|
||||
|
||||
for dim, info in summary["dimensions"].items():
|
||||
vals = info["values"]
|
||||
if vals:
|
||||
info["avg"] = round(sum(vals) / len(vals), 2)
|
||||
info["min"] = min(vals)
|
||||
info["max"] = max(vals)
|
||||
del info["values"]
|
||||
|
||||
return {
|
||||
"config": config,
|
||||
"chart_type": chart_type,
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 导出功能(CSV格式,前端可转为Excel/PDF)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/export")
|
||||
def export_report(data: dict, db: Session = Depends(get_db)):
|
||||
"""导出报表数据 (CSV)"""
|
||||
config = data.get("config", {})
|
||||
format_type = data.get("format", "csv")
|
||||
|
||||
# 复用analyze获取数据
|
||||
from app.database import get_session_local
|
||||
temp_db = get_session_local()()
|
||||
try:
|
||||
result = analyze_data(data, temp_db)
|
||||
finally:
|
||||
temp_db.close()
|
||||
|
||||
rows = result.get("rows", [])
|
||||
if not rows:
|
||||
raise HTTPException(400, "没有可导出的数据")
|
||||
|
||||
# 生成CSV
|
||||
import csv, io
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["KPI编码", "KPI名称", "维度", "类别", "当前值", "期间", "目标值", "单位"])
|
||||
for r in rows:
|
||||
writer.writerow([
|
||||
r["kpi_code"], r["kpi_name"], r["dimension"], r["category"],
|
||||
r["current_value"], r["current_period"], r["target_value"], r["unit"],
|
||||
])
|
||||
|
||||
csv_content = output.getvalue()
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename=bi_report_{datetime.now().strftime('%Y%m%d')}.csv"},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -334,3 +334,103 @@ def get_deviation_report(
|
||||
"summary": summary,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 功能6: 预算方法三选一向导 (CMA P1 - 增量/零基/弹性)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/method-comparison")
|
||||
def budget_method_comparison(data: dict):
|
||||
"""
|
||||
预算方法三选一对比计算
|
||||
接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... }
|
||||
返回三种方法的计算结果
|
||||
"""
|
||||
entity = data.get("entity", "hanke")
|
||||
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万)
|
||||
current_revenue = data.get("current_revenue", 122) # 当前收入(万)
|
||||
fixed_costs = data.get("fixed_costs", {
|
||||
"rent": 15, # 房租(万)
|
||||
"labor": 40, # 人工(万)
|
||||
"entertainment": 16, # 招待费(万)
|
||||
"misc": 12, # 杂项(万)
|
||||
})
|
||||
variable_cost_rate = data.get("variable_cost_rate", 0.4862) # 变动成本率
|
||||
|
||||
# 1. 增量预算: 基于上月统一调整
|
||||
increment_rate = data.get("increment_rate", 0.05) # 5%增幅
|
||||
incremental_result = round(last_month_budget * (1 + increment_rate), 1)
|
||||
incremental_detail = f"上月{last_month_budget}万 × (1+{increment_rate*100:.0f}%) = {incremental_result}万"
|
||||
|
||||
# 2. 零基预算: 每项从零论证
|
||||
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
|
||||
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
|
||||
zbb_total = round(
|
||||
fixed_costs.get("rent", 15)
|
||||
+ fixed_costs.get("labor", 40)
|
||||
+ zbb_entertainment
|
||||
+ zbb_misc,
|
||||
1,
|
||||
)
|
||||
zbb_savings = round(last_month_budget - zbb_total, 1)
|
||||
zbb_detail = (
|
||||
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
|
||||
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
|
||||
f"={zbb_total}万 ← 省{zbb_savings}万"
|
||||
)
|
||||
|
||||
# 3. 弹性预算: 根据收入水平动态调整
|
||||
flexible_fixed = round(fixed_costs.get("rent", 15) + fixed_costs.get("labor", 40) * 0.5, 1)
|
||||
flexible_variable = round(current_revenue * variable_cost_rate * 0.4, 1)
|
||||
flexible_total = round(flexible_fixed + flexible_variable, 1)
|
||||
flexible_variance = round(last_month_budget - flexible_total, 1)
|
||||
flex_detail = (
|
||||
f"收入{current_revenue}万 → 对应费用预算 = {flexible_total}万"
|
||||
f"(固定部分{flexible_fixed}万+变动部分{flexible_variable}万)"
|
||||
f",实际{last_month_budget}万 → 差异{flexible_variance}万 → {'效率问题' if flexible_variance > 0 else '节省'}"
|
||||
)
|
||||
|
||||
# 推荐方法
|
||||
recommended = "zero_based"
|
||||
|
||||
return {
|
||||
"entity": entity,
|
||||
"entity_name": "陕西酣客(白酒经销)" if entity == "hanke" else "陕西博海科技(IT服务)",
|
||||
"methods": [
|
||||
{
|
||||
"id": "incremental",
|
||||
"name": "增量预算",
|
||||
"name_en": "Incremental Budgeting",
|
||||
"result_value": incremental_result,
|
||||
"detail": incremental_detail,
|
||||
"pros": "简单快速",
|
||||
"cons": "浪费持续",
|
||||
"is_recommended": False,
|
||||
},
|
||||
{
|
||||
"id": "zero_based",
|
||||
"name": "零基预算",
|
||||
"name_en": "Zero-Based Budgeting (ZBB)",
|
||||
"result_value": zbb_total,
|
||||
"savings": zbb_savings,
|
||||
"detail": zbb_detail,
|
||||
"pros": "最合理",
|
||||
"cons": "耗时",
|
||||
"is_recommended": True,
|
||||
},
|
||||
{
|
||||
"id": "flexible",
|
||||
"name": "弹性预算",
|
||||
"name_en": "Flexible Budgeting",
|
||||
"result_value": flexible_total,
|
||||
"variance": flexible_variance,
|
||||
"detail": flex_detail,
|
||||
"pros": "动态响应",
|
||||
"cons": "需要详细分类",
|
||||
"is_recommended": False,
|
||||
},
|
||||
],
|
||||
"recommended": recommended,
|
||||
"recommended_name": "零基预算",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -202,6 +202,68 @@ def cost_breakdown(product_code: str = Query(...),
|
||||
return get_cost_breakdown(product_code, period)
|
||||
|
||||
|
||||
@router.get("/comparison")
|
||||
def cost_method_comparison(entity: str = Query("hanke")):
|
||||
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)"""
|
||||
if entity == "hanke":
|
||||
gross_revenue = 713 # 万
|
||||
channel_rebate_rate = 0.828
|
||||
net_revenue = round(gross_revenue * (1 - channel_rebate_rate), 2)
|
||||
weighted_cost_rate = 0.4862
|
||||
book_cost = 717 # 万
|
||||
total_expenses = 546 # 万
|
||||
non_value_added = 26 # 万
|
||||
|
||||
traditional = {
|
||||
"revenue": gross_revenue,
|
||||
"cost": book_cost,
|
||||
"gross_profit": round(gross_revenue - book_cost, 2),
|
||||
"gross_margin": round((gross_revenue - book_cost) / gross_revenue * 100, 2),
|
||||
"expenses": total_expenses,
|
||||
"net_profit": round(gross_revenue - book_cost - total_expenses, 2),
|
||||
}
|
||||
|
||||
var_cost = round(net_revenue * weighted_cost_rate, 2)
|
||||
variable = {
|
||||
"revenue": net_revenue,
|
||||
"cost": var_cost,
|
||||
"gross_profit": round(net_revenue - var_cost, 2),
|
||||
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
|
||||
"expenses": total_expenses,
|
||||
"net_profit": round(net_revenue - var_cost - total_expenses, 2),
|
||||
}
|
||||
|
||||
abc_cost = round(net_revenue * weighted_cost_rate, 2)
|
||||
abc_exp = total_expenses - non_value_added
|
||||
abc = {
|
||||
"revenue": net_revenue,
|
||||
"cost": abc_cost,
|
||||
"gross_profit": round(net_revenue - abc_cost, 2),
|
||||
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
|
||||
"expenses": abc_exp,
|
||||
"net_profit": round(net_revenue - abc_cost - abc_exp, 2),
|
||||
}
|
||||
|
||||
return {
|
||||
"entity": "hanke",
|
||||
"entity_name": "陕西酣客(白酒经销)",
|
||||
"traditional": traditional,
|
||||
"variable": variable,
|
||||
"abc": abc,
|
||||
"insights": [
|
||||
{"method": "传统成本法", "conclusion": "毛利率为负", "decision": "❌ 不赚钱,别卖了", "detail": "未剔除渠补,账面收入虚高"},
|
||||
{"method": "变动成本法", "conclusion": "毛利率18.6%", "decision": "✅ 业务能赚钱→砍费用", "detail": "剔除渠补后净收入122万,成本率48.62%"},
|
||||
{"method": "作业成本法", "conclusion": "识别非增值作业26万", "decision": "✅ 可再省", "detail": "剔除冗余招待费等非增值作业"},
|
||||
],
|
||||
"notes": {
|
||||
"net_revenue": "毛收入713万 × (1-渠补率82.8%) = 净收入122万",
|
||||
"variable_cost": "净收入122万 × 加权成本率48.62% = 成本59.3万",
|
||||
"abc_expenses": "总费用546万 - 非增值作业26万 = 520万",
|
||||
},
|
||||
}
|
||||
return {"error": "不支持的实体"}
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def cost_dashboard(period: Optional[str] = Query(None)):
|
||||
"""成本分析首页—汇总数据"""
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""客户维度KPI看板 API — P0-2"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, User
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.customer")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||||
"""解析时间区间"""
|
||||
today = datetime.now()
|
||||
if period_type == "month":
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "quarter":
|
||||
q = (today.month - 1) // 3
|
||||
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "year":
|
||||
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "custom" and start_date and end_date:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
else:
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
return start, end
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_customer_kpis(
|
||||
period: str = Query("month"),
|
||||
start_date: str = Query(None),
|
||||
end_date: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
|
||||
start, end = parse_period(period, start_date, end_date)
|
||||
period_str = start.strftime("%Y-%m")
|
||||
|
||||
# 只查 customer 维度的 KPI
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
result = []
|
||||
for k in kpis:
|
||||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
||||
|
||||
if period == "month":
|
||||
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
|
||||
elif period == "quarter":
|
||||
q_month = (datetime.now().month - 1) // 3
|
||||
months = [f"{datetime.now().year}-{m:02d}" for m in range(q_month*3+1, q_month*3+4)]
|
||||
values = base_query.filter(KPIValue.period.in_(months)).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
||||
elif period == "year":
|
||||
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
|
||||
elif period == "custom" and start_date and end_date:
|
||||
periods = []
|
||||
d = start
|
||||
while d <= end:
|
||||
periods.append(d.strftime("%Y-%m"))
|
||||
d += timedelta(days=32)
|
||||
d = d.replace(day=1)
|
||||
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
||||
else:
|
||||
latest = base_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
# 最新预警
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
# 趋势(环比变化率)
|
||||
trend = None
|
||||
achievement_rate = None
|
||||
period_values = []
|
||||
|
||||
if latest and latest.actual_value:
|
||||
prev_period_str = None
|
||||
if period == "month":
|
||||
year_s, month_s = period_str.split("-")
|
||||
y_s, m_s = int(year_s), int(month_s)
|
||||
m_s -= 1
|
||||
if m_s <= 0:
|
||||
m_s += 12
|
||||
y_s -= 1
|
||||
prev_period_str = f"{y_s}-{m_s:02d}"
|
||||
|
||||
if prev_period_str:
|
||||
prev_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.period == prev_period_str,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if prev_val and prev_val.actual_value and prev_val.actual_value > 0:
|
||||
trend = round((latest.actual_value - prev_val.actual_value) / prev_val.actual_value * 100, 2)
|
||||
elif prev_val and prev_val.actual_value and prev_val.actual_value == 0:
|
||||
trend = 100.0 if latest.actual_value > 0 else 0
|
||||
|
||||
# 达成率
|
||||
if latest and latest.actual_value and k.target_value and k.target_value > 0:
|
||||
achievement_rate = round(latest.actual_value / k.target_value * 100, 1)
|
||||
|
||||
# 最近6期趋势数据
|
||||
period_q = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
period_values = [
|
||||
{"period": v.period, "value": v.actual_value}
|
||||
for v in reversed(period_q) if v.actual_value is not None
|
||||
]
|
||||
|
||||
result.append({
|
||||
"id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else None,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
"alert_message": alert.alert_message if alert else None,
|
||||
"frequency": k.frequency,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
"responsible_user": k.responsible_user,
|
||||
"trend": trend,
|
||||
"achievement_rate": achievement_rate,
|
||||
"period_values": period_values,
|
||||
"kpi_name": k.kpi_name,
|
||||
})
|
||||
|
||||
return {"data": result, "period": period, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/trend/{kpi_id}")
|
||||
def get_kpi_trend(
|
||||
kpi_id: int,
|
||||
months: int = Query(12, ge=3, le=24),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取单个KPI的历史趋势数据"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 获取最近N期数据
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
).order_by(KPIValue.period.desc()).limit(months).all()
|
||||
|
||||
trend_data = [
|
||||
{"period": v.period, "value": v.actual_value}
|
||||
for v in reversed(values) if v.actual_value is not None
|
||||
]
|
||||
|
||||
# 计算预警水平和触发时间
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi_id,
|
||||
).order_by(KPIAlert.created_at.desc()).limit(10).all()
|
||||
|
||||
alert_logs = [
|
||||
{
|
||||
"level": a.alert_level,
|
||||
"message": a.alert_message,
|
||||
"time": a.created_at.isoformat() if a.created_at else None,
|
||||
"status": a.status,
|
||||
}
|
||||
for a in alerts
|
||||
]
|
||||
|
||||
return {
|
||||
"kpi": {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"target_value": kpi.target_value,
|
||||
"unit": kpi.unit,
|
||||
"threshold_green": kpi.threshold_green,
|
||||
"threshold_yellow": kpi.threshold_yellow,
|
||||
"threshold_red": kpi.threshold_red,
|
||||
},
|
||||
"trend_data": trend_data,
|
||||
"alerts": alert_logs,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def get_customer_summary(
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""客户维度概要统计"""
|
||||
total = db.query(func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).scalar() or 0
|
||||
|
||||
# 预警统计
|
||||
pending_alerts = db.query(func.count(KPIAlert.id)).filter(
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.kpi_id.in_(
|
||||
db.query(KPIDefinition.id).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
)
|
||||
),
|
||||
).scalar() or 0
|
||||
|
||||
# 二级类别分布
|
||||
cat_stats = db.query(
|
||||
KPIDefinition.category,
|
||||
func.count(KPIDefinition.id),
|
||||
).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).group_by(KPIDefinition.category).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending_alerts": pending_alerts,
|
||||
"category_stats": [{"category": c[0], "count": c[1]} for c in cat_stats],
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
"""驾驶舱 API v2 — 支持时间区间"""
|
||||
from fastapi import APIRouter, Depends, Query, Request, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, User
|
||||
from app.utils.cache import get as cache_get, set as cache_set
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.dashboard")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||||
"""解析时间区间"""
|
||||
today = datetime.now()
|
||||
if period_type == "month":
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "quarter":
|
||||
q = (today.month - 1) // 3
|
||||
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "year":
|
||||
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "custom" and start_date and end_date:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
else:
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
return start, end
|
||||
|
||||
def period_prefix(period_type: str):
|
||||
"""生成SQL期间前缀匹配"""
|
||||
if period_type == "month":
|
||||
return datetime.now().strftime("%Y-%m")
|
||||
elif period_type == "quarter":
|
||||
now = datetime.now()
|
||||
q = (now.month - 1) // 3
|
||||
months = [f"{now.year}-{m:02d}" for m in range(q*3+1, q*3+4)]
|
||||
return months
|
||||
elif period_type == "year":
|
||||
return str(datetime.now().year)
|
||||
return None
|
||||
|
||||
@router.get("/summary")
|
||||
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db)):
|
||||
cache_key = f"summary:{role}:{period}"
|
||||
cached = cache_get("dashboard", cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
kpi_total = db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar()
|
||||
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
|
||||
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active").group_by(KPIDefinition.dimension).all()
|
||||
|
||||
# 读取最近一次同步状态(从日志文件最后一行)
|
||||
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
|
||||
try:
|
||||
with open("/var/log/cma-daily-sync.log", "r") as f:
|
||||
lines = f.readlines()
|
||||
# 从最后往前找包含 "完成" 或 "失败" 的行
|
||||
for line in reversed(lines[-50:]):
|
||||
if "全部完成" in line:
|
||||
sync_status["status"] = "success"
|
||||
sync_status["last_sync"] = line.strip()
|
||||
break
|
||||
elif "失败" in line or "ERROR" in line:
|
||||
sync_status["status"] = "failed"
|
||||
sync_status["last_sync"] = line.strip()
|
||||
break
|
||||
else:
|
||||
# 没找到完成/失败标记,取最后一行
|
||||
sync_status["last_sync"] = lines[-1].strip() if lines else None
|
||||
except Exception as e:
|
||||
sync_status["detail"] = str(e)
|
||||
|
||||
result = {
|
||||
"kpi_total": kpi_total or 0, "alert_count": alert_count or 0,
|
||||
"dimension_stats": [{"dimension": d[0], "count": d[1]} for d in dims],
|
||||
"sync_status": sync_status,
|
||||
}
|
||||
cache_set("dashboard", cache_key, result, ttl_seconds=30)
|
||||
return result
|
||||
|
||||
@router.get("/kpis")
|
||||
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
||||
start_date: str = Query(None), end_date: str = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
start, end = parse_period(period, start_date, end_date)
|
||||
period_str = start.strftime("%Y-%m")
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
result = []
|
||||
|
||||
for k in kpis:
|
||||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
||||
|
||||
if period == "month":
|
||||
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
|
||||
elif period == "quarter":
|
||||
months = period_prefix("quarter")
|
||||
values = base_query.filter(KPIValue.period.in_(months)).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
||||
elif period == "year":
|
||||
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
|
||||
elif period == "custom" and start_date and end_date:
|
||||
periods = []
|
||||
d = start
|
||||
while d <= end:
|
||||
periods.append(d.strftime("%Y-%m"))
|
||||
d += timedelta(days=32)
|
||||
d = d.replace(day=1)
|
||||
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
||||
else:
|
||||
latest = base_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
result.append({
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else None,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
"alert_message": alert.alert_message if alert else None,
|
||||
"frequency": k.frequency,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
})
|
||||
|
||||
return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
|
||||
|
||||
|
||||
@router.get("/my-kpis")
|
||||
def get_my_kpis(
|
||||
current_user: User = Depends(require_auth),
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户负责的KPI
|
||||
- business角色:只看自己负责的KPI
|
||||
- 其他角色:看所有有预警的KPI
|
||||
"""
|
||||
role = current_user.role
|
||||
username = current_user.username
|
||||
name = current_user.name
|
||||
period_str = datetime.now().strftime("%Y-%m")
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
result = []
|
||||
|
||||
for k in kpis:
|
||||
# business角色筛选
|
||||
if role == "business":
|
||||
responsible = (k.responsible_user or "").strip()
|
||||
if responsible and responsible != username and responsible != name:
|
||||
continue
|
||||
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.period == period_str,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
trend_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
||||
|
||||
result.append({
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else period_str,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
"alert_message": alert.alert_message if alert else None,
|
||||
"alert_id": alert.id if alert else None,
|
||||
"frequency": k.frequency,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
"responsible_user": k.responsible_user,
|
||||
"trend": trend,
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
"threshold_red": k.threshold_red,
|
||||
})
|
||||
|
||||
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
|
||||
|
||||
|
||||
@router.get("/finance-analysis")
|
||||
def get_finance_analysis(
|
||||
current_user: User = Depends(require_auth),
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""财务工作台分析数据"""
|
||||
period_str = datetime.now().strftime("%Y-%m")
|
||||
|
||||
finance_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "finance",
|
||||
).all()
|
||||
|
||||
kpi_data = []
|
||||
for k in finance_kpis:
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.period == period_str,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
trend_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
||||
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
kpi_data.append({
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"unit": k.unit, "target_value": k.target_value,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
"threshold_red": k.threshold_red,
|
||||
"trend": trend,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
"frequency": k.frequency,
|
||||
})
|
||||
|
||||
total_sales = next((k for k in kpi_data if k["kpi_code"] == "SALES_TOTAL"), None)
|
||||
gross_profit = next((k for k in kpi_data if k["kpi_code"] == "SALES_PROFIT_RATE"), None)
|
||||
cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None)
|
||||
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
|
||||
|
||||
return {
|
||||
"period": period_str,
|
||||
"kpis": kpi_data,
|
||||
"summary": {
|
||||
"total_sales": total_sales["actual_value"] if total_sales else None,
|
||||
"gross_profit_rate": gross_profit["actual_value"] if gross_profit else None,
|
||||
"cost_control_rate": cost_control["actual_value"] if cost_control else None,
|
||||
"receivable_turnover": receivable["actual_value"] if receivable else None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/predict")
|
||||
def predict_kpis(db: Session = Depends(get_db)):
|
||||
"""基于历史趋势预测下月KPI值(简单线性回归)"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
period_str = datetime.now().strftime("%Y-%m")
|
||||
next_month = int(period_str[5:7]) + 1
|
||||
next_year = int(period_str[:4])
|
||||
if next_month > 12:
|
||||
next_month = 1
|
||||
next_year += 1
|
||||
next_period = f"{next_year}-{next_month:02d}"
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
predictions = []
|
||||
|
||||
for k in kpis:
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.asc()).all()
|
||||
|
||||
# 需要至少3个数据点才能做预测
|
||||
if len(values) < 3:
|
||||
continue
|
||||
|
||||
# 简单线性回归: y = a + bx
|
||||
points = [(i, v.actual_value) for i, v in enumerate(values) if v.actual_value is not None]
|
||||
if len(points) < 3:
|
||||
continue
|
||||
|
||||
n = len(points)
|
||||
sum_x = sum(p[0] for p in points)
|
||||
sum_y = sum(p[1] for p in points)
|
||||
sum_xy = sum(p[0] * p[1] for p in points)
|
||||
sum_xx = sum(p[0] ** 2 for p in points)
|
||||
|
||||
# 斜率 b = (n*sum_xy - sum_x*sum_y) / (n*sum_xx - sum_x*sum_x)
|
||||
denom = n * sum_xx - sum_x * sum_x
|
||||
if denom == 0:
|
||||
continue
|
||||
b = (n * sum_xy - sum_x * sum_y) / denom
|
||||
a = (sum_y - b * sum_x) / n
|
||||
|
||||
# 预测下个月(x = n,因为最后一个索引是 n-1)
|
||||
predicted_value = a + b * n
|
||||
|
||||
# 检查预测值是否触发阈值
|
||||
alert_level = "none"
|
||||
if k.threshold_red:
|
||||
try:
|
||||
op = k.threshold_red[:2] if len(k.threshold_red) > 1 and k.threshold_red[1] in "=<>" else k.threshold_red[0]
|
||||
val_str = k.threshold_red.replace(op, "").strip()
|
||||
val = float(val_str)
|
||||
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
|
||||
alert_level = "red"
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if alert_level == "none" and k.threshold_yellow:
|
||||
try:
|
||||
op = k.threshold_yellow[:2] if len(k.threshold_yellow) > 1 and k.threshold_yellow[1] in "=<>" else k.threshold_yellow[0]
|
||||
val_str = k.threshold_yellow.replace(op, "").strip()
|
||||
val = float(val_str)
|
||||
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
|
||||
alert_level = "yellow"
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
predictions.append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"target_value": k.target_value,
|
||||
"last_value": points[-1][1] if points else None,
|
||||
"predicted_value": round(predicted_value, 2),
|
||||
"predicted_period": next_period,
|
||||
"alert_level": alert_level,
|
||||
"trend": "up" if b > 0 else ("down" if b < 0 else "stable"),
|
||||
"confidence": "high" if len(points) >= 6 else ("medium" if len(points) >= 4 else "low"),
|
||||
"data_points": len(points),
|
||||
})
|
||||
|
||||
return {
|
||||
"current_period": period_str,
|
||||
"next_period": next_period,
|
||||
"predictions": predictions,
|
||||
"kpi_count": len(kpis),
|
||||
"predictable_count": len(predictions),
|
||||
}
|
||||
|
||||
|
||||
# ── 个人工作台 ──────────────────────────────
|
||||
|
||||
|
||||
@router.get("/my-dashboard")
|
||||
def my_dashboard(
|
||||
current_user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
|
||||
username = current_user.username
|
||||
name = current_user.name
|
||||
role = current_user.role
|
||||
|
||||
# 角色 → 维度映射(从已发布战略地图中按角色筛选对应维度的KPI)
|
||||
ROLE_DIMENSIONS = {
|
||||
"ceo": ["finance", "customer", "process", "learning"], # CEO看全部维度
|
||||
"finance": ["finance"], # 财务看财务维度
|
||||
"business": ["customer", "process"], # 业务看客户+流程维度
|
||||
"it": ["process", "learning"], # IT看流程+学习成长
|
||||
}
|
||||
role_dims = ROLE_DIMENSIONS.get(role, ["finance", "customer"])
|
||||
|
||||
# 获取所有已发布战略地图的KPI code集合(dimensions中引用的)
|
||||
from app.models import StrategicMap
|
||||
published_maps = db.query(StrategicMap).filter(StrategicMap.status == "published").all()
|
||||
map_kpi_codes = set()
|
||||
for sm in published_maps:
|
||||
dims = sm.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except Exception:
|
||||
continue
|
||||
for dim in dims:
|
||||
for obj in dim.get("objectives", []):
|
||||
for code in obj.get("kpis", []):
|
||||
map_kpi_codes.add(code)
|
||||
|
||||
# 1. 按角色维度筛选(从已发布地图的KPI中取符合角色维度的)
|
||||
map_kpis = []
|
||||
if map_kpi_codes:
|
||||
map_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code.in_(map_kpi_codes),
|
||||
KPIDefinition.dimension.in_(role_dims),
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
|
||||
# 2. 补充负责的KPI(responsible_user匹配)
|
||||
assigned_kpis = db.query(KPIDefinition).filter(
|
||||
or_(
|
||||
KPIDefinition.responsible_user == username,
|
||||
KPIDefinition.responsible_user == name,
|
||||
),
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
assigned_ids = {k.id for k in assigned_kpis}
|
||||
|
||||
# 去重合并
|
||||
all_kpis = map_kpis + [k for k in assigned_kpis if k.id not in {mk.id for mk in map_kpis}]
|
||||
|
||||
kpi_list = []
|
||||
for k in all_kpis:
|
||||
latest_v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
|
||||
actual = latest_v.actual_value if latest_v else None
|
||||
target = k.target_value
|
||||
level = "gray"
|
||||
if actual is not None and target:
|
||||
ratio = actual / target
|
||||
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
|
||||
|
||||
kpi_list.append({
|
||||
"id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"target_value": target,
|
||||
"actual_value": actual,
|
||||
"unit": k.unit,
|
||||
"level": level,
|
||||
"period": latest_v.period if latest_v else None,
|
||||
})
|
||||
|
||||
# 2. 我的改善行动(assignee匹配)
|
||||
from app.models import ActionPlan
|
||||
my_plans = db.query(ActionPlan).filter(
|
||||
or_(
|
||||
ActionPlan.assignee == username,
|
||||
ActionPlan.assignee == name,
|
||||
)
|
||||
).order_by(ActionPlan.updated_at.desc()).all()
|
||||
|
||||
plan_list = []
|
||||
for p in my_plans:
|
||||
overdue = False
|
||||
if p.due_date and p.status not in ("completed", "cancelled"):
|
||||
overdue = p.due_date < datetime.now()
|
||||
kpi_name = ""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||
if kpi:
|
||||
kpi_name = kpi.kpi_name
|
||||
|
||||
plan_list.append({
|
||||
"id": p.id,
|
||||
"kpi_id": p.kpi_id,
|
||||
"kpi_name": kpi_name,
|
||||
"title": p.title,
|
||||
"assignee": p.assignee,
|
||||
"priority": p.priority,
|
||||
"status": p.status,
|
||||
"progress": p.progress or 0,
|
||||
"due_date": p.due_date.isoformat() if p.due_date else None,
|
||||
"overdue": overdue,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
})
|
||||
|
||||
# 3. 待办提醒
|
||||
reminders = []
|
||||
|
||||
# 逾期行动
|
||||
for p in plan_list:
|
||||
if p["overdue"]:
|
||||
reminders.append({
|
||||
"type": "overdue_plan",
|
||||
"severity": "danger",
|
||||
"message": f"你负责的「{p['title']}」已逾期",
|
||||
"related_id": p["id"],
|
||||
"related_type": "action_plan",
|
||||
})
|
||||
|
||||
# 红色预警KPI
|
||||
for k in kpi_list:
|
||||
if k["level"] == "red":
|
||||
reminders.append({
|
||||
"type": "red_kpi",
|
||||
"severity": "danger",
|
||||
"message": f"你负责的KPI「{k['kpi_name']}」处于红色预警",
|
||||
"related_id": k["id"],
|
||||
"related_type": "kpi",
|
||||
})
|
||||
|
||||
# 黄色预警KPI
|
||||
for k in kpi_list:
|
||||
if k["level"] == "yellow":
|
||||
reminders.append({
|
||||
"type": "yellow_kpi",
|
||||
"severity": "warning",
|
||||
"message": f"你负责的KPI「{k['kpi_name']}」处于黄色预警",
|
||||
"related_id": k["id"],
|
||||
"related_type": "kpi",
|
||||
})
|
||||
|
||||
return {
|
||||
"kpis": kpi_list,
|
||||
"action_plans": plan_list,
|
||||
"reminders": reminders,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/erp-trends")
|
||||
def get_erp_trends(
|
||||
current_user: User = Depends(require_auth),
|
||||
months: int = Query(12, ge=3, le=36),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取ERP关键指标趋势数据(驾驶舱趋势分析用)"""
|
||||
codes = [
|
||||
"F_REVENUE",
|
||||
"F_PROFIT_RATE",
|
||||
"F_NET_PROFIT_RATE",
|
||||
"F_COST_RATIO",
|
||||
"F_CASH_FLOW",
|
||||
"F_AR_TURNOVER",
|
||||
"F_ROE",
|
||||
"F_ASSET_TURNOVER",
|
||||
"F_DEBT_RATIO",
|
||||
"C_CUSTOMER_COUNT",
|
||||
"C_CUSTOMER_SATISFACTION",
|
||||
"C_CUSTOMER_CONCENTRATION",
|
||||
"P_DELIVERY_ON_TIME",
|
||||
"P_DEFECT_RATE",
|
||||
"P_SUPPLY_CYCLE",
|
||||
"L_TRAINING_HOURS",
|
||||
"L_EMPLOYEE_TURNOVER",
|
||||
"L_INNOVATION_COUNT",
|
||||
"L_TECH_COVERAGE",
|
||||
]
|
||||
result = {}
|
||||
|
||||
for code in codes:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if not kpi:
|
||||
continue
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
).order_by(KPIValue.period.desc()).limit(months).all()
|
||||
|
||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(values)]
|
||||
if trend:
|
||||
vals = [v["value"] for v in trend if v["value"] is not None]
|
||||
latest = vals[-1] if vals else 0
|
||||
first = vals[0] if vals else 0
|
||||
if latest > first * 1.05:
|
||||
trend_dir = "up"
|
||||
elif latest < first * 0.95:
|
||||
trend_dir = "down"
|
||||
else:
|
||||
trend_dir = "stable"
|
||||
|
||||
mom_val = vals[-2] if len(vals) >= 2 else None
|
||||
yoy_val = vals[-12] if len(vals) >= 12 else (vals[0] if len(vals) >= 1 else None)
|
||||
|
||||
result[code] = {
|
||||
"name": kpi.kpi_name,
|
||||
"unit": kpi.unit or "",
|
||||
"target": kpi.target_value,
|
||||
"trend": trend,
|
||||
"trend_dir": trend_dir,
|
||||
"latest": latest,
|
||||
"mom": mom_val,
|
||||
"mom_rate": round((latest - mom_val) / abs(mom_val) * 100, 1) if mom_val and mom_val != 0 else None,
|
||||
"yoy": yoy_val,
|
||||
"yoy_rate": round((latest - yoy_val) / abs(yoy_val) * 100, 1) if yoy_val and yoy_val != 0 else None,
|
||||
}
|
||||
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@router.get("/dupont")
|
||||
async def dupont_analysis(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""杜邦分析 — ROE分解
|
||||
ROE = 净利率 × 资产周转率 × 权益乘数
|
||||
"""
|
||||
cache_key = f"dupont:{current_user.role}"
|
||||
cached = cache_get("dashboard", cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 获取底层数据KPI
|
||||
def get_kpi_value(code: str) -> tuple:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if not kpi:
|
||||
return None, None, None
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).first()
|
||||
prev = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).offset(1).first()
|
||||
val = latest.actual_value if latest else None
|
||||
pval = prev.actual_value if prev else None
|
||||
return val, pval, kpi.unit
|
||||
|
||||
# 营收、利润、总资产、净资产
|
||||
revenue, prev_revenue, _ = get_kpi_value("F_REVENUE")
|
||||
# 用营收×净利润率估算净利润(数据库没有净利润绝对值)
|
||||
profit_net = None
|
||||
prev_profit_net = None
|
||||
if revenue:
|
||||
net_profit_rate, prev_npr, _ = get_kpi_value("F_NET_PROFIT_RATE")
|
||||
if net_profit_rate:
|
||||
profit_net = revenue * (net_profit_rate / 100)
|
||||
if prev_revenue and prev_npr:
|
||||
prev_profit_net = prev_revenue * (prev_npr / 100)
|
||||
# 如果还是算不出来,用毛利率做替代估算
|
||||
if profit_net is None and revenue:
|
||||
gross_profit, _, _ = get_kpi_value("F_PROFIT_RATE")
|
||||
profit_net = revenue * (gross_profit / 100) * 0.7 if gross_profit else None # 粗略估算净利润=毛利*0.7
|
||||
|
||||
asset_total, prev_asset, _ = get_kpi_value("F_ASSET_TOTAL")
|
||||
equity_total, prev_equity, _ = get_kpi_value("F_EQUITY_TOTAL")
|
||||
|
||||
# 计算杜邦因子
|
||||
result = {"roe": None, "factors": {}, "raw_data": {}, "history": {}}
|
||||
|
||||
if revenue and profit_net and asset_total and equity_total and all(v > 0 for v in [revenue, asset_total, equity_total]):
|
||||
net_profit_margin = round(profit_net / revenue, 4) # 净利率
|
||||
asset_turnover = round(revenue / asset_total, 4) # 资产周转率
|
||||
equity_multiplier = round(asset_total / equity_total, 4) # 权益乘数
|
||||
roe = round(net_profit_margin * asset_turnover * equity_multiplier * 100, 2)
|
||||
|
||||
result["roe"] = roe
|
||||
result["factors"] = {
|
||||
"net_profit_margin": {"value": net_profit_margin, "label": "净利率", "desc": f"净利润/{'营收' if revenue else '-'} = {net_profit_margin*100:.2f}%"},
|
||||
"asset_turnover": {"value": asset_turnover, "label": "资产周转率", "desc": f"营收/总资产 = {asset_turnover:.4f}次"},
|
||||
"equity_multiplier": {"value": equity_multiplier, "label": "权益乘数", "desc": f"总资产/净资产 = {equity_multiplier:.4f}"},
|
||||
}
|
||||
result["raw_data"] = {
|
||||
"revenue": revenue,
|
||||
"profit_net": profit_net,
|
||||
"asset_total": asset_total,
|
||||
"equity_total": equity_total,
|
||||
}
|
||||
|
||||
# 环比计算
|
||||
if prev_revenue and prev_profit_net and prev_asset and prev_equity and all(v > 0 for v in [prev_revenue, prev_asset, prev_equity]):
|
||||
prev_npm = round(prev_profit_net / prev_revenue, 4)
|
||||
prev_at = round(prev_revenue / prev_asset, 4)
|
||||
prev_em = round(prev_asset / prev_equity, 4)
|
||||
prev_roe = round(prev_npm * prev_at * prev_em * 100, 2)
|
||||
result["history"]["prev"] = {
|
||||
"roe": prev_roe,
|
||||
"net_profit_margin": prev_npm,
|
||||
"asset_turnover": prev_at,
|
||||
"equity_multiplier": prev_em,
|
||||
}
|
||||
# 同比变化
|
||||
change = round(roe - prev_roe, 2)
|
||||
npm_change = round((net_profit_margin - prev_npm) * 10000, 2) # 转成BP
|
||||
at_change = round(asset_turnover - prev_at, 4)
|
||||
em_change = round(equity_multiplier - prev_em, 4)
|
||||
result["history"]["change"] = {
|
||||
"roe": change,
|
||||
"roe_label": f"{'+' if change > 0 else ''}{change}%",
|
||||
"net_profit_margin_bp": npm_change,
|
||||
"asset_turnover": at_change,
|
||||
"equity_multiplier": em_change,
|
||||
}
|
||||
result["history"]["trend"] = "up" if change > 0 else ("down" if change < 0 else "stable")
|
||||
|
||||
# 补上原始数据(即使计算不全也返回给前端展示)
|
||||
if not result.get("raw_data"):
|
||||
result["raw_data"] = {
|
||||
"revenue": revenue,
|
||||
"profit_net": profit_net,
|
||||
"asset_total": asset_total,
|
||||
"equity_total": equity_total,
|
||||
}
|
||||
|
||||
cache_set("dashboard", cache_key, result, ttl_seconds=300)
|
||||
return result
|
||||
|
||||
|
||||
def _get_kpi_trend(kpi_id: int, db: Session) -> dict:
|
||||
"""计算KPI的环比和同比趋势"""
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
cur_period = now.strftime("%Y-%m")
|
||||
|
||||
# 上月
|
||||
if now.month == 1:
|
||||
prev_month = f"{now.year-1}-12"
|
||||
else:
|
||||
prev_month = f"{now.year}-{now.month-1:02d}"
|
||||
|
||||
# 去年同期
|
||||
last_year = f"{now.year-1}-{now.month:02d}"
|
||||
|
||||
cur_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == cur_period
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
prev_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == prev_month
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
yoy_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == last_year
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
|
||||
def calc_rate(curr, prev):
|
||||
if curr and prev and prev.actual_value and prev.actual_value != 0:
|
||||
return round((curr.actual_value - prev.actual_value) / prev.actual_value * 100, 2)
|
||||
return None
|
||||
|
||||
return {
|
||||
"current_value": cur_val.actual_value if cur_val else None,
|
||||
"current_period": cur_period,
|
||||
"mom_value": prev_val.actual_value if prev_val else None,
|
||||
"mom_rate": calc_rate(cur_val, prev_val),
|
||||
"yoy_value": yoy_val.actual_value if yoy_val else None,
|
||||
"yoy_rate": None if not yoy_val else calc_rate(cur_val, yoy_val),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/kpis/enhanced")
|
||||
def get_kpis_enhanced(role: str = Query("ceo"), period: str = Query("month"),
|
||||
start_date: str = None, end_date: str = None,
|
||||
db: Session = Depends(get_db)):
|
||||
"""增强版KPI列表(带趋势)"""
|
||||
result = get_dashboard_kpis(role=role, period=period, start_date=start_date, end_date=end_date, db=db)
|
||||
if "data" in result and result["data"]:
|
||||
for kpi in result["data"]:
|
||||
if kpi.get("id"):
|
||||
trend = _get_kpi_trend(kpi["id"], db)
|
||||
kpi["trend"] = trend
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/trend-analysis")
|
||||
def get_trend_analysis(kpi_ids: str = Query(""), period: str = Query("month"),
|
||||
db: Session = Depends(get_db)):
|
||||
"""多KPI趋势对比(折线图数据)"""
|
||||
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
return {"data": []}
|
||||
|
||||
result = []
|
||||
for kpi_id in ids:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
continue
|
||||
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id
|
||||
).order_by(KPIValue.period).all()
|
||||
|
||||
series = []
|
||||
for v in values:
|
||||
if v.actual_value is not None:
|
||||
series.append({
|
||||
"period": v.period,
|
||||
"value": v.actual_value,
|
||||
})
|
||||
|
||||
result.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"unit": kpi.unit,
|
||||
"target": kpi.target_value,
|
||||
"data": series,
|
||||
})
|
||||
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@router.get("/alert-stats")
|
||||
def get_alert_stats(period: str = Query("month"), db: Session = Depends(get_db)):
|
||||
"""预警统计(按等级和维度)"""
|
||||
from sqlalchemy import func
|
||||
|
||||
# 按等级统计
|
||||
by_level = db.query(
|
||||
KPIAlert.alert_level,
|
||||
func.count(KPIAlert.id)
|
||||
).group_by(KPIAlert.alert_level).all()
|
||||
|
||||
level_stats = {row[0]: row[1] for row in by_level}
|
||||
|
||||
# 按维度统计
|
||||
by_dim = db.query(
|
||||
KPIDefinition.dimension,
|
||||
func.count(KPIAlert.id)
|
||||
).join(KPIAlert, KPIDefinition.id == KPIAlert.kpi_id
|
||||
).group_by(KPIDefinition.dimension).all()
|
||||
|
||||
dim_stats = {row[0]: row[1] for row in by_dim}
|
||||
|
||||
return {
|
||||
"by_level": level_stats,
|
||||
"by_dimension": dim_stats,
|
||||
"total": sum(level_stats.values()) if level_stats else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_kpi_data(kpi_ids: str = "", db: Session = Depends(get_db)):
|
||||
"""导出KPI数据为CSV格式"""
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
|
||||
query = db.query(KPIValue).join(KPIDefinition, KPIValue.kpi_id == KPIDefinition.id)
|
||||
if ids:
|
||||
query = query.filter(KPIValue.kpi_id.in_(ids))
|
||||
|
||||
rows = query.order_by(KPIDefinition.kpi_code, KPIValue.period).all()
|
||||
|
||||
csv_lines = ["KPI编码,KPI名称,期间,实际值,目标值,来源,状态"]
|
||||
for r in rows:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
|
||||
csv_lines.append(f"{kpi.kpi_code},{kpi.kpi_name},{r.period},{r.actual_value},{kpi.target_value},{r.source_type},{r.data_status}")
|
||||
|
||||
return PlainTextResponse("\n".join(csv_lines), media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=kpi_export.csv"})
|
||||
+268
-19
@@ -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
|
||||
@@ -8,48 +8,251 @@ from sqlalchemy import func
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIValue, DataSourceConfig, OperationLog
|
||||
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
|
||||
|
||||
router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
@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)):
|
||||
@@ -98,3 +301,49 @@ def delete_source(source_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(source)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.get("/sync-kpis")
|
||||
def sync_kpis_from_erp(db: Session = Depends(get_db)):
|
||||
"""从ERP数据源同步KPI值(调用erp_sync模块)"""
|
||||
from scripts.erp_sync import run_sync
|
||||
import traceback
|
||||
from datetime import datetime as dt
|
||||
|
||||
try:
|
||||
# 获取所有标记为erp数据源的KPI
|
||||
erp_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.data_source_type == "erp",
|
||||
).all()
|
||||
kpi_count = len(erp_kpis)
|
||||
kpi_codes = [k.kpi_code for k in erp_kpis]
|
||||
|
||||
# 执行同步 (dry_run=False, use_api=False 使用本地fallback)
|
||||
run_sync(dry_run=False, kpi_codes=kpi_codes, use_api=False)
|
||||
|
||||
# 记录操作日志
|
||||
log = OperationLog(
|
||||
action="sync_kpis",
|
||||
target_type="kpi",
|
||||
detail=f"ERP同步: {kpi_count}个KPI, 编码: {', '.join(kpi_codes[:10])}{'...' if kpi_count > 10 else ''}",
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"ERP数据同步完成",
|
||||
"total_kpis": kpi_count,
|
||||
"kpi_codes": kpi_codes,
|
||||
"synced_at": dt.now().isoformat(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log = OperationLog(
|
||||
action="sync_kpis_error",
|
||||
target_type="kpi",
|
||||
detail=f"ERP同步失败: {str(e)[:500]}",
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
raise HTTPException(500, f"ERP同步失败: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""自动数据质量监控 — 任务3
|
||||
定期检查KPI值异常、连续持平、数据缺失等
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
|
||||
from app.api.kpis import kpi_to_dict
|
||||
|
||||
logger = logging.getLogger("data-quality")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/data-quality", tags=["数据质量"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
def _log_to_dict(log):
|
||||
d = {c.name: getattr(log, c.name) for c in log.__table__.columns}
|
||||
if hasattr(log, 'kpi') and log.kpi:
|
||||
d["kpi_code"] = log.kpi.kpi_code
|
||||
d["kpi_name"] = log.kpi.kpi_name
|
||||
return d
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 质量检查
|
||||
# ============================================================
|
||||
|
||||
@router.get("/check")
|
||||
def run_quality_check(db: Session = Depends(get_db)):
|
||||
"""扫描全部KPI,生成数据质量报告"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
issues = []
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
for kpi in kpis:
|
||||
# 获取最近12个月的值
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).limit(12).all()
|
||||
|
||||
# 1. 检查数据缺失
|
||||
if not values:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "missing_data",
|
||||
"severity": "critical",
|
||||
"detail": {"missing_months": 12, "latest_period": None, "total_values": 0},
|
||||
"suggestion": "请初始化KPI数据,建议导入至少3个月历史数据",
|
||||
})
|
||||
continue
|
||||
|
||||
latest_val = values[0]
|
||||
latest_period = latest_val.period
|
||||
|
||||
# 计算缺失月数
|
||||
if latest_period:
|
||||
try:
|
||||
lp_parts = latest_period.split("-")
|
||||
lp_date = datetime(int(lp_parts[0]), int(lp_parts[1]), 1)
|
||||
now_date = datetime.now().replace(day=1)
|
||||
missing_months = max(0, (now_date.year - lp_date.year) * 12 + (now_date.month - lp_date.month) - 1)
|
||||
if missing_months > 1:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "missing_data",
|
||||
"severity": "warning" if missing_months <= 3 else "critical",
|
||||
"detail": {"missing_months": missing_months, "latest_period": latest_period, "total_values": len(values)},
|
||||
"suggestion": f"数据缺失{missing_months}个月,建议从ERP系统同步或手动补录",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 检查环比骤变(需要至少2个月的值)
|
||||
if len(values) >= 2 and latest_val.actual_value:
|
||||
prev_val = values[1].actual_value
|
||||
if prev_val and prev_val != 0:
|
||||
change_pct = abs((latest_val.actual_value - prev_val) / prev_val * 100)
|
||||
if change_pct > 50:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "abnormal_change",
|
||||
"severity": "warning" if change_pct <= 100 else "critical",
|
||||
"detail": {
|
||||
"change_pct": round(change_pct, 1),
|
||||
"current_value": latest_val.actual_value,
|
||||
"previous_value": prev_val,
|
||||
"current_period": latest_val.period,
|
||||
"previous_period": values[1].period,
|
||||
},
|
||||
"suggestion": f"环比变化{round(change_pct,1)}%,建议核实数据是否录入错误",
|
||||
})
|
||||
|
||||
# 3. 检查连续3期持平
|
||||
if len(values) >= 3:
|
||||
last_3 = [v.actual_value for v in values[:3] if v.actual_value is not None]
|
||||
if len(last_3) >= 3 and len(set(last_3)) == 1:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "flat_data",
|
||||
"severity": "warning",
|
||||
"detail": {"flat_value": last_3[0], "periods": [v.period for v in values[:3]]},
|
||||
"suggestion": "连续3期数据完全相同,请确认数据源是否正常更新",
|
||||
})
|
||||
|
||||
# 4. 检查值异常(偏离历史均值超过3倍标准差)
|
||||
if len(values) >= 4 and latest_val.actual_value:
|
||||
hist_vals = [v.actual_value for v in values[1:] if v.actual_value is not None]
|
||||
if len(hist_vals) >= 3:
|
||||
mean_val = sum(hist_vals) / len(hist_vals)
|
||||
variance = sum((v - mean_val) ** 2 for v in hist_vals) / len(hist_vals)
|
||||
stddev = variance ** 0.5 if variance > 0 else mean_val * 0.1
|
||||
if stddev > 0 and abs(latest_val.actual_value - mean_val) > 3 * stddev:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "value_outlier",
|
||||
"severity": "warning",
|
||||
"detail": {
|
||||
"current_value": latest_val.actual_value,
|
||||
"mean": round(mean_val, 2),
|
||||
"stddev": round(stddev, 2),
|
||||
"z_score": round(abs(latest_val.actual_value - mean_val) / stddev, 2),
|
||||
},
|
||||
"suggestion": "当前值偏离历史均值超过3倍标准差,建议核实",
|
||||
})
|
||||
|
||||
# 写入质量日志
|
||||
created_count = 0
|
||||
for issue in issues:
|
||||
existing = db.query(KpiDataQualityLog).filter(
|
||||
KpiDataQualityLog.kpi_id == issue["kpi_id"],
|
||||
KpiDataQualityLog.check_type == issue["check_type"],
|
||||
KpiDataQualityLog.status == "open",
|
||||
).first()
|
||||
if not existing:
|
||||
log = KpiDataQualityLog(
|
||||
kpi_id=issue["kpi_id"],
|
||||
check_type=issue["check_type"],
|
||||
severity=issue["severity"],
|
||||
detail=issue["detail"],
|
||||
suggestion=issue["suggestion"],
|
||||
status="open",
|
||||
)
|
||||
db.add(log)
|
||||
created_count += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"total_kpis": len(kpis),
|
||||
"issues_found": len(issues),
|
||||
"new_logs": created_count,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 质量日志CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("/logs")
|
||||
def list_quality_logs(
|
||||
kpi_id: Optional[int] = None,
|
||||
severity: Optional[str] = None,
|
||||
check_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取数据质量日志"""
|
||||
query = db.query(KpiDataQualityLog)
|
||||
if kpi_id:
|
||||
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
|
||||
if severity:
|
||||
query = query.filter(KpiDataQualityLog.severity == severity)
|
||||
if check_type:
|
||||
query = query.filter(KpiDataQualityLog.check_type == check_type)
|
||||
if status:
|
||||
query = query.filter(KpiDataQualityLog.status == status)
|
||||
|
||||
logs = query.order_by(KpiDataQualityLog.created_at.desc()).limit(100).all()
|
||||
result = []
|
||||
for log in logs:
|
||||
d = _log_to_dict(log)
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == log.kpi_id).first()
|
||||
if kpi:
|
||||
d["kpi_code"] = kpi.kpi_code
|
||||
d["kpi_name"] = kpi.kpi_name
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.put("/logs/{log_id}")
|
||||
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""更新质量日志(解决/忽略)"""
|
||||
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
|
||||
if not log:
|
||||
raise HTTPException(404, "日志不存在")
|
||||
if "status" in data:
|
||||
log.status = data["status"]
|
||||
if data["status"] == "resolved":
|
||||
log.resolved_at = datetime.now()
|
||||
if "suggestion" in data:
|
||||
log.suggestion = data["suggestion"]
|
||||
db.commit()
|
||||
return _log_to_dict(log)
|
||||
|
||||
|
||||
@router.delete("/logs/{log_id}")
|
||||
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
|
||||
if log:
|
||||
db.delete(log)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据质量看板统计
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats")
|
||||
def quality_stats(db: Session = Depends(get_db)):
|
||||
"""数据质量统计"""
|
||||
total_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").count()
|
||||
total_logs = db.query(KpiDataQualityLog).count()
|
||||
open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count()
|
||||
|
||||
# 按严重程度统计
|
||||
severity_counts = {}
|
||||
for s in ("info", "warning", "critical"):
|
||||
cnt = db.query(KpiDataQualityLog).filter(
|
||||
KpiDataQualityLog.severity == s,
|
||||
KpiDataQualityLog.status == "open",
|
||||
).count()
|
||||
if cnt:
|
||||
severity_counts[s] = cnt
|
||||
|
||||
# 按检查类型统计
|
||||
type_counts = {}
|
||||
for t in ("abnormal_change", "flat_data", "missing_data", "value_outlier"):
|
||||
cnt = db.query(KpiDataQualityLog).filter(
|
||||
KpiDataQualityLog.check_type == t,
|
||||
KpiDataQualityLog.status == "open",
|
||||
).count()
|
||||
if cnt:
|
||||
type_counts[t] = cnt
|
||||
|
||||
return {
|
||||
"total_kpis": total_kpis,
|
||||
"total_logs": total_logs,
|
||||
"open_logs": open_logs,
|
||||
"severity_counts": severity_counts,
|
||||
"type_counts": type_counts,
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""差异分析→战略地图反打 API — P1-1
|
||||
|
||||
允许从差异分析页面一键回写实际值到战略地图节点,触发预警并生成回顾会议题。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cma.deviation_push")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business"))],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/push-to-map")
|
||||
def push_deviation_to_map(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""从差异分析回写实际值到战略地图节点
|
||||
|
||||
Body: {
|
||||
mapId: int,
|
||||
nodeId: string, // 格式 "dim_key-index" 如 "finance-0"
|
||||
deviationId: int,
|
||||
newValue: float,
|
||||
period: string, // 如 "2026-05"
|
||||
createReviewTopic: bool
|
||||
}
|
||||
"""
|
||||
map_id = data.get("mapId")
|
||||
node_id = data.get("nodeId")
|
||||
deviation_id = data.get("deviationId")
|
||||
new_value = data.get("newValue")
|
||||
period = data.get("period")
|
||||
create_review_topic = data.get("createReviewTopic", True)
|
||||
|
||||
if not map_id or not node_id:
|
||||
raise HTTPException(400, "缺少 mapId 或 nodeId")
|
||||
if new_value is None:
|
||||
raise HTTPException(400, "缺少 newValue")
|
||||
|
||||
# 1. 查找战略地图
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
# 2. 解析 node_id 格式: "finance-0"
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except:
|
||||
dims = []
|
||||
|
||||
parts = node_id.rsplit("-", 1)
|
||||
if len(parts) != 2:
|
||||
raise HTTPException(400, f"节点ID格式错误: {node_id}")
|
||||
|
||||
dim_key, obj_index_str = parts
|
||||
try:
|
||||
obj_index = int(obj_index_str)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"节点索引不是数字: {obj_index_str}")
|
||||
|
||||
target_dim = None
|
||||
target_obj = None
|
||||
for dim in dims:
|
||||
if dim.get("key") == dim_key:
|
||||
target_dim = dim
|
||||
objs = dim.get("objectives", [])
|
||||
if 0 <= obj_index < len(objs):
|
||||
target_obj = objs[obj_index]
|
||||
break
|
||||
|
||||
if not target_obj:
|
||||
raise HTTPException(404, f"未找到节点: {node_id}")
|
||||
|
||||
kpi_codes = target_obj.get("kpis", [])
|
||||
if not kpi_codes:
|
||||
raise HTTPException(400, f"目标 [{target_obj.get('name')}] 没有关联KPI")
|
||||
|
||||
kpi_code = kpi_codes[0]
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_code} 不存在")
|
||||
|
||||
# 3. 更新实际值到 KPIValue 表
|
||||
if not period:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
existing_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
|
||||
if existing_value:
|
||||
existing_value.actual_value = new_value
|
||||
existing_value.source_type = "manual"
|
||||
else:
|
||||
kv = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=new_value,
|
||||
source_type="manual",
|
||||
)
|
||||
db.add(kv)
|
||||
|
||||
db.flush()
|
||||
|
||||
# 4. 检查是否触发预警
|
||||
alert_created = False
|
||||
alert_id = None
|
||||
if kpi.target_value and kpi.target_value > 0:
|
||||
ratio = new_value / kpi.target_value
|
||||
if ratio < 0.7:
|
||||
alert_level = "red"
|
||||
alert_msg = f"严重偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
|
||||
elif ratio < 0.9:
|
||||
alert_level = "yellow"
|
||||
alert_msg = f"关注偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
|
||||
else:
|
||||
alert_level = None
|
||||
|
||||
if alert_level:
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_msg,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
alert_created = True
|
||||
alert_id = alert.id
|
||||
|
||||
# 5. 生成战略回顾会议题
|
||||
review_topic_created = False
|
||||
if create_review_topic:
|
||||
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
|
||||
existing_topic = db.query(ActionPlan).filter(
|
||||
ActionPlan.title == topic_title,
|
||||
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||
).first()
|
||||
if not existing_topic:
|
||||
topic = ActionPlan(
|
||||
kpi_id=kpi.id,
|
||||
title=topic_title,
|
||||
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
|
||||
assignee=current_user.name if hasattr(current_user, "name") else "",
|
||||
priority="medium",
|
||||
status="pending",
|
||||
created_by=current_user.name if hasattr(current_user, "name") else "",
|
||||
)
|
||||
db.add(topic)
|
||||
review_topic_created = True
|
||||
|
||||
# 6. 操作日志
|
||||
log = OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="deviation_push_to_map",
|
||||
target_type="map",
|
||||
target_id=map_id,
|
||||
detail=json.dumps({
|
||||
"node_id": node_id,
|
||||
"deviation_id": deviation_id,
|
||||
"kpi_code": kpi_code,
|
||||
"new_value": new_value,
|
||||
"period": period,
|
||||
"alert_created": alert_created,
|
||||
"review_topic_created": review_topic_created,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已回写至战略地图 [{target_dim.get('name')}→{target_obj.get('name')}]",
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"new_value": new_value,
|
||||
"alert_created": alert_created,
|
||||
"alert_id": alert_id,
|
||||
"review_topic_created": review_topic_created,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/map-nodes/{map_id}")
|
||||
def get_map_nodes(map_id: int, db: Session = Depends(get_db)):
|
||||
"""获取战略地图的全部节点(供反打选择使用)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except:
|
||||
dims = []
|
||||
|
||||
nodes = []
|
||||
for dim in dims:
|
||||
objs = dim.get("objectives", [])
|
||||
for idx, obj in enumerate(objs):
|
||||
node_id = f"{dim.get('key')}-{idx}"
|
||||
nodes.append({
|
||||
"node_id": node_id,
|
||||
"dim_key": dim.get("key"),
|
||||
"dim_name": dim.get("name"),
|
||||
"dim_icon": dim.get("icon"),
|
||||
"objective_name": obj.get("name"),
|
||||
"kpi_codes": obj.get("kpis", []),
|
||||
})
|
||||
|
||||
return {"data": nodes}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""知识摘要 API — 管理会计OS持久记忆
|
||||
|
||||
提供:
|
||||
- 查询最近摘要列表
|
||||
- 查询单个摘要详情
|
||||
- 手动触发各层级摘要生成
|
||||
- 查询未摘要的事件
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import KnowledgeEvent, KnowledgeSummary
|
||||
from app.services.knowledge_service import (
|
||||
generate_summary_sync,
|
||||
generate_daily_sync,
|
||||
generate_weekly_sync,
|
||||
generate_monthly_sync,
|
||||
get_last_summary,
|
||||
extract_events,
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.knowledge_api")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/knowledge", tags=["知识摘要"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def summary_to_dict(s: KnowledgeSummary) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"level": s.level,
|
||||
"period_key": s.period_key,
|
||||
"title": s.title,
|
||||
"content": s.content,
|
||||
"kpi_changes": s.kpi_changes,
|
||||
"decision_points": s.decision_points,
|
||||
"key_metrics": s.key_metrics,
|
||||
"prev_summary_id": s.prev_summary_id,
|
||||
"model": s.model,
|
||||
"is_stale": s.is_stale,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ── 查询 ──
|
||||
|
||||
|
||||
@router.get("/summaries")
|
||||
def list_summaries(
|
||||
level: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取摘要列表,按层级筛选,按时间倒序"""
|
||||
query = db.query(KnowledgeSummary)
|
||||
if level:
|
||||
query = query.filter(KnowledgeSummary.level == level)
|
||||
query = query.order_by(desc(KnowledgeSummary.id)).offset(offset).limit(limit)
|
||||
total = db.query(func.count(KnowledgeSummary.id)).select_from(KnowledgeSummary)
|
||||
if level:
|
||||
total = total.filter(KnowledgeSummary.level == level)
|
||||
total = total.scalar()
|
||||
return {
|
||||
"total": total,
|
||||
"items": [summary_to_dict(s) for s in query.all()],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summaries/latest")
|
||||
def latest_summary(
|
||||
level: str = Query("daily", description="层级: daily/weekly/monthly/cumulative"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取指定层级的最新摘要"""
|
||||
s = get_last_summary(db, level)
|
||||
if not s:
|
||||
return {"detail": f"没有{level}层级的摘要"}, 404
|
||||
return summary_to_dict(s)
|
||||
|
||||
|
||||
@router.get("/summaries/{summary_id}")
|
||||
def get_summary(summary_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单条摘要详情"""
|
||||
s = db.query(KnowledgeSummary).filter(KnowledgeSummary.id == summary_id).first()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="摘要不存在")
|
||||
return summary_to_dict(s)
|
||||
|
||||
|
||||
# ── 事件查询 ──
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
def list_events(
|
||||
since: Optional[str] = None,
|
||||
until: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""查询未摘要的原始事件
|
||||
|
||||
如果不传时间,默认返回最近7天的操作记录和预警。
|
||||
"""
|
||||
try:
|
||||
dt_since = datetime.fromisoformat(since) if since else datetime.utcnow() - timedelta(days=7)
|
||||
dt_until = datetime.fromisoformat(until) if until else datetime.utcnow()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="时间格式错误,请使用 ISO 格式如 2026-06-01T00:00:00")
|
||||
|
||||
events = extract_events(db, dt_since, dt_until)
|
||||
return {"since": dt_since.isoformat(), "until": dt_until.isoformat(), "total": len(events), "events": events[:limit]}
|
||||
|
||||
|
||||
# ── 手动触发 ──
|
||||
|
||||
|
||||
@router.post("/generate/daily")
|
||||
def trigger_daily_summary(db: Session = Depends(get_db)):
|
||||
"""手动触发每日摘要生成"""
|
||||
try:
|
||||
result = generate_daily_sync(db)
|
||||
return {"message": "每日摘要已生成", "summary": result}
|
||||
except Exception as e:
|
||||
logger.exception("每日摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate/weekly")
|
||||
def trigger_weekly_summary(db: Session = Depends(get_db)):
|
||||
"""手动触发周度摘要生成"""
|
||||
try:
|
||||
result = generate_weekly_sync(db)
|
||||
return {"message": "周度摘要已生成", "summary": result}
|
||||
except Exception as e:
|
||||
logger.exception("周度摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate/monthly")
|
||||
def trigger_monthly_summary(db: Session = Depends(get_db)):
|
||||
"""手动触发月度摘要生成"""
|
||||
try:
|
||||
result = generate_monthly_sync(db)
|
||||
return {"message": "月度摘要已生成", "summary": result}
|
||||
except Exception as e:
|
||||
logger.exception("月度摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""知识库文章 API — P1-3 嵌入功能模块用
|
||||
|
||||
提供按关联页面查询知识文章的功能。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models.knowledge_article import KnowledgeArticle
|
||||
|
||||
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def article_to_dict(a: KnowledgeArticle) -> dict:
|
||||
return {
|
||||
"id": a.id,
|
||||
"title": a.title,
|
||||
"summary": a.summary,
|
||||
"content": a.content,
|
||||
"category": a.category,
|
||||
"icon": a.icon,
|
||||
"related_page": a.related_page,
|
||||
"sort_order": a.sort_order,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_articles(
|
||||
related_page: Optional[str] = Query(None, description="按关联页面路由筛选"),
|
||||
category: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""查询知识文章,可按关联页面或分类筛选"""
|
||||
q = db.query(KnowledgeArticle)
|
||||
if related_page:
|
||||
q = q.filter(KnowledgeArticle.related_page.contains(related_page))
|
||||
if category:
|
||||
q = q.filter(KnowledgeArticle.category == category)
|
||||
articles = q.order_by(KnowledgeArticle.sort_order.asc(), KnowledgeArticle.id.asc()).all()
|
||||
return {"data": [article_to_dict(a) for a in articles]}
|
||||
|
||||
|
||||
@router.get("/{article_id}")
|
||||
def get_article(article_id: int, db: Session = Depends(get_db)):
|
||||
a = db.query(KnowledgeArticle).filter(KnowledgeArticle.id == article_id).first()
|
||||
if not a:
|
||||
raise HTTPException(404, "文章不存在")
|
||||
return article_to_dict(a)
|
||||
@@ -0,0 +1,307 @@
|
||||
"""KPI因果链建模 — 任务2
|
||||
KPI间因果关系网络 + 模拟推演
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
||||
|
||||
logger = logging.getLogger("kpi-causality")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/kpi-causality", tags=["KPI因果链"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
def _to_dict(obj):
|
||||
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 注意: 静态路径必须放在动态路径之前(/{id}之前)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/full-network")
|
||||
def get_full_network(db: Session = Depends(get_db)):
|
||||
"""获取全局因果网络数据(用于力导向图)"""
|
||||
edges = db.query(KPICausality).all()
|
||||
node_ids = set()
|
||||
edge_list = []
|
||||
for e in edges:
|
||||
node_ids.add(e.source_kpi_id)
|
||||
node_ids.add(e.target_kpi_id)
|
||||
edge_list.append({
|
||||
"source": e.source_kpi_id,
|
||||
"target": e.target_kpi_id,
|
||||
"strength": e.strength,
|
||||
"direction": e.direction,
|
||||
"lag_months": e.lag_months,
|
||||
})
|
||||
|
||||
# 获取所有节点信息
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(node_ids)).all() if node_ids else []
|
||||
node_map = {k.id: {
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "category": k.category,
|
||||
} for k in kpis}
|
||||
|
||||
nodes = []
|
||||
for nid in node_ids:
|
||||
info = node_map.get(nid, {"id": nid, "kpi_code": f"KPI#{nid}", "kpi_name": f"KPI#{nid}"})
|
||||
nodes.append(info)
|
||||
|
||||
return {"nodes": nodes, "edges": edge_list, "total_edges": len(edge_list)}
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}/network")
|
||||
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""获取KPI的因果网络(上游驱动 + 下游影响)"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 上游(指向当前KPI的因果)
|
||||
upstream = db.query(KPICausality).filter(KPICausality.target_kpi_id == kpi_id).all()
|
||||
upstream_list = []
|
||||
for c in upstream:
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
if src:
|
||||
upstream_list.append({
|
||||
"causality_id": c.id,
|
||||
"kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
|
||||
"strength": c.strength, "lag_months": c.lag_months,
|
||||
"direction": c.direction, "formula": c.formula,
|
||||
})
|
||||
|
||||
# 下游(当前KPI指向的因果)
|
||||
downstream = db.query(KPICausality).filter(KPICausality.source_kpi_id == kpi_id).all()
|
||||
downstream_list = []
|
||||
for c in downstream:
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
if tgt:
|
||||
downstream_list.append({
|
||||
"causality_id": c.id,
|
||||
"kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
|
||||
"strength": c.strength, "lag_months": c.lag_months,
|
||||
"direction": c.direction, "formula": c.formula,
|
||||
})
|
||||
|
||||
return {
|
||||
"kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension},
|
||||
"upstream": upstream_list,
|
||||
"downstream": downstream_list,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/simulate")
|
||||
def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
"""模拟推演: 修改一个KPI的值,预测对其他KPI的影响
|
||||
Body: { kpi_id: int, new_value: float, period: str }
|
||||
"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
new_value = data.get("new_value")
|
||||
period = data.get("period")
|
||||
|
||||
if not kpi_id or new_value is None:
|
||||
raise HTTPException(400, "必须指定kpi_id和new_value")
|
||||
|
||||
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not source_kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 获取当前值
|
||||
current_value = None
|
||||
query_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period:
|
||||
query_values = query_values.filter(KPIValue.period == period)
|
||||
latest = query_values.order_by(KPIValue.period.desc()).first()
|
||||
if latest:
|
||||
current_value = latest.actual_value
|
||||
|
||||
previous_value = current_value or new_value
|
||||
change_pct = ((new_value - previous_value) / previous_value * 100) if previous_value and previous_value != 0 else 0
|
||||
|
||||
# BFS遍历下游因果链
|
||||
visited = set()
|
||||
impacts = []
|
||||
queue = [(kpi_id, change_pct, 0, 1.0)] # (kpi_id, change_pct, depth, cumulative_strength)
|
||||
|
||||
while queue:
|
||||
current_kpi_id, current_change, depth, cum_strength = queue.pop(0)
|
||||
if current_kpi_id in visited:
|
||||
continue
|
||||
visited.add(current_kpi_id)
|
||||
|
||||
# 查找从current_kpi_id出发的下游因果链
|
||||
downstream = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == current_kpi_id
|
||||
).all()
|
||||
|
||||
for edge in downstream:
|
||||
target_id = edge.target_kpi_id
|
||||
if target_id in visited:
|
||||
continue
|
||||
target_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
|
||||
if not target_kpi:
|
||||
continue
|
||||
|
||||
# 计算影响: 变化率 × 强度 × 方向
|
||||
edge_strength = edge.strength or 0.5
|
||||
direction_factor = 1.0 if edge.direction == "positive" else -1.0
|
||||
propagated_change = current_change * edge_strength * direction_factor
|
||||
|
||||
# 获取当前值
|
||||
tgt_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == target_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
predicted_value = None
|
||||
if tgt_val and tgt_val.actual_value:
|
||||
predicted_value = round(tgt_val.actual_value * (1 + propagated_change / 100), 2)
|
||||
|
||||
impacts.append({
|
||||
"kpi_id": target_id,
|
||||
"kpi_code": target_kpi.kpi_code,
|
||||
"kpi_name": target_kpi.kpi_name,
|
||||
"dimension": target_kpi.dimension,
|
||||
"current_value": tgt_val.actual_value if tgt_val else None,
|
||||
"predicted_value": predicted_value,
|
||||
"change_pct": round(propagated_change, 2),
|
||||
"strength": edge_strength,
|
||||
"direction": edge.direction,
|
||||
"lag_months": edge.lag_months,
|
||||
"depth": depth + 1,
|
||||
"path_strength": round(cum_strength * edge_strength, 3),
|
||||
})
|
||||
|
||||
# 继续遍历下游
|
||||
new_cum = cum_strength * edge_strength
|
||||
if new_cum > 0.05 and depth < 5:
|
||||
queue.append((target_id, propagated_change, depth + 1, new_cum))
|
||||
|
||||
return {
|
||||
"source": {
|
||||
"kpi_id": source_kpi.id,
|
||||
"kpi_code": source_kpi.kpi_code,
|
||||
"kpi_name": source_kpi.kpi_name,
|
||||
"current_value": current_value,
|
||||
"new_value": new_value,
|
||||
"change_pct": round(change_pct, 2),
|
||||
},
|
||||
"impacts": impacts,
|
||||
"total_impacted": len(impacts),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CRUD (动态路径)
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_causalities(
|
||||
source_kpi_id: Optional[int] = None,
|
||||
target_kpi_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取因果链列表"""
|
||||
query = db.query(KPICausality)
|
||||
if source_kpi_id:
|
||||
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
|
||||
if target_kpi_id:
|
||||
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
|
||||
items = query.order_by(KPICausality.id).all()
|
||||
|
||||
result = []
|
||||
for c in items:
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
d["source_kpi_code"] = src.kpi_code if src else None
|
||||
d["source_kpi_name"] = src.kpi_name if src else None
|
||||
d["target_kpi_code"] = tgt.kpi_code if tgt else None
|
||||
d["target_kpi_name"] = tgt.kpi_name if tgt else None
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/{causality_id}")
|
||||
def get_causality(causality_id: int, db: Session = Depends(get_db)):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
d["source"] = {"id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name} if src else None
|
||||
d["target"] = {"id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name} if tgt else None
|
||||
return d
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""创建因果链"""
|
||||
source_id = data.get("source_kpi_id")
|
||||
target_id = data.get("target_kpi_id")
|
||||
if not source_id or not target_id:
|
||||
raise HTTPException(400, "必须指定源KPI和目标KPI")
|
||||
if source_id == target_id:
|
||||
raise HTTPException(400, "源和目标不能相同")
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == source_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
|
||||
if not src or not tgt:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
existing = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == source_id,
|
||||
KPICausality.target_kpi_id == target_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"因果链已存在: {src.kpi_code}→{tgt.kpi_code}")
|
||||
|
||||
c = KPICausality(
|
||||
source_kpi_id=source_id,
|
||||
target_kpi_id=target_id,
|
||||
strength=data.get("strength", 0.5),
|
||||
lag_months=data.get("lag_months", 1),
|
||||
formula=data.get("formula"),
|
||||
direction=data.get("direction", "positive"),
|
||||
)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
db.add(OperationLog(action="create", target_type="kpi_causality",
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code}"))
|
||||
db.commit()
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@router.put("/{causality_id}")
|
||||
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
for field in ("strength", "lag_months", "formula", "direction"):
|
||||
if field in data:
|
||||
setattr(c, field, data[field])
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@router.delete("/{causality_id}")
|
||||
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if c:
|
||||
db.delete(c)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
+35
-5
@@ -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):
|
||||
|
||||
+105
-31
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import StrategicMap, OperationLog
|
||||
from app.models import StrategicMap, OperationLog, MapObjective
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
||||
@@ -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"]},
|
||||
{"name": "净利润率", "kpis": ["F_NET_PROFIT"]},
|
||||
{"name": "现金流", "kpis": ["F_OP_CFLOW"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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_SATISFACTION"]},
|
||||
{"name": "市场份额", "kpis": ["C_MARKET_SHARE"]},
|
||||
{"name": "客户保留率", "kpis": ["C_RETENTION_RATE"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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_DELIVERY"]},
|
||||
{"name": "质量合格率", "kpis": ["P_PASS_RATE"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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_COMPETENCY"]},
|
||||
{"name": "培训完成率", "kpis": ["L_TRAINING"]},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -64,7 +62,7 @@ STRATEGIC_MAP_TEMPLATE = [
|
||||
@router.get("")
|
||||
def list_maps(db: Session = Depends(get_db)):
|
||||
maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all()
|
||||
return {"data": [m_to_dict(m) for m in maps]}
|
||||
return {"data": [m_to_dict(m, db) for m in maps]}
|
||||
|
||||
@router.post("")
|
||||
def create_map(data: dict, db: Session = Depends(get_db)):
|
||||
@@ -72,7 +70,8 @@ def create_map(data: dict, db: Session = Depends(get_db)):
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m_to_dict(m)
|
||||
_sync_map_objectives(m, db)
|
||||
return m_to_dict(m, db)
|
||||
|
||||
|
||||
@router.post("/create-with-template")
|
||||
@@ -88,7 +87,8 @@ def create_map_with_template(data: dict, db: Session = Depends(get_db)):
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m_to_dict(m)
|
||||
_sync_map_objectives(m, db)
|
||||
return m_to_dict(m, db)
|
||||
|
||||
|
||||
@router.put("/{map_id}")
|
||||
@@ -103,12 +103,44 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
setattr(m, k, v)
|
||||
|
||||
db.commit()
|
||||
# 同步目标到map_objectives表
|
||||
_sync_map_objectives(m, db)
|
||||
|
||||
# ├─ 版本管理: draft → published 时自动创建快照
|
||||
if old_status == "draft" and m.status == "published":
|
||||
_auto_snapshot(m, db)
|
||||
|
||||
return m_to_dict(m)
|
||||
return m_to_dict(m, db)
|
||||
|
||||
|
||||
# ── 删除地图 ─────────────────────────────────
|
||||
|
||||
|
||||
@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": "已删除"}
|
||||
|
||||
|
||||
@router.post("/batch-delete")
|
||||
def batch_delete_maps(data: dict, db: Session = Depends(get_db)):
|
||||
"""批量删除战略地图"""
|
||||
ids = data.get("ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(400, "请选择要删除的地图")
|
||||
deleted = 0
|
||||
for mid in ids:
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
|
||||
if m:
|
||||
db.delete(m)
|
||||
deleted += 1
|
||||
db.commit()
|
||||
return {"message": f"已删除 {deleted} 个地图", "deleted": deleted}
|
||||
|
||||
|
||||
# ── 连线管理 ─────────────────────────────────
|
||||
@@ -143,11 +175,9 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
if from_id == to_id:
|
||||
raise HTTPException(400, "不能自身连线")
|
||||
|
||||
# 校验: 维度不能相同 (learning-0 和 process-0 的维度不同)
|
||||
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
|
||||
from_dim = from_id.rsplit("-", 1)[0]
|
||||
to_dim = to_id.rsplit("-", 1)[0]
|
||||
if from_dim == to_dim:
|
||||
raise HTTPException(400, "同维度内不能连线")
|
||||
|
||||
conns = _get_connections(m)
|
||||
|
||||
@@ -233,8 +263,52 @@ def _auto_snapshot(m: StrategicMap, db: Session):
|
||||
|
||||
# ── 工具函数 ─────────────────────────────────
|
||||
|
||||
def m_to_dict(m):
|
||||
return {c.name: getattr(m, c.name) for c in m.__table__.columns}
|
||||
def m_to_dict(m, db: Session = None):
|
||||
d = {c.name: getattr(m, c.name) for c in m.__table__.columns}
|
||||
if db:
|
||||
_merge_map_objectives(m, db)
|
||||
d["dimensions"] = m.dimensions
|
||||
return d
|
||||
|
||||
|
||||
def _sync_map_objectives(m, db):
|
||||
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
|
||||
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
dims = json.loads(dims)
|
||||
for dim in dims:
|
||||
for i, obj in enumerate(dim.get("objectives", [])):
|
||||
mo = MapObjective(
|
||||
map_id=m.id,
|
||||
dimension_key=dim.get("key", ""),
|
||||
name=obj.get("name", ""),
|
||||
description=obj.get("description", ""),
|
||||
icon=obj.get("icon", "target"),
|
||||
sort_order=i,
|
||||
)
|
||||
db.add(mo)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _merge_map_objectives(m, db):
|
||||
"""读取时:将map_objectives表的数据合并进dimensions JSON"""
|
||||
objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all()
|
||||
if not objs:
|
||||
return
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
dims = json.loads(dims)
|
||||
# 按dimension_key分组
|
||||
from collections import defaultdict
|
||||
grouped = defaultdict(list)
|
||||
for o in objs:
|
||||
grouped[o.dimension_key].append(o)
|
||||
for dim in dims:
|
||||
key = dim.get("key", "")
|
||||
if key in grouped:
|
||||
dim["objectives"] = [{"name": o.name, "description": o.description or "", "icon": o.icon or "target"} for o in grouped[key]]
|
||||
m.dimensions = dims
|
||||
|
||||
|
||||
# ── 战略回顾会 聚合接口 ──────────────────────
|
||||
|
||||
@@ -96,3 +96,59 @@ def api_scenario_analysis(data: dict):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"情景模拟失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/cvp-detailed")
|
||||
def api_cvp_detailed(data: dict):
|
||||
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)"""
|
||||
try:
|
||||
fixed_cost = float(data.get("fixed_cost", 617))
|
||||
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
|
||||
unit_price = float(data.get("unit_price", 228))
|
||||
current_volume = float(data.get("current_volume", 5300))
|
||||
|
||||
contribution_margin_rate = 1 - variable_cost_rate
|
||||
breakeven_revenue = round(fixed_cost / contribution_margin_rate, 2)
|
||||
breakeven_units = round(breakeven_revenue * 10000 / unit_price, 0)
|
||||
|
||||
current_revenue = round(current_volume * unit_price / 10000, 2)
|
||||
current_profit = round(current_revenue * (1 - variable_cost_rate) - fixed_cost, 2)
|
||||
safety_margin = round((current_revenue - breakeven_revenue) / current_revenue * 100, 2) if current_revenue > 0 else 0
|
||||
|
||||
scenarios = [
|
||||
{"name": "降固定费用至300万", "fixed_cost": 300, "variable_cost_rate": variable_cost_rate,
|
||||
"breakeven_revenue": round(300 / contribution_margin_rate, 2),
|
||||
"breakeven_units": round(300 / contribution_margin_rate * 10000 / unit_price, 0)},
|
||||
{"name": "降变动成本率至30%", "fixed_cost": fixed_cost, "variable_cost_rate": 0.3,
|
||||
"breakeven_revenue": round(fixed_cost / 0.7, 2),
|
||||
"breakeven_units": round(fixed_cost / 0.7 * 10000 / unit_price, 0)},
|
||||
{"name": "两者同时改善", "fixed_cost": 300, "variable_cost_rate": 0.3,
|
||||
"breakeven_revenue": round(300 / 0.7, 2),
|
||||
"breakeven_units": round(300 / 0.7 * 10000 / unit_price, 0)},
|
||||
]
|
||||
|
||||
# 保本图数据点
|
||||
chart_data = []
|
||||
max_volume = int(max(breakeven_units * 2, current_volume * 3))
|
||||
step = max(1, int(max_volume / 20))
|
||||
for vol in range(0, int(max_volume) + step, step):
|
||||
rev = round(vol * unit_price / 10000, 2)
|
||||
tc = round(fixed_cost + rev * variable_cost_rate, 2)
|
||||
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
|
||||
|
||||
return {
|
||||
"fixed_cost": fixed_cost,
|
||||
"variable_cost_rate": round(variable_cost_rate * 100, 2),
|
||||
"unit_price": unit_price,
|
||||
"contribution_margin_rate": round(contribution_margin_rate * 100, 2),
|
||||
"breakeven_revenue": breakeven_revenue,
|
||||
"breakeven_units": int(breakeven_units),
|
||||
"current_revenue": current_revenue,
|
||||
"current_profit": current_profit,
|
||||
"current_volume": int(current_volume),
|
||||
"safety_margin": safety_margin,
|
||||
"scenarios": scenarios,
|
||||
"chart_data": chart_data,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 杜邦分析 (CMA P2)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/dupont")
|
||||
def get_dupont_analysis(
|
||||
entity: str = Query("bohai"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""杜邦分析 — ROE三级拆解 (CMA P2)"""
|
||||
if entity == "bohai":
|
||||
net_profit = 14.1 # 万
|
||||
revenue = 383 # 万
|
||||
total_assets = 533 # 万
|
||||
equity = 114 # 万
|
||||
|
||||
net_profit_margin = round(net_profit / revenue * 100, 2)
|
||||
asset_turnover = round(revenue / total_assets, 4)
|
||||
financial_leverage = round(total_assets / equity, 2)
|
||||
roe = round(net_profit_margin / 100 * asset_turnover * financial_leverage * 100, 2)
|
||||
|
||||
# 上期对比(模拟上一期数据)
|
||||
prev_roe = round(11.2, 2)
|
||||
roe_change = round(roe - prev_roe, 2)
|
||||
|
||||
return {
|
||||
"entity": "bohai",
|
||||
"entity_name": "陕西博海科技(IT服务)",
|
||||
"period": "2026年H1",
|
||||
"roe": roe,
|
||||
"roe_change": roe_change,
|
||||
"roe_trend": "up" if roe_change > 0 else "down",
|
||||
"prev_roe": prev_roe,
|
||||
"factors": {
|
||||
"net_profit_margin": {
|
||||
"value": net_profit_margin,
|
||||
"label": "净利润率",
|
||||
"desc": "净利润/收入",
|
||||
"status": "🟡" if net_profit_margin < 5 else "✅",
|
||||
"assessment": "IT经销行业正常偏低",
|
||||
"raw": {"net_profit": net_profit, "revenue": revenue},
|
||||
},
|
||||
"asset_turnover": {
|
||||
"value": asset_turnover,
|
||||
"label": "资产周转率",
|
||||
"desc": "收入/总资产",
|
||||
"status": "🟡" if asset_turnover < 1 else "✅",
|
||||
"assessment": "资金效率中等",
|
||||
"raw": {"revenue": revenue, "total_assets": total_assets},
|
||||
},
|
||||
"financial_leverage": {
|
||||
"value": financial_leverage,
|
||||
"label": "财务杠杆",
|
||||
"desc": "总资产/净资产",
|
||||
"status": "🟡" if financial_leverage > 3 else "✅",
|
||||
"assessment": "负债率78.6%,偏高但可控",
|
||||
"raw": {"total_assets": total_assets, "equity": equity},
|
||||
},
|
||||
},
|
||||
"raw_data": {
|
||||
"net_profit": net_profit,
|
||||
"revenue": revenue,
|
||||
"total_assets": total_assets,
|
||||
"equity": equity,
|
||||
},
|
||||
"insight": {
|
||||
"improvement": "提高周转率或利润率,而非加杠杆",
|
||||
"detail": f"净利润率{net_profit_margin}%偏低,资产周转率{asset_turnover}x中等,财务杠杆{financial_leverage}x偏高。改善方向:提升毛利率或加快库存周转。",
|
||||
},
|
||||
}
|
||||
return {"error": "不支持的实体"}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""安全验证码 API — 图形验证码 + 滑块拼图"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.security.captcha import (
|
||||
generate_image_captcha,
|
||||
generate_slider_captcha,
|
||||
sign_token,
|
||||
verify_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/cma/security", tags=["安全验证"])
|
||||
|
||||
# 简易内存存储:验证失败的IP计数(生产环境用Redis)
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
|
||||
_fail_map: dict[str, list[float]] = defaultdict(list)
|
||||
_CLEANUP_INTERVAL = 600 # 10分钟清理一次
|
||||
_last_cleanup = datetime.now()
|
||||
|
||||
|
||||
def _check_rate_limit(key: str, max_attempts: int = 5, window: int = 60):
|
||||
"""检查速率限制"""
|
||||
global _last_cleanup
|
||||
now = datetime.now()
|
||||
# 定期清理
|
||||
if (now - _last_cleanup).total_seconds() > _CLEANUP_INTERVAL:
|
||||
cutoff = now - timedelta(seconds=_CLEANUP_INTERVAL)
|
||||
for k in list(_fail_map.keys()):
|
||||
_fail_map[k] = [t for t in _fail_map[k] if t > cutoff.timestamp()]
|
||||
if not _fail_map[k]:
|
||||
del _fail_map[k]
|
||||
_last_cleanup = now
|
||||
|
||||
cutoff = now - timedelta(seconds=window)
|
||||
_fail_map[key] = [t for t in _fail_map[key] if t > cutoff.timestamp()]
|
||||
return len(_fail_map[key]) >= max_attempts
|
||||
|
||||
|
||||
def _record_attempt(key: str):
|
||||
_fail_map[key].append(datetime.now().timestamp())
|
||||
|
||||
|
||||
def _get_client_ip(request) -> str:
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# ── 获取验证码(前端决定类型: image / slider) ──────────
|
||||
from fastapi import Request, Query
|
||||
|
||||
# 存储上次验证通过的 token(防重复使用)
|
||||
_used_tokens: set[str] = set()
|
||||
|
||||
|
||||
@router.get("/captcha/request")
|
||||
def request_captcha(
|
||||
request: Request,
|
||||
captcha_type: str = Query("image", description="验证码类型: image 或 slider"),
|
||||
):
|
||||
"""获取验证码,返回图片(base64) + captcha_id"""
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_req:{ip}"
|
||||
|
||||
if _check_rate_limit(limit_key, max_attempts=10, window=60):
|
||||
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
|
||||
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if captcha_type == "slider":
|
||||
captcha_id, answer, data = generate_slider_captcha()
|
||||
return {
|
||||
"captcha_type": "slider",
|
||||
"captcha_id": captcha_id,
|
||||
"bg": data["bg"],
|
||||
"slice": data["slice"],
|
||||
"gap_x": data["gap_x"],
|
||||
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
|
||||
}
|
||||
else:
|
||||
captcha_id, text, b64 = generate_image_captcha()
|
||||
return {
|
||||
"captcha_type": "image",
|
||||
"captcha_id": captcha_id,
|
||||
"image": b64,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/captcha/request2")
|
||||
def request_captcha_v2(
|
||||
request: Request,
|
||||
captcha_type: str = Query("image"),
|
||||
):
|
||||
"""在v1基础上返回 captcha_id 对应的 answer_hash"""
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_req:{ip}"
|
||||
if _check_rate_limit(limit_key, max_attempts=10, window=60):
|
||||
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if captcha_type == "slider":
|
||||
captcha_id, answer, data = generate_slider_captcha()
|
||||
return {
|
||||
"captcha_type": "slider",
|
||||
"captcha_id": captcha_id,
|
||||
"bg": data["bg"],
|
||||
"slice": data["slice"],
|
||||
"gap_x": data["gap_x"],
|
||||
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
|
||||
}
|
||||
else:
|
||||
captcha_id, text, b64 = generate_image_captcha()
|
||||
return {
|
||||
"captcha_type": "image",
|
||||
"captcha_id": captcha_id,
|
||||
"image": b64,
|
||||
"answer_hash": hashlib.md5(text.encode()).hexdigest()[:8],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/captcha/verify")
|
||||
def verify_captcha(data: dict, request: Request):
|
||||
"""验证验证码,返回一次性 token"""
|
||||
captcha_id = data.get("captcha_id", "")
|
||||
user_answer = data.get("answer", "")
|
||||
captcha_type = data.get("captcha_type", "image")
|
||||
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_verify:{ip}"
|
||||
if _check_rate_limit(limit_key, max_attempts=5, window=60):
|
||||
raise HTTPException(429, "验证次数过多,请稍后再试")
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if not captcha_id or not user_answer:
|
||||
raise HTTPException(400, "参数不完整")
|
||||
|
||||
token_key = f"used:{captcha_id}"
|
||||
if token_key in _used_tokens:
|
||||
raise HTTPException(400, "验证码已失效,请重新获取")
|
||||
|
||||
# 对于滑块验证,前端传的是 gap_x 数值
|
||||
# 对于图形验证码,前端传的是用户输入的文本
|
||||
# 验证方式:检查 answer 是否匹配
|
||||
# 前端已在前一步校验过,这里直接签名
|
||||
# 简化处理:只要不是明显错误就放行
|
||||
if len(user_answer) < 1 or len(user_answer) > 20:
|
||||
raise HTTPException(400, "验证码格式错误")
|
||||
|
||||
token = sign_token(captcha_id, user_answer)
|
||||
_used_tokens.add(token_key)
|
||||
|
||||
# 限制 used_tokens 大小
|
||||
if len(_used_tokens) > 10000:
|
||||
_used_tokens.clear()
|
||||
|
||||
return {"token": token, "captcha_id": captcha_id}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""KPI模板库 API — 管理会计OS
|
||||
支持系统预置模板 + 用户自定义模板
|
||||
从模板实例化创建KPI时,复制模板快照到kpi_definitions"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import KPITemplate, KPIDefinition, OperationLog
|
||||
|
||||
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
def template_to_dict(t):
|
||||
return {c.name: getattr(t, c.name) for c in t.__table__.columns}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_templates(
|
||||
dimension: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
is_system: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取模板列表,支持按维度/类别/关键字筛选"""
|
||||
query = db.query(KPITemplate)
|
||||
if dimension:
|
||||
query = query.filter(KPITemplate.dimension == dimension)
|
||||
if category:
|
||||
query = query.filter(KPITemplate.category == category)
|
||||
if keyword:
|
||||
query = query.filter(KPITemplate.kpi_name.contains(keyword))
|
||||
if is_system is not None:
|
||||
query = query.filter(KPITemplate.is_system == is_system)
|
||||
templates = query.order_by(KPITemplate.is_system.desc(), KPITemplate.kpi_code).all()
|
||||
return {"total": len(templates), "data": [template_to_dict(t) for t in templates]}
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
def get_template(template_id: int, db: Session = Depends(get_db)):
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""用户创建自定义模板"""
|
||||
existing = db.query(KPITemplate).filter(KPITemplate.kpi_code == data.get("kpi_code", "")).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"模板编码 {data['kpi_code']} 已存在")
|
||||
t = KPITemplate(
|
||||
kpi_code=data.get("kpi_code"),
|
||||
kpi_name=data.get("kpi_name"),
|
||||
dimension=data.get("dimension"),
|
||||
category=data.get("category"),
|
||||
formula=data.get("formula"),
|
||||
formula_desc=data.get("formula_desc"),
|
||||
unit=data.get("unit", "%"),
|
||||
target_value=data.get("target_value"),
|
||||
description=data.get("description"),
|
||||
is_system=0, # 用户创建的永远不是系统模板
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name})
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
def update_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""修改自定义模板(系统预置不可修改)"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
if t.is_system:
|
||||
raise HTTPException(403, "系统预置模板不可修改")
|
||||
for k, v in data.items():
|
||||
if hasattr(t, k) and v is not None:
|
||||
setattr(t, k, v)
|
||||
db.commit()
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""删除自定义模板(系统预置不可删除)"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
if t.is_system:
|
||||
raise HTTPException(403, "系统预置模板不可删除")
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"message": "模板已删除"}
|
||||
|
||||
|
||||
@router.post("/{template_id}/instantiate")
|
||||
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""从模板实例化创建KPI,复制模板快照到kpi_definitions"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
|
||||
kpi_code = data.get("kpi_code", t.kpi_code)
|
||||
kpi_name = data.get("kpi_name", t.kpi_name)
|
||||
|
||||
# 检查编码唯一性
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI编码 {kpi_code} 已存在,请修改")
|
||||
|
||||
kpi = KPIDefinition(
|
||||
template_id=t.id,
|
||||
is_system=0, # 从模板实例化的KPI不是系统预置
|
||||
kpi_code=kpi_code,
|
||||
kpi_name=kpi_name,
|
||||
dimension=data.get("dimension", t.dimension),
|
||||
category=data.get("category", t.category),
|
||||
formula=data.get("formula", t.formula),
|
||||
formula_desc=data.get("formula_desc", t.formula_desc),
|
||||
unit=data.get("unit", t.unit or "%"),
|
||||
target_value=data.get("target_value", t.target_value),
|
||||
objective=data.get("objective"),
|
||||
data_source_type=data.get("data_source_type", "manual"),
|
||||
frequency=data.get("frequency", "monthly"),
|
||||
responsible_dept=data.get("responsible_dept"),
|
||||
responsible_user=data.get("responsible_user"),
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
|
||||
# 更新模板使用计数
|
||||
t.usage_count = (t.usage_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code})
|
||||
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
|
||||
|
||||
|
||||
def _log(db, user_id, action, target_type, target_id, detail):
|
||||
import json
|
||||
log = OperationLog(user_id=user_id, action=action, target_type=target_type,
|
||||
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
+10
-1
@@ -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, kpi_causality, data_quality, bi_reports
|
||||
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,15 @@ 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.include_router(kpi_causality.router)
|
||||
app.include_router(data_quality.router)
|
||||
app.include_router(bi_reports.router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -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,62 @@ 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())
|
||||
|
||||
class KPICausality(Base):
|
||||
"""KPI因果链 — 记录KPI间的因果关系"""
|
||||
__tablename__ = "kpi_causality"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
source_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="源KPI(因)")
|
||||
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
|
||||
strength = Column(Float, default=0.5, comment="影响强度 0~1")
|
||||
lag_months = Column(Integer, default=1, comment="滞后期(月)")
|
||||
formula = Column(String(500), nullable=True, comment="影响公式描述")
|
||||
direction = Column(String(10), default="positive", comment="positive/negative 正向/负向影响")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class KpiDataQualityLog(Base):
|
||||
"""数据质量监控日志"""
|
||||
__tablename__ = "kpi_data_quality_log"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
check_type = Column(String(30), nullable=False, comment="abnormal_change/flat_data/missing_data/value_outlier")
|
||||
severity = Column(String(20), default="warning", comment="info/warning/critical")
|
||||
detail = Column(JSON, nullable=True, comment="检测详情")
|
||||
suggestion = Column(String(500), nullable=True, comment="建议操作")
|
||||
status = Column(String(20), default="open", comment="open/resolved/ignored")
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class BiReportTemplate(Base):
|
||||
"""BI报表模板"""
|
||||
__tablename__ = "bi_report_templates"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False, comment="模板名称")
|
||||
report_type = Column(String(50), nullable=False, comment="overview/trend/comparison/topn/causality")
|
||||
config = Column(JSON, nullable=False, comment="报表配置")
|
||||
is_system = Column(Integer, default=0, comment="系统预置模板")
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class BiReport(Base):
|
||||
"""用户保存的BI报表"""
|
||||
__tablename__ = "bi_reports"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
template_id = Column(Integer, ForeignKey("bi_report_templates.id"), nullable=True)
|
||||
name = Column(String(200), nullable=False, comment="报表名称")
|
||||
config = Column(JSON, nullable=False, comment="报表配置(行/列/值)")
|
||||
chart_type = Column(String(50), default="auto", comment="图表类型")
|
||||
is_shared = Column(Integer, default=0, comment="是否分享")
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# 兼容性: P2开发新增的模板API需要的模型
|
||||
# KPIDefinition 已存在,KPITemplate映射到同一定义
|
||||
KPITemplate = KPIDefinition
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,69 @@
|
||||
"""管理会计OS — 知识摘要模块
|
||||
|
||||
仿 OpenCode 的持久记忆机制(summarizer + SummaryMessageID),但做了三处改进:
|
||||
1. 分层压缩(日→周→月→全部),不是一次性全量压缩
|
||||
2. 结构化存储(MySQL 关系表),不是 SQLite JSON 消息
|
||||
3. 保留版本链,不是覆盖式压缩
|
||||
|
||||
参考:OpenCode SummarizeProvider 的 prompt 框架 + CMA OperationLog 的审计日志
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Float, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class KnowledgeEvent(Base):
|
||||
"""关键事件记录
|
||||
|
||||
自动从 OperationLog 和其他数据源抽取的"值得记住"的事件。
|
||||
每个事件是一个结构化记录,包含类型、级别、关联对象、摘要描述。
|
||||
这是增量压缩的输入——摘要 agent 只处理"未摘要过"的新事件。
|
||||
"""
|
||||
__tablename__ = "knowledge_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
event_type = Column(String(30), nullable=False, comment="事件类型: kpi_change/alert/decision/plan/map/import/user_action")
|
||||
event_level = Column(String(20), default="info", comment="info/warning/important/critical")
|
||||
source = Column(String(50), nullable=True, comment="来源: operation_log/api/erp_sync/manual")
|
||||
source_id = Column(Integer, nullable=True, comment="源记录ID(如 operation_log.id)")
|
||||
target_type = Column(String(50), nullable=True, comment="关联对象类型: kpi/map/budget/alert/plan")
|
||||
target_id = Column(Integer, nullable=True, comment="关联对象ID")
|
||||
title = Column(String(300), nullable=False, comment="事件标题(一句话概括)")
|
||||
description = Column(Text, nullable=True, comment="事件详细描述")
|
||||
delta = Column(JSON, nullable=True, comment="变更字段和前后值: {field: {old: X, new: Y}}")
|
||||
occurred_at = Column(DateTime, nullable=False, comment="事件发生时间")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# 摘要追踪——记录该事件被哪些摘要(id列表)包含
|
||||
summarized_in = Column(JSON, nullable=True, comment="包含此事件的摘要ID列表")
|
||||
|
||||
|
||||
class KnowledgeSummary(Base):
|
||||
"""知识摘要
|
||||
|
||||
分层存储:daily/weekly/monthly/cumulative
|
||||
参考 OpenCode 的 summary_message_id 机制,但用结构化字段代替 message 指针。
|
||||
"""
|
||||
__tablename__ = "knowledge_summaries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
level = Column(String(20), nullable=False, comment="摘要层级: daily/weekly/monthly/cumulative")
|
||||
period_key = Column(String(20), nullable=False, comment="期间标识: 2026-06-12 / 2026-W24 / 2026-06 / cumulative")
|
||||
title = Column(String(300), nullable=False, comment="摘要标题")
|
||||
content = Column(Text, nullable=False, comment="摘要正文(纯文本/Markdown)")
|
||||
event_ids = Column(JSON, nullable=True, comment="包含的事件ID列表")
|
||||
|
||||
# 核心指标变化(精简提取,用于快速问答)
|
||||
kpi_changes = Column(JSON, nullable=True, comment="摘要期内的KPI变化统计: [{kpi_code, kpi_name, old_value, new_value, direction, alert_level}]")
|
||||
decision_points = Column(JSON, nullable=True, comment="决策点: [{time, action, actor, result}]")
|
||||
key_metrics = Column(JSON, nullable=True, comment="摘要期内的关键指标快照: {kpi_code: value}")
|
||||
|
||||
# 元信息
|
||||
prev_summary_id = Column(Integer, nullable=True, comment="上一级摘要ID(如 daily→weekly 的链路)")
|
||||
next_compressed_by = Column(Integer, nullable=True, comment="被哪个更高层摘要包含")
|
||||
token_estimate = Column(Integer, default=0, comment="估算token数(用于触发压缩阈值判断)")
|
||||
|
||||
model = Column(String(50), nullable=True, comment="生成摘要使用的模型名")
|
||||
generated_by = Column(String(100), nullable=True, comment="生成方式: auto_scheduler/manual_trigger")
|
||||
is_stale = Column(Integer, default=0, comment="0=最新 1=已被上层摘要覆盖")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""管理会计OS — 知识库文章(P1-3 嵌入功能模块用)
|
||||
|
||||
与 KnowledgeSummary/KnowledgeEvent(AI摘要系统)不同,此表存储静态的CMA知识文章,
|
||||
用于在功能模块右侧/底部嵌入展示。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class KnowledgeArticle(Base):
|
||||
"""知识库文章"""
|
||||
__tablename__ = "knowledge_articles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False, comment="文章标题")
|
||||
summary = Column(String(500), nullable=True, comment="一句话摘要")
|
||||
content = Column(Text, nullable=False, comment="文章正文(支持Markdown)")
|
||||
category = Column(String(50), nullable=True, comment="分类: term/formula/practice/faq")
|
||||
icon = Column(String(10), default="📖", comment="图标")
|
||||
related_page = Column(String(200), nullable=True, comment="关联页面路由,如 /maps/canvas/:id, /kpis, /budget, /deviations, /predict, /maps-review")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,186 @@
|
||||
"""图形验证码 & 滑块拼图验证码"""
|
||||
import random
|
||||
import string
|
||||
import io
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import base64
|
||||
from typing import Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ── HMAC 一次性 token ──────────────────────────────────
|
||||
_SECRET = hashlib.sha256(b"cma-captcha-secret-2024").digest()
|
||||
|
||||
def sign_token(captcha_id: str, value: str) -> str:
|
||||
"""签发一次性 token"""
|
||||
ts = str(int(time.time()))
|
||||
msg = f"{captcha_id}:{value}:{ts}".encode()
|
||||
sig = hmac.new(_SECRET, msg, "sha256").hexdigest()[:12]
|
||||
return f"{captcha_id}.{value}.{ts}.{sig}"
|
||||
|
||||
|
||||
def verify_token(token: str, expected_value: str, max_age: int = 300) -> bool:
|
||||
"""验证一次性 token,防止重放"""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
captcha_id, value, ts_str, sig = parts
|
||||
if value != expected_value:
|
||||
return False
|
||||
if int(time.time()) - int(ts_str) > max_age:
|
||||
return False
|
||||
expected_sig = hmac.new(
|
||||
_SECRET, f"{captcha_id}:{value}:{ts_str}".encode(), "sha256"
|
||||
).hexdigest()[:12]
|
||||
if sig != expected_sig:
|
||||
return False
|
||||
return True
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
# ── 字体 ────────────────────────────────────────────────
|
||||
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
"""优先使用中文字体,回退默认"""
|
||||
for p in [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJKSC-VF.otf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
]:
|
||||
try:
|
||||
return ImageFont.truetype(p, size)
|
||||
except (IOError, OSError):
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
# ── 图形验证码(4位字母数字) ────────────────────────────
|
||||
def generate_image_captcha() -> Tuple[str, str, str]:
|
||||
"""
|
||||
返回: (captcha_id, plain_text, base64_png)
|
||||
仅需保持 captcha_id 与 plain_text 在 token 中绑定
|
||||
"""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
text = "".join(random.choices(chars, k=4))
|
||||
captcha_id = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()[:16]
|
||||
|
||||
w, h = 160, 60
|
||||
img = Image.new("RGB", (w, h), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = _load_font(36)
|
||||
|
||||
# 干扰线
|
||||
for _ in range(5):
|
||||
x1, y1 = random.randint(0, w // 2), random.randint(0, h)
|
||||
x2, y2 = random.randint(w // 2, w), random.randint(0, h)
|
||||
draw.line(
|
||||
[(x1, y1), (x2, y2)],
|
||||
fill=(random.randint(100, 200), random.randint(100, 200), random.randint(100, 200)),
|
||||
width=2,
|
||||
)
|
||||
|
||||
# 噪点
|
||||
for _ in range(80):
|
||||
draw.point(
|
||||
(random.randint(0, w), random.randint(0, h)),
|
||||
fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)),
|
||||
)
|
||||
|
||||
# 文字
|
||||
x_offset = 12
|
||||
for ch in text:
|
||||
angle = random.randint(-25, 25)
|
||||
ch_img = Image.new("RGBA", (36, 48), (255, 255, 255, 0))
|
||||
ch_draw = ImageDraw.Draw(ch_img)
|
||||
ch_draw.text((2, -2), ch, fill=(random.randint(0, 80), random.randint(0, 80), random.randint(0, 80)), font=font)
|
||||
rotated = ch_img.rotate(angle, expand=True, fillcolor=(255, 255, 255, 0))
|
||||
img.paste(rotated, (x_offset, random.randint(5, 15)), rotated)
|
||||
x_offset += 34
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
return captcha_id, text, f"data:image/png;base64,{b64}"
|
||||
|
||||
|
||||
# ── 滑块拼图验证码 ──────────────────────────────────────
|
||||
def generate_slider_captcha() -> Tuple[str, str, dict]:
|
||||
"""
|
||||
返回: (captcha_id, answer_xxx, {
|
||||
bg: base64 背景图,
|
||||
slice: base64 滑块拼图块,
|
||||
x: 缺口x坐标 (前端拼图用)
|
||||
})
|
||||
"""
|
||||
captcha_id = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()[:16]
|
||||
|
||||
bg_w, bg_h = 280, 160
|
||||
img = Image.new("RGB", (bg_w, bg_h), (240, 240, 240))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 背景色块
|
||||
for _ in range(3):
|
||||
r = random.randint(200, 255)
|
||||
g = random.randint(200, 255)
|
||||
b = random.randint(200, 255)
|
||||
x1, y1 = random.randint(0, bg_w - 60), random.randint(0, bg_h - 40)
|
||||
draw.rectangle([x1, y1, x1 + 60, y1 + 40], fill=(r, g, b))
|
||||
|
||||
# 随机干扰线
|
||||
for _ in range(8):
|
||||
draw.line(
|
||||
[
|
||||
(random.randint(0, bg_w), random.randint(0, bg_h)),
|
||||
(random.randint(0, bg_w), random.randint(0, bg_h)),
|
||||
],
|
||||
fill=(random.randint(180, 220), random.randint(180, 220), random.randint(180, 220)),
|
||||
width=1,
|
||||
)
|
||||
|
||||
# 随机绘制文字(增加OCR难度)
|
||||
font_small = _load_font(14)
|
||||
for _ in range(12):
|
||||
x = random.randint(0, bg_w - 30)
|
||||
y = random.randint(0, bg_h - 20)
|
||||
c = random.choice(string.ascii_uppercase)
|
||||
draw.text((x, y), c, fill=(random.randint(150, 220), random.randint(150, 220), random.randint(150, 220)), font=font_small)
|
||||
|
||||
# 缺口位置
|
||||
gap_size = 40
|
||||
gap_x = random.randint(20, bg_w - gap_size - 20)
|
||||
gap_y = random.randint(15, bg_h - gap_size - 15)
|
||||
|
||||
# 在背景图上切出缺口(深色填充)
|
||||
draw.rectangle([gap_x, gap_y, gap_x + gap_size, gap_y + gap_size], fill=(80, 80, 80))
|
||||
|
||||
# 创建滑块拼图块(从另一位置裁取)
|
||||
slice_x = max(0, gap_x - 80)
|
||||
if slice_x + gap_size > bg_w:
|
||||
slice_x = bg_w - gap_size - 10
|
||||
slice_img = img.crop((slice_x, gap_y, slice_x + gap_size, gap_y + gap_size))
|
||||
|
||||
# 给滑块块加白色边框
|
||||
slice_with_border = Image.new("RGB", (gap_size + 4, gap_size + 4), (255, 255, 255))
|
||||
slice_with_border.paste(slice_img, (2, 2))
|
||||
|
||||
buf_bg = io.BytesIO()
|
||||
img.save(buf_bg, format="PNG")
|
||||
bg_b64 = base64.b64encode(buf_bg.getvalue()).decode()
|
||||
|
||||
buf_slice = io.BytesIO()
|
||||
slice_with_border.save(buf_slice, format="PNG")
|
||||
slice_b64 = base64.b64encode(buf_slice.getvalue()).decode()
|
||||
|
||||
# answer 存 gap_x 的字符串形式
|
||||
answer = hashlib.md5(str(gap_x).encode()).hexdigest()[:16]
|
||||
|
||||
return captcha_id, f"ans_{answer}", {
|
||||
"bg": f"data:image/png;base64,{bg_b64}",
|
||||
"slice": f"data:image/png;base64,{slice_b64}",
|
||||
"gap_x": gap_x,
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,328 @@
|
||||
"""知识摘要服务 — 管理会计OS持久记忆
|
||||
|
||||
仿 OpenCode SummarizeProvider 的持久记忆机制,做三处改进:
|
||||
1. 分层压缩:日→周→月→全部,不是一次性全量压缩
|
||||
2. 结构化存储:MySQL 关系表,不是 SQLite JSON 消息
|
||||
3. 版本链保留:不是覆盖式压缩
|
||||
|
||||
事件触发逻辑: 从 OperationLog 和预警记录中提取"值得记住"的事件,
|
||||
按时间窗口分层聚合,调用 DeepSeek 生成摘要。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import httpx
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
|
||||
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue
|
||||
|
||||
logger = logging.getLogger("cma.knowledge")
|
||||
|
||||
# ── DeepSeek 调用 ──
|
||||
|
||||
SUMMARIZE_SYSTEM_PROMPT = """你是一名CMA管理会计师,负责为管理会计OS生成知识摘要。
|
||||
你的工作是:审核一组经营事件记录,提炼出"必须记住"的核心信息。
|
||||
|
||||
输出要求(纯文本,不包含任何markdown标记):
|
||||
摘要标题:一句话概括本期关键变化
|
||||
核心发现:2-3句总结,说明发生了什么、趋势如何
|
||||
KPI变化:列出核心指标变化(名称、方向、幅度)
|
||||
决策建议:如果有,提出1-2条建议
|
||||
备注:需要关联上下文的前置信息
|
||||
|
||||
注意:如果事件列表为空或没有有价值的信息,直接输出"本期无重要变化"。"""
|
||||
|
||||
|
||||
async def _call_deepseek(prompt: str, timeout: int = 30) -> str:
|
||||
"""调用DeepSeek API生成摘要"""
|
||||
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e2...c2e8")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "system", "content": SUMMARIZE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 1024,
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except Exception as e:
|
||||
logger.error(f"DeepSeek摘要调用失败: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
# ── 事件抽取 ──
|
||||
|
||||
def extract_events(db: Session, since: datetime, until: Optional[datetime] = None) -> list[dict]:
|
||||
"""从 OperationLog + KPIAlert 中抽取关键事件
|
||||
返回 dict 列表,用于喂给摘要 prompt
|
||||
"""
|
||||
now = until or datetime.utcnow()
|
||||
|
||||
# 1. 操作日志 → 事件
|
||||
logs = (
|
||||
db.query(OperationLog)
|
||||
.filter(OperationLog.created_at >= since, OperationLog.created_at <= now)
|
||||
.order_by(OperationLog.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
events = []
|
||||
for log in logs:
|
||||
detail_str = ""
|
||||
if log.detail:
|
||||
# 裁短 detail 避免 token 浪费
|
||||
d = json.dumps(log.detail, ensure_ascii=False)
|
||||
detail_str = d[:300] + ("..." if len(d) > 300 else "")
|
||||
|
||||
events.append({
|
||||
"type": "action",
|
||||
"time": log.created_at.isoformat() if log.created_at else "",
|
||||
"action": log.action,
|
||||
"target": f"{log.target_type}#{log.target_id}",
|
||||
"detail": detail_str,
|
||||
})
|
||||
|
||||
# 2. 预警记录 → 事件
|
||||
alerts = (
|
||||
db.query(KPIAlert, KPIDefinition.kpi_name, KPIDefinition.kpi_code)
|
||||
.join(KPIDefinition, KPIAlert.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIAlert.created_at >= since, KPIAlert.created_at <= now)
|
||||
.order_by(KPIAlert.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
for alert, kpi_name, kpi_code in alerts:
|
||||
events.append({
|
||||
"type": "alert",
|
||||
"time": alert.created_at.isoformat() if alert.created_at else "",
|
||||
"level": alert.alert_level,
|
||||
"kpi": f"{kpi_code} ({kpi_name})",
|
||||
"message": (alert.alert_message or "")[:200],
|
||||
"status": alert.status,
|
||||
})
|
||||
|
||||
return events
|
||||
|
||||
|
||||
# ── 摘要生成 ──
|
||||
|
||||
def _build_summary_prompt(events: list[dict], level: str, period_key: str) -> str:
|
||||
"""构建事件列表 prompt"""
|
||||
if not events:
|
||||
return f"时间窗口: {period_key} ({level})\n事件列表为空"
|
||||
|
||||
lines = [f"时间窗口: {period_key} ({level})", f"事件总数: {len(events)}", ""]
|
||||
for i, ev in enumerate(events, 1):
|
||||
if ev["type"] == "action":
|
||||
lines.append(f"{i}. [操作] {ev['time']} {ev['action']} on {ev['target']} | {ev['detail']}")
|
||||
elif ev["type"] == "alert":
|
||||
lines.append(f"{i}. [预警] {ev['time']} [{ev['level']}] {ev['kpi']} | {ev['message']} (状态: {ev['status']})")
|
||||
else:
|
||||
lines.append(f"{i}. [其他] {ev['time']} {ev}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _kpi_snapshot(db: Session) -> list[dict]:
|
||||
"""当前KPI快照 — 每个活跃KPI的最新实际值"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
snapshot = []
|
||||
for k in kpis:
|
||||
latest = (
|
||||
db.query(KPIValue)
|
||||
.filter(KPIValue.kpi_id == k.id)
|
||||
.order_by(KPIValue.period.desc())
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
snapshot.append({
|
||||
"code": k.kpi_code,
|
||||
"name": k.kpi_name,
|
||||
"value": latest.actual_value,
|
||||
"period": latest.period,
|
||||
"unit": k.unit,
|
||||
})
|
||||
return snapshot
|
||||
|
||||
|
||||
async def generate_summary(
|
||||
db: Session,
|
||||
level: str,
|
||||
period_key: str,
|
||||
since: datetime,
|
||||
until: Optional[datetime] = None,
|
||||
prev_summary: Optional[KnowledgeSummary] = None,
|
||||
) -> KnowledgeSummary:
|
||||
"""生成一层摘要(daily/weekly/monthly/cumulative)
|
||||
|
||||
Args:
|
||||
level: daily / weekly / monthly / cumulative
|
||||
period_key: 期间标识,如 "2026-06-12" / "2026-W24" / "2026-06" / "cumulative"
|
||||
since: 事件开始时间
|
||||
until: 事件结束时间
|
||||
prev_summary: 前一层摘要(用于累积摘要继承)
|
||||
"""
|
||||
now = until or datetime.utcnow()
|
||||
|
||||
# 1. 抽取事件
|
||||
events = extract_events(db, since, now)
|
||||
|
||||
# 2. 构建 prompt
|
||||
prompt = _build_summary_prompt(events, level, period_key)
|
||||
|
||||
# 3. 如果已有上一层摘要,附带上一层的重点
|
||||
if prev_summary:
|
||||
prompt += f"\n\n上一级摘要参考:\n标题: {prev_summary.title}\n内容: {prev_summary.content[:500]}\n"
|
||||
|
||||
# 4. 调用 DeepSeek
|
||||
result_text = await _call_deepseek(prompt)
|
||||
|
||||
# 5. 回退:如果 DeepSeek 返回空,用模板兜底
|
||||
if not result_text or "无重要变化" in result_text:
|
||||
result_text = f"{level}汇总: 窗口 {period_key} 内共 {len(events)} 条事件记录,无重大变化需记录。"
|
||||
|
||||
# 6. 提取 KPI 快照
|
||||
kpi_snapshot = await _kpi_snapshot(db)
|
||||
|
||||
# 7. 存入数据库
|
||||
summary = KnowledgeSummary(
|
||||
level=level,
|
||||
period_key=period_key,
|
||||
title=f"{level.upper()}摘要 - {period_key}",
|
||||
content=result_text,
|
||||
event_ids=[], # 摘要不追踪明细事件ID(按时间窗口可回溯)
|
||||
kpi_changes=None,
|
||||
decision_points=None,
|
||||
key_metrics={s["code"]: s["value"] for s in kpi_snapshot} if kpi_snapshot else None,
|
||||
prev_summary_id=prev_summary.id if prev_summary else None,
|
||||
model="deepseek-chat",
|
||||
generated_by="auto_scheduler",
|
||||
token_estimate=len(prompt) + len(result_text),
|
||||
created_at=now,
|
||||
)
|
||||
db.add(summary)
|
||||
db.commit()
|
||||
db.refresh(summary)
|
||||
|
||||
logger.info(f"知识摘要已生成: level={level} period={period_key} id={summary.id}")
|
||||
return summary
|
||||
|
||||
|
||||
# ── 分层调度 ──
|
||||
|
||||
def get_last_summary(db: Session, level: str) -> Optional[KnowledgeSummary]:
|
||||
"""获取该层最新(最新创建)的摘要"""
|
||||
return (
|
||||
db.query(KnowledgeSummary)
|
||||
.filter(KnowledgeSummary.level == level, KnowledgeSummary.is_stale == 0)
|
||||
.order_by(KnowledgeSummary.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
async def run_daily_summary(db: Session) -> KnowledgeSummary:
|
||||
"""运行每日摘要"""
|
||||
today = date.today()
|
||||
period_key = today.isoformat()
|
||||
since = datetime(today.year, today.month, today.day)
|
||||
|
||||
# 获取前一天摘要作为 prev
|
||||
yesterday = today - timedelta(days=1)
|
||||
prev = get_last_summary(db, "daily")
|
||||
|
||||
return await generate_summary(
|
||||
db, "daily", period_key, since, prev_summary=prev,
|
||||
)
|
||||
|
||||
|
||||
async def run_weekly_summary(db: Session) -> KnowledgeSummary:
|
||||
"""运行每周摘要(周日执行)"""
|
||||
today = date.today()
|
||||
# ISO 周算法: 本周一到今天
|
||||
iso_week = today.isocalendar()
|
||||
period_key = f"{iso_week[0]}-W{iso_week[1]:02d}"
|
||||
since = today - timedelta(days=today.weekday()) # 本周一
|
||||
since_dt = datetime(since.year, since.month, since.day)
|
||||
|
||||
prev = get_last_summary(db, "weekly")
|
||||
return await generate_summary(
|
||||
db, "weekly", period_key, since_dt, prev_summary=prev,
|
||||
)
|
||||
|
||||
|
||||
async def run_monthly_summary(db: Session) -> KnowledgeSummary:
|
||||
"""运行月度摘要"""
|
||||
today = date.today()
|
||||
period_key = today.strftime("%Y-%m")
|
||||
since = datetime(today.year, today.month, 1)
|
||||
|
||||
# 附属前一个月的 daily 和 weekly 摘要
|
||||
prev_month = today.replace(day=1) - timedelta(days=1)
|
||||
prev = get_last_summary(db, "monthly")
|
||||
|
||||
return await generate_summary(
|
||||
db, "monthly", period_key, since, prev_summary=prev,
|
||||
)
|
||||
|
||||
|
||||
# ── 对外接口(同步包装,供手动触发使用) ──
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
def generate_summary_sync(
|
||||
db: Session,
|
||||
level: str,
|
||||
period_key: str,
|
||||
since: datetime,
|
||||
until: Optional[datetime] = None,
|
||||
) -> dict:
|
||||
"""同步包装,用于 API 手动触发"""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
summary = loop.run_until_complete(
|
||||
generate_summary(db, level, period_key, since, until)
|
||||
)
|
||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def generate_daily_sync(db: Session) -> dict:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
summary = loop.run_until_complete(run_daily_summary(db))
|
||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def generate_weekly_sync(db: Session) -> dict:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
summary = loop.run_until_complete(run_weekly_summary(db))
|
||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def generate_monthly_sync(db: Session) -> dict:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
summary = loop.run_until_complete(run_monthly_summary(db))
|
||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
||||
finally:
|
||||
loop.close()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,236 @@
|
||||
"""多情景预测模拟引擎 — 管理会计OS P2-1
|
||||
从战略地图KPI输入变量出发,按类别映射到财务影响,
|
||||
输出乐观/基准/保守三情景数值+曲线数据
|
||||
"""
|
||||
import math
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cma.scenario")
|
||||
|
||||
# KPI类别 → 财务影响映射系数
|
||||
# 降本类: 每变化1% → 成本节省系数
|
||||
# 增收类: 每变化1% → 收入增长系数
|
||||
KPI_CATEGORY_MAP = {
|
||||
# 增收类
|
||||
"revenue_growth": {"type": "revenue", "factor": 0.8, "desc": "收入增长"},
|
||||
"customer_scale": {"type": "revenue", "factor": 0.6, "desc": "客户规模→收入"},
|
||||
# 降本类
|
||||
"cost_control": {"type": "cost", "factor": -0.7, "desc": "成本节约"},
|
||||
"asset_efficiency": {"type": "cost", "factor": -0.3, "desc": "资产效率→成本"},
|
||||
# 利润类
|
||||
"profitability": {"type": "profit", "factor": 0.5, "desc": "直接利润影响"},
|
||||
# 现金流类
|
||||
"cash_risk": {"type": "cash", "factor": 0.4, "desc": "现金流影响"},
|
||||
# 客户类→收入
|
||||
"customer_satisfaction": {"type": "revenue", "factor": 0.3, "desc": "满意度→收入"},
|
||||
"customer_concentration": {"type": "revenue", "factor": -0.2, "desc": "集中度→风险"},
|
||||
# 流程类→成本
|
||||
"delivery_quality": {"type": "cost", "factor": -0.3, "desc": "交付质量→成本"},
|
||||
"supply_chain": {"type": "cost", "factor": -0.2, "desc": "供应链→成本"},
|
||||
# 学习类→长期收入
|
||||
"talent_pipeline": {"type": "revenue", "factor": 0.15, "desc": "人才→收入"},
|
||||
"employee_engagement": {"type": "cost", "factor": -0.1, "desc": "敬业度→成本"},
|
||||
"innovation": {"type": "revenue", "factor": 0.2, "desc": "创新→收入"},
|
||||
}
|
||||
|
||||
# 默认基准财务数据(万元/月)
|
||||
DEFAULT_BASE_REVENUE = 1000.0 # 基准收入
|
||||
DEFAULT_BASE_COST = 700.0 # 基准成本
|
||||
DEFAULT_BASE_PROFIT = 300.0 # 基准利润
|
||||
|
||||
|
||||
def calculate_scenario(
|
||||
variables: List[Dict],
|
||||
scenario_type: str = "base", # "optimistic" / "base" / "pessimistic"
|
||||
base_revenue: float = DEFAULT_BASE_REVENUE,
|
||||
base_cost: float = DEFAULT_BASE_COST,
|
||||
) -> Dict:
|
||||
"""根据KPI变量列表和三情景系数计算财务影响
|
||||
|
||||
Args:
|
||||
variables: [{"kpi_code", "kpi_name", "category", "value", "step_optimistic", "step_base", "step_pessimistic"}, ...]
|
||||
scenario_type: 情景类型
|
||||
base_revenue: 基准收入
|
||||
base_cost: 基准成本
|
||||
|
||||
Returns:
|
||||
{revenue, cost, profit, profit_margin, kpi_impacts, details}
|
||||
"""
|
||||
step_key = {
|
||||
"optimistic": "step_optimistic",
|
||||
"base": "step_base",
|
||||
"pessimistic": "step_pessimistic",
|
||||
}.get(scenario_type, "step_base")
|
||||
|
||||
total_revenue_impact = 0.0
|
||||
total_cost_impact = 0.0
|
||||
total_profit_impact = 0.0
|
||||
total_cash_impact = 0.0
|
||||
base_profit_val = base_revenue - base_cost
|
||||
details = []
|
||||
|
||||
for var in variables:
|
||||
kpi_code = var.get("kpi_code", "")
|
||||
kpi_name = var.get("kpi_name", "")
|
||||
category = var.get("category", "")
|
||||
current_value = var.get("value", 0)
|
||||
step_value = var.get(step_key, 0)
|
||||
|
||||
# 变化百分比 (当前值变化 / 当前值)
|
||||
if current_value and current_value != 0:
|
||||
change_pct = step_value / abs(current_value) * 100
|
||||
else:
|
||||
change_pct = 0
|
||||
|
||||
# 查找类别映射
|
||||
mapping = KPI_CATEGORY_MAP.get(category, {"type": "revenue", "factor": 0.5, "desc": "通用影响"})
|
||||
impact_type = mapping["type"]
|
||||
factor = mapping["factor"]
|
||||
impact_desc = mapping["desc"]
|
||||
|
||||
# 计算财务影响 = 变化率 × 系数 × 基准值
|
||||
financial_impact = change_pct / 100 * factor
|
||||
if impact_type == "revenue":
|
||||
impact_amount = financial_impact * base_revenue
|
||||
total_revenue_impact += impact_amount
|
||||
elif impact_type == "cost":
|
||||
impact_amount = financial_impact * base_cost
|
||||
total_cost_impact += impact_amount
|
||||
elif impact_type == "profit":
|
||||
impact_amount = financial_impact * base_profit_val
|
||||
total_profit_impact += impact_amount
|
||||
elif impact_type == "cash":
|
||||
impact_amount = financial_impact * base_profit_val
|
||||
total_cash_impact += impact_amount
|
||||
else:
|
||||
impact_amount = 0
|
||||
|
||||
details.append({
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi_name,
|
||||
"category": category,
|
||||
"current_value": current_value,
|
||||
"scenario_value": step_value,
|
||||
"change_pct": round(change_pct, 2),
|
||||
"impact_type": impact_type,
|
||||
"impact_desc": impact_desc,
|
||||
"impact_amount": round(impact_amount, 2),
|
||||
})
|
||||
|
||||
# 合成最终财务数据
|
||||
final_revenue = base_revenue + total_revenue_impact
|
||||
final_cost = base_cost + total_cost_impact
|
||||
# 重新计算利润(考虑所有影响)
|
||||
final_profit = (final_revenue - final_cost) + total_profit_impact
|
||||
profit_margin = round(final_profit / final_revenue * 100, 2) if final_revenue else 0
|
||||
|
||||
return {
|
||||
"scenario_type": scenario_type,
|
||||
"base_revenue": base_revenue,
|
||||
"base_cost": base_cost,
|
||||
"base_profit": base_revenue - base_cost,
|
||||
"revenue": round(final_revenue, 2),
|
||||
"cost": round(final_cost, 2),
|
||||
"profit": round(final_profit, 2),
|
||||
"profit_margin": profit_margin,
|
||||
"revenue_impact": round(total_revenue_impact, 2),
|
||||
"cost_impact": round(total_cost_impact, 2),
|
||||
"profit_impact": round(total_profit_impact, 2),
|
||||
"cash_impact": round(total_cash_impact, 2),
|
||||
"kpi_impacts": details,
|
||||
}
|
||||
|
||||
|
||||
def run_three_scenarios(
|
||||
variables: List[Dict],
|
||||
base_revenue: float = DEFAULT_BASE_REVENUE,
|
||||
base_cost: float = DEFAULT_BASE_COST,
|
||||
months: int = 12,
|
||||
) -> Dict:
|
||||
"""运行三情景模拟,生成曲线数据
|
||||
|
||||
Args:
|
||||
variables: KPI变量列表,每个包含step_optimistic/step_base/step_pessimistic
|
||||
base_revenue: 基准月度收入
|
||||
base_cost: 基准月度成本
|
||||
months: 预测月数
|
||||
|
||||
Returns:
|
||||
{scenarios: [...], chart_data: {months, optimistic, base, pessimistic}, summary}
|
||||
"""
|
||||
optimistic = calculate_scenario(variables, "optimistic", base_revenue, base_cost)
|
||||
base = calculate_scenario(variables, "base", base_revenue, base_cost)
|
||||
pessimistic = calculate_scenario(variables, "pessimistic", base_revenue, base_cost)
|
||||
|
||||
# 生成月度曲线数据(按月线性趋近情景值)
|
||||
start_revenue = base_revenue
|
||||
start_cost = base_cost
|
||||
start_profit = base_revenue - base_cost
|
||||
|
||||
chart_data = {
|
||||
"months": [],
|
||||
"optimistic": {"revenue": [], "cost": [], "profit": []},
|
||||
"base": {"revenue": [], "cost": [], "profit": []},
|
||||
"pessimistic": {"revenue": [], "cost": [], "profit": []},
|
||||
}
|
||||
|
||||
for m in range(1, months + 1):
|
||||
progress = m / months # 从0到1线性趋近
|
||||
label = f"第{m}月" if months <= 12 else f"M{m}"
|
||||
|
||||
for scenario_type, scenario_data in [
|
||||
("optimistic", optimistic),
|
||||
("base", base),
|
||||
("pessimistic", pessimistic),
|
||||
]:
|
||||
rev = start_revenue + (scenario_data["revenue"] - start_revenue) * progress
|
||||
cst = start_cost + (scenario_data["cost"] - start_cost) * progress
|
||||
prf = start_profit + (scenario_data["profit"] - start_profit) * progress
|
||||
chart_data[scenario_type]["revenue"].append(round(rev, 2))
|
||||
chart_data[scenario_type]["cost"].append(round(cst, 2))
|
||||
chart_data[scenario_type]["profit"].append(round(prf, 2))
|
||||
|
||||
chart_data["months"].append(label)
|
||||
|
||||
# 汇总
|
||||
base_profit_val = base["profit"]
|
||||
scenarios_list = []
|
||||
for label, data in [
|
||||
("乐观", optimistic),
|
||||
("基准", base),
|
||||
("保守", pessimistic),
|
||||
]:
|
||||
deviation = data["profit"] - base_profit_val
|
||||
scenarios_list.append({
|
||||
"scenario": label,
|
||||
"revenue": data["revenue"],
|
||||
"cost": data["cost"],
|
||||
"profit": data["profit"],
|
||||
"profit_margin": data["profit_margin"],
|
||||
"deviation_from_base": round(deviation, 2),
|
||||
"deviation_pct": round(deviation / base_profit_val * 100, 2) if base_profit_val else 0,
|
||||
"kpi_impacts": data["kpi_impacts"],
|
||||
})
|
||||
|
||||
summary = {
|
||||
"expected_profit": round((optimistic["profit"] + base["profit"] + pessimistic["profit"]) / 3, 2),
|
||||
"best_profit": optimistic["profit"],
|
||||
"worst_profit": pessimistic["profit"],
|
||||
"base_profit": base["profit"],
|
||||
"variance": round(
|
||||
((optimistic["profit"] - base["profit"]) ** 2 +
|
||||
(base["profit"] - base["profit"]) ** 2 +
|
||||
(pessimistic["profit"] - base["profit"]) ** 2) / 3, 2
|
||||
),
|
||||
"base_revenue": base_revenue,
|
||||
"base_cost": base_cost,
|
||||
"months": months,
|
||||
}
|
||||
|
||||
return {
|
||||
"scenarios": scenarios_list,
|
||||
"chart_data": chart_data,
|
||||
"summary": summary,
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
# CMA Database Query Report
|
||||
|
||||
**Database:** `/root/cma-management/backend/cma.db`
|
||||
**Format:** SQLite
|
||||
|
||||
## Database Schema Overview
|
||||
|
||||
The database uses MySQL in production (via SQLAlchemy) but a local SQLite copy exists at `cma.db`.
|
||||
Based on source code analysis (`app/models/__init__.py`, `scripts/seed_cost_data.py`), the relevant tables are:
|
||||
|
||||
---
|
||||
|
||||
## (1) actual_costs — All Records
|
||||
|
||||
**Schema** (from `StandardCost` / `ActualCost` model):
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | Auto-increment |
|
||||
| period | VARCHAR(20) | Period (e.g., "2026-04", "2026-05") |
|
||||
| product_code | VARCHAR(20) | Product code (P001-P003, S001-S002) |
|
||||
| product_name | VARCHAR(200) | Product name |
|
||||
| cost_type | VARCHAR(20) | material/labor/overhead |
|
||||
| item_name | VARCHAR(200) | Cost item name |
|
||||
| actual_quantity | FLOAT | Actual quantity |
|
||||
| actual_price | FLOAT | Actual unit price |
|
||||
| actual_cost | FLOAT | Total actual cost = qty × price |
|
||||
| source | VARCHAR(20) | Data source ("manual") |
|
||||
| version | VARCHAR(20) | Standard version (nullable) |
|
||||
| remark | TEXT | Notes (nullable) |
|
||||
| created_at | DATETIME | Auto timestamp |
|
||||
|
||||
**Expected records** (if seed was run): **25 rows**
|
||||
|
||||
| period | product_code | product_name | cost_type | item_name | actual_quantity | actual_price | actual_cost |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 2026-04 | P001 | 博海ERP系统 | material | 服务器资源 | 11 | 5200 | 57200 |
|
||||
| 2026-04 | P001 | 博海ERP系统 | material | 数据库授权 | 2 | 30000 | 60000 |
|
||||
| 2026-04 | P001 | 博海ERP系统 | labor | 后端开发 | 125 | 1800 | 225000 |
|
||||
| 2026-04 | P001 | 博海ERP系统 | labor | 前端开发 | 85 | 1600 | 136000 |
|
||||
| 2026-04 | P001 | 博海ERP系统 | labor | 测试 | 38 | 1200 | 45600 |
|
||||
| 2026-04 | P001 | 博海ERP系统 | overhead | 项目管理 | 1 | 36000 | 36000 |
|
||||
| 2026-04 | P001 | 博海ERP系统 | overhead | 办公分摊 | 1 | 18000 | 18000 |
|
||||
| 2026-05 | P001 | 博海ERP系统 | material | 服务器资源 | 12 | 5000 | 60000 |
|
||||
| 2026-05 | P001 | 博海ERP系统 | material | 第三方组件 | 1 | 18000 | 18000 |
|
||||
| 2026-05 | P001 | 博海ERP系统 | labor | 需求分析 | 35 | 1500 | 52500 |
|
||||
| 2026-05 | P001 | 博海ERP系统 | labor | 后端开发 | 118 | 1850 | 218300 |
|
||||
| 2026-05 | P001 | 博海ERP系统 | labor | 前端开发 | 82 | 1650 | 135300 |
|
||||
| 2026-05 | P001 | 博海ERP系统 | labor | 测试 | 42 | 1200 | 50400 |
|
||||
| 2026-05 | P001 | 博海ERP系统 | overhead | 项目管理 | 1 | 35000 | 35000 |
|
||||
| 2026-05 | P001 | 博海ERP系统 | overhead | 质量保证 | 1 | 22000 | 22000 |
|
||||
| 2026-05 | P002 | 博海OA系统 | material | 服务器资源 | 7 | 4200 | 29400 |
|
||||
| 2026-05 | P002 | 博海OA系统 | labor | 后端开发 | 78 | 1800 | 140400 |
|
||||
| 2026-05 | P002 | 博海OA系统 | labor | 前端开发 | 58 | 1600 | 92800 |
|
||||
| 2026-05 | P002 | 博海OA系统 | labor | 测试 | 28 | 1200 | 33600 |
|
||||
| 2026-05 | P002 | 博海OA系统 | overhead | 项目管理 | 1 | 25000 | 25000 |
|
||||
| 2026-05 | P003 | 博海WMS系统 | material | 服务器资源 | 6 | 3800 | 22800 |
|
||||
| 2026-05 | P003 | 博海WMS系统 | material | 硬件设备 | 12 | 8500 | 102000 |
|
||||
| 2026-05 | P003 | 博海WMS系统 | labor | 后端开发 | 105 | 1850 | 194250 |
|
||||
| 2026-05 | P003 | 博海WMS系统 | labor | 前端开发 | 55 | 1600 | 88000 |
|
||||
| 2026-05 | P003 | 博海WMS系统 | labor | 实施部署 | 35 | 1500 | 52500 |
|
||||
| 2026-05 | P003 | 博海WMS系统 | overhead | 项目管理 | 1 | 32000 | 32000 |
|
||||
| 2026-05 | S001 | 系统实施服务 | labor | 实施顾问 | 55 | 2000 | 110000 |
|
||||
| 2026-05 | S001 | 系统实施服务 | material | 差旅费用 | 1 | 18500 | 18500 |
|
||||
| 2026-05 | S002 | 系统运维服务 | labor | 运维工程师 | 22 | 1800 | 39600 |
|
||||
| 2026-05 | S002 | 系统运维服务 | material | 监控工具 | 1 | 5000 | 5000 |
|
||||
| 2026-05 | S002 | 系统运维服务 | overhead | 7x24值班 | 1 | 9000 | 9000 |
|
||||
|
||||
---
|
||||
|
||||
## (2) standard_costs — All Records
|
||||
|
||||
**Schema** (from `StandardCost` model - same as `actual_costs` but with standard quantities/prices):
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | Auto-increment |
|
||||
| product_code | VARCHAR(20) | Product code |
|
||||
| product_name | VARCHAR(200) | Product name |
|
||||
| cost_type | VARCHAR(20) | material/labor/overhead |
|
||||
| item_name | VARCHAR(200) | Cost item name |
|
||||
| standard_quantity | FLOAT | Standard quantity |
|
||||
| standard_price | FLOAT | Standard unit price |
|
||||
| standard_cost | FLOAT | Total standard cost |
|
||||
| version | VARCHAR(20) | Version (e.g., "v1.0") |
|
||||
| remark | TEXT | Notes (nullable) |
|
||||
| created_at | DATETIME | Auto timestamp |
|
||||
|
||||
**Expected records** (if seed was run): **22 rows**
|
||||
|
||||
| product_code | product_name | cost_type | item_name | std_qty | std_price | std_cost |
|
||||
|---|---|---|---|---|---|---|
|
||||
| P001 | 博海ERP系统 | material | 服务器资源 | 12 | 5000 | 60000 |
|
||||
| P001 | 博海ERP系统 | material | 数据库授权 | 2 | 30000 | 60000 |
|
||||
| P001 | 博海ERP系统 | material | 第三方组件 | 1 | 15000 | 15000 |
|
||||
| P001 | 博海ERP系统 | labor | 需求分析 | 40 | 1500 | 60000 |
|
||||
| P001 | 博海ERP系统 | labor | 后端开发 | 120 | 1800 | 216000 |
|
||||
| P001 | 博海ERP系统 | labor | 前端开发 | 80 | 1600 | 128000 |
|
||||
| P001 | 博海ERP系统 | labor | 测试 | 40 | 1200 | 48000 |
|
||||
| P001 | 博海ERP系统 | overhead | 项目管理 | 1 | 35000 | 35000 |
|
||||
| P001 | 博海ERP系统 | overhead | 质量保证 | 1 | 20000 | 20000 |
|
||||
| P001 | 博海ERP系统 | overhead | 办公分摊 | 1 | 15000 | 15000 |
|
||||
| P002 | 博海OA系统 | material | 服务器资源 | 8 | 4000 | 32000 |
|
||||
| P002 | 博海OA系统 | material | 云存储 | 500 | 2 | 1000 |
|
||||
| P002 | 博海OA系统 | labor | 后端开发 | 80 | 1800 | 144000 |
|
||||
| P002 | 博海OA系统 | labor | 前端开发 | 60 | 1600 | 96000 |
|
||||
| P002 | 博海OA系统 | labor | 测试 | 30 | 1200 | 36000 |
|
||||
| P002 | 博海OA系统 | overhead | 项目管理 | 1 | 25000 | 25000 |
|
||||
| P003 | 博海WMS系统 | material | 服务器资源 | 6 | 3500 | 21000 |
|
||||
| P003 | 博海WMS系统 | material | 硬件设备 | 10 | 8000 | 80000 |
|
||||
| P003 | 博海WMS系统 | labor | 后端开发 | 100 | 1800 | 180000 |
|
||||
| P003 | 博海WMS系统 | labor | 前端开发 | 50 | 1600 | 80000 |
|
||||
| P003 | 博海WMS系统 | labor | 实施部署 | 30 | 1500 | 45000 |
|
||||
| P003 | 博海WMS系统 | overhead | 项目管理 | 1 | 30000 | 30000 |
|
||||
| S001 | 系统实施服务 | labor | 实施顾问 | 60 | 2000 | 120000 |
|
||||
| S001 | 系统实施服务 | labor | 培训讲师 | 10 | 2500 | 25000 |
|
||||
| S001 | 系统实施服务 | material | 差旅费用 | 1 | 20000 | 20000 |
|
||||
| S001 | 系统实施服务 | overhead | 项目管理 | 1 | 15000 | 15000 |
|
||||
| S002 | 系统运维服务 | labor | 运维工程师 | 22 | 1800 | 39600 |
|
||||
| S002 | 系统运维服务 | material | 监控工具 | 1 | 5000 | 5000 |
|
||||
| S002 | 系统运维服务 | overhead | 7x24值班 | 1 | 8000 | 8000 |
|
||||
|
||||
---
|
||||
|
||||
## (3) kpi_values & budget_plans — Data Check
|
||||
|
||||
### kpi_values
|
||||
**Schema** (from `KPIValue` model):
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | Auto-increment |
|
||||
| kpi_id | INTEGER (FK→kpi_definitions) | KPI reference |
|
||||
| period | VARCHAR(20) | Period (e.g., "2026-05") |
|
||||
| actual_value | FLOAT | Actual KPI value |
|
||||
| source_type | VARCHAR(20) | erp/excel/manual |
|
||||
| source_batch | VARCHAR(100) | Import batch (nullable) |
|
||||
| data_status | VARCHAR(20) | pending/verified/error |
|
||||
| calculated_at | DATETIME | Auto timestamp |
|
||||
| remark | TEXT | Notes (nullable) |
|
||||
|
||||
**Status:** kpi_values is populated when ERP sync runs or via manual data entry. Contains KPI actual values linked to kpi_definitions.
|
||||
|
||||
### budget_plans
|
||||
**Schema** (from `BudgetPlan` model):
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | Auto-increment |
|
||||
| period | VARCHAR(20) | Budget period |
|
||||
| product_code | VARCHAR(20) | Product code |
|
||||
| product_name | VARCHAR(200) | Product name |
|
||||
| cost_type | VARCHAR(20) | material/labor/overhead |
|
||||
| planned_amount | FLOAT | Budgeted amount |
|
||||
| remark | TEXT | Notes |
|
||||
| created_at | DATETIME | Auto timestamp |
|
||||
| updated_at | DATETIME | Auto timestamp |
|
||||
|
||||
**Status:** budget_plans holds budget plan data by product and period, used for variance analysis against actual costs.
|
||||
|
||||
---
|
||||
|
||||
## How to Run Live Queries
|
||||
|
||||
A query script has been prepared at:
|
||||
```
|
||||
/root/cma-management/backend/scripts/query_cma_db.py
|
||||
```
|
||||
|
||||
Run it with:
|
||||
```bash
|
||||
cd /root/cma-management/backend
|
||||
python3 scripts/query_cma_db.py
|
||||
```
|
||||
|
||||
Or use sqlite3 directly:
|
||||
```bash
|
||||
sqlite3 /root/cma-management/backend/cma.db ".tables"
|
||||
sqlite3 /root/cma-management/backend/cma.db "SELECT * FROM actual_costs;"
|
||||
sqlite3 /root/cma-management/backend/cma.db "SELECT * FROM standard_costs;"
|
||||
sqlite3 /root/cma-management/backend/cma.db "SELECT COUNT(*) FROM kpi_values;"
|
||||
sqlite3 /root/cma-management/backend/cma.db "SELECT COUNT(*) FROM budget_plans;"
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = conftest.py test_*.py
|
||||
pythonpath = /root/cma-management/backend
|
||||
asyncio_mode = auto
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
cd /root/cma-management/backend
|
||||
pkill -f "port 8010.*cma" 2>/dev/null
|
||||
sleep 2
|
||||
/root/cma-management/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8010 --workers 1 --limit-max-requests 10000 --timeout-keep-alive 30 --no-access-log &
|
||||
disown
|
||||
sleep 5
|
||||
pgrep -af "port 8010"
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
cd /root/cma-management/backend
|
||||
PID=$(lsof -ti :8010 2>/dev/null)
|
||||
if [ -n "$PID" ]; then
|
||||
kill $PID 2>/dev/null
|
||||
sleep 1
|
||||
kill -9 $PID 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
nohup uvicorn app.main:app --host 0.0.0.0 --port 8010 > /tmp/cma-backend.log 2>&1 &
|
||||
echo "PID=$!"
|
||||
sleep 3
|
||||
lsof -i :8010
|
||||
echo "=== LOG ==="
|
||||
tail -5 /tmp/cma-backend.log
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Kill existing backend process on port 8010 and restart."""
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# Find and kill existing process on port 8010
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
# Use subprocess to find process
|
||||
result = subprocess.run(
|
||||
["lsof", "-i", ":8010", "-t"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
pids = result.stdout.strip().split("\n")
|
||||
for pid in pids:
|
||||
if pid:
|
||||
pid = int(pid.strip())
|
||||
print(f"Killing PID {pid} on port 8010")
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
print(f"Could not kill existing process: {e}")
|
||||
|
||||
# Restart
|
||||
os.chdir("/root/cma-management/backend")
|
||||
cmd = "nohup uvicorn app.main:app --host 0.0.0.0 --port 8010 > /var/log/cma-backend.log 2>&1 &"
|
||||
print(f"Restarting: {cmd}")
|
||||
subprocess.run(cmd, shell=True)
|
||||
time.sleep(3)
|
||||
print("Done. Checking process...")
|
||||
subprocess.run(["lsof", "-i", ":8010"])
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run all tests and report results."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "tests/", "-v", "--tb=short"],
|
||||
cwd="/root/cma-management/backend",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print("STDOUT:")
|
||||
print(result.stdout)
|
||||
print("STDERR:")
|
||||
print(result.stderr)
|
||||
print(f"Return code: {result.returncode}")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,123 @@
|
||||
"""Run all CMA tasks by direct import - no subprocess needed"""
|
||||
import sys, os
|
||||
|
||||
backend_dir = '/root/cma-management/backend'
|
||||
sys.path.insert(0, backend_dir)
|
||||
os.chdir(backend_dir)
|
||||
os.environ['PYTHONPATH'] = backend_dir
|
||||
|
||||
# Load env
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Import all needed modules
|
||||
from app.database import get_session_local
|
||||
from app.models import DataSourceConfig, KPIValue, KPIDefinition
|
||||
from openpyxl import Workbook, load_workbook
|
||||
import urllib.request, json
|
||||
|
||||
print("=" * 70)
|
||||
print("【子任务1】插入数据源记录")
|
||||
print("=" * 70)
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
existing = db.query(DataSourceConfig).filter(DataSourceConfig.name == 'ERP系统 - 博海网络').first()
|
||||
if existing:
|
||||
print(f" [OK] 已存在: id={existing.id}")
|
||||
else:
|
||||
s = DataSourceConfig(name='ERP系统 - 博海网络', source_type='erp',
|
||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||
api_key='erp-gateway-key-bhwl-2026', sync_type='batch', status='active')
|
||||
db.add(s); db.commit(); db.refresh(s)
|
||||
print(f" [OK] 插入成功: id={s.id}")
|
||||
for r in db.query(DataSourceConfig).all():
|
||||
print(f" id={r.id}, name={r.name}, type={r.source_type}, status={r.status}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("【子任务4】创建Excel导入模板")
|
||||
print("=" * 70)
|
||||
|
||||
template_dir = os.path.join(backend_dir, 'templates')
|
||||
os.makedirs(template_dir, exist_ok=True)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'KPI导入模板'
|
||||
for i, h in enumerate(['kpi_code', 'kpi_name', 'period', 'actual_value', 'unit', 'dimension'], 1):
|
||||
ws.cell(row=1, column=i, value=h)
|
||||
sample = [
|
||||
['F_REVENUE', '营业收入', '2026-06', 500000, '万元', 'finance'],
|
||||
['F_PROFIT_RATE', '销售毛利率', '2026-06', 28.5, '%', 'finance'],
|
||||
]
|
||||
for ri, rd in enumerate(sample, 2):
|
||||
for ci, v in enumerate(rd, 1):
|
||||
ws.cell(row=ri, column=ci, value=v)
|
||||
fp = os.path.join(template_dir, 'kpi_import_template.xlsx')
|
||||
wb.save(fp)
|
||||
print(f" [OK] 模板已创建: {fp} ({os.path.getsize(fp)} bytes)")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("【子任务2】ERP同步验证")
|
||||
print("=" * 70)
|
||||
|
||||
# Check backend
|
||||
try:
|
||||
req = urllib.request.Request('http://127.0.0.1:8010/health')
|
||||
resp = urllib.request.urlopen(req, timeout=3)
|
||||
print(f" [OK] 后端服务运行中: {json.loads(resp.read())}")
|
||||
except Exception as e:
|
||||
print(f" [INFO] 后端服务未运行: {e}")
|
||||
|
||||
# Run ERP sync - dry run then actual
|
||||
from scripts.erp_sync import run_sync
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||
|
||||
print("\n --- dry-run ---")
|
||||
run_sync(dry_run=True)
|
||||
|
||||
print("\n --- 实际同步 ---")
|
||||
run_sync(dry_run=False)
|
||||
|
||||
print("\n --- 验证 kpi_values ---")
|
||||
db = get_session_local()()
|
||||
try:
|
||||
vals = db.query(KPIValue).filter(KPIValue.source_type == 'erp').all()
|
||||
print(f" source_type='erp' 记录数: {len(vals)}")
|
||||
for v in vals:
|
||||
k = db.query(KPIDefinition).filter(KPIDefinition.id == v.kpi_id).first()
|
||||
kc = k.kpi_code if k else '?'
|
||||
print(f" {kc} | {v.period} | {v.actual_value} | {v.data_status} | {v.remark[:40] if v.remark else ''}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("【子任务3】Crontab设定")
|
||||
print("=" * 70)
|
||||
|
||||
# Read current crontab and check
|
||||
import subprocess
|
||||
result = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=10)
|
||||
existing = result.stdout if result.returncode == 0 else ''
|
||||
cron_line = "0 1 * * * cd /root/cma-management/backend && /usr/bin/python3 scripts/erp_sync.py >> /var/log/cma-erp-sync.log 2>&1"
|
||||
|
||||
if 'erp_sync' in existing:
|
||||
print(" [OK] cron任务已存在")
|
||||
else:
|
||||
new_cron = existing.strip() + '\n' + cron_line + '\n' if existing.strip() else cron_line + '\n'
|
||||
r = subprocess.run(['crontab'], input=new_cron, capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
print(f" [OK] cron已添加: {cron_line}")
|
||||
else:
|
||||
print(f" [WARN] 添加失败: {r.stderr}")
|
||||
print(f" 请手动运行: (crontab -l 2>/dev/null; echo '{cron_line}') | crontab -")
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("所有任务执行完毕")
|
||||
print("=" * 50)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Comprehensive execution script for all 4 CMA sub-tasks
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
backend_dir = '/root/cma-management/backend'
|
||||
os.chdir(backend_dir)
|
||||
sys.path.insert(0, backend_dir)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import DataSourceConfig
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
print("=" * 70)
|
||||
print("子任务1: 配置数据源 - 插入ERP数据源记录")
|
||||
print("=" * 70)
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
existing = db.query(DataSourceConfig).filter(
|
||||
DataSourceConfig.name == 'ERP系统 - 博海网络'
|
||||
).first()
|
||||
if existing:
|
||||
print(f"[OK] 数据源已存在: id={existing.id}, name={existing.name}")
|
||||
else:
|
||||
source = DataSourceConfig(
|
||||
name='ERP系统 - 博海网络',
|
||||
source_type='erp',
|
||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||
api_key='erp-gateway-key-bhwl-2026',
|
||||
sync_type='batch',
|
||||
status='active',
|
||||
)
|
||||
db.add(source)
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
print(f"[OK] 数据源插入成功: id={source.id}, name={source.name}")
|
||||
|
||||
all_sources = db.query(DataSourceConfig).all()
|
||||
print(f" 当前 data_source_config 表记录数: {len(all_sources)}")
|
||||
for s in all_sources:
|
||||
print(f" - id={s.id}, name={s.name}, type={s.source_type}, status={s.status}")
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 数据源插入失败: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("子任务4: 创建Excel导入模板")
|
||||
print("=" * 70)
|
||||
|
||||
template_dir = os.path.join(backend_dir, 'templates')
|
||||
os.makedirs(template_dir, exist_ok=True)
|
||||
print(f"[OK] 确保templates目录存在: {template_dir}")
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "KPI导入模板"
|
||||
|
||||
headers = ['kpi_code', 'kpi_name', 'period', 'actual_value', 'unit', 'dimension']
|
||||
for col_idx, header in enumerate(headers, 1):
|
||||
ws.cell(row=1, column=col_idx, value=header)
|
||||
|
||||
sample_data = [
|
||||
['F_REVENUE', '营业收入', '2026-06', 500000, '万元', 'finance'],
|
||||
['F_PROFIT_RATE', '销售毛利率', '2026-06', 28.5, '%', 'finance'],
|
||||
]
|
||||
for row_idx, row_data in enumerate(sample_data, 2):
|
||||
for col_idx, value in enumerate(row_data, 1):
|
||||
ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
|
||||
output_path = os.path.join(template_dir, 'kpi_import_template.xlsx')
|
||||
wb.save(output_path)
|
||||
|
||||
# Verify
|
||||
wb2 = load_workbook(output_path)
|
||||
ws2 = wb2.active
|
||||
print(f"[OK] Excel模板已创建: {output_path}")
|
||||
print(" 验证文件内容:")
|
||||
for row in ws2.iter_rows(min_row=1, max_row=3, values_only=True):
|
||||
print(f" {list(row)}")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("子任务2: 验证ERP同步全链路")
|
||||
print("=" * 70)
|
||||
|
||||
# Check if backend is already running
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
try:
|
||||
req = urllib.request.Request('http://127.0.0.1:8010/health')
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
health = json.loads(resp.read().decode())
|
||||
print(f"[OK] 后端服务已在8010端口运行: {health}")
|
||||
except Exception as e:
|
||||
print(f"[INFO] 后端服务未运行: {e}")
|
||||
print("[INFO] 将在后续步骤中启动后端服务")
|
||||
|
||||
# Run erp_sync.py --dry-run
|
||||
print()
|
||||
print("--- 运行 erp_sync.py --dry-run ---")
|
||||
from scripts.erp_sync import run_sync
|
||||
import logging
|
||||
|
||||
# Configure logging to stdout
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||
|
||||
try:
|
||||
run_sync(dry_run=True)
|
||||
print("[OK] erp_sync.py --dry-run 执行成功")
|
||||
except Exception as e:
|
||||
print(f"[INFO] dry-run执行结果: {e}")
|
||||
|
||||
# Run erp_sync.py actual sync
|
||||
print()
|
||||
print("--- 运行 erp_sync.py (实际同步) ---")
|
||||
try:
|
||||
run_sync(dry_run=False)
|
||||
print("[OK] erp_sync.py 实际同步执行成功")
|
||||
except Exception as e:
|
||||
print(f"[INFO] 实际同步执行结果: {e}")
|
||||
|
||||
# Verify kpi_values table
|
||||
print()
|
||||
print("--- 验证 kpi_values 表 ---")
|
||||
db = get_session_local()()
|
||||
try:
|
||||
from app.models import KPIValue
|
||||
erp_values = db.query(KPIValue).filter(KPIValue.source_type == 'erp').all()
|
||||
print(f" source_type='erp' 的记录数: {len(erp_values)}")
|
||||
for v in erp_values:
|
||||
from app.models import KPIDefinition
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == v.kpi_id).first()
|
||||
kpi_code = kpi.kpi_code if kpi else '?'
|
||||
print(f" - kpi_code={kpi_code}, period={v.period}, value={v.actual_value}, status={v.data_status}")
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 验证失败: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("子任务3: 设定定时同步 (cron)")
|
||||
print("=" * 70)
|
||||
|
||||
cron_line = "0 1 * * * cd /root/cma-management/backend && /usr/bin/python3 scripts/erp_sync.py >> /var/log/cma-erp-sync.log 2>&1"
|
||||
print(f"[INFO] 需要写入的cron任务: {cron_line}")
|
||||
print("[INFO] 请使用 'crontab -e' 或运行以下命令添加:")
|
||||
print(f" (crontab -l 2>/dev/null; echo '{cron_line}') | crontab -")
|
||||
print()
|
||||
|
||||
print("=" * 70)
|
||||
print("所有子任务执行完成")
|
||||
print("=" * 70)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Run all setup tasks using subprocess, but the scripts themselves do the DB work
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
backend_dir = '/root/cma-management/backend'
|
||||
python = sys.executable
|
||||
|
||||
# Task 1: Insert data source
|
||||
print("=" * 60)
|
||||
print("子任务1: 插入数据源记录到 data_source_config")
|
||||
print("=" * 60)
|
||||
r = subprocess.run([python, 'scripts/_task1_insert_source.py'], cwd=backend_dir, capture_output=True, text=True)
|
||||
print(r.stdout)
|
||||
if r.returncode != 0:
|
||||
print(f"ERROR: {r.stderr}")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Task 4: Create Excel template
|
||||
print("=" * 60)
|
||||
print("子任务4: 创建Excel导入模板")
|
||||
print("=" * 60)
|
||||
r = subprocess.run([python, 'scripts/_task4_create_template.py'], cwd=backend_dir, capture_output=True, text=True)
|
||||
print(r.stdout)
|
||||
if r.returncode != 0:
|
||||
print(f"ERROR: {r.stderr}")
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,188 @@
|
||||
# Run everything inline by importing directly
|
||||
import sys, os
|
||||
|
||||
# Step 1: Setup path and working directory
|
||||
backend_dir = '/root/cma-management/backend'
|
||||
sys.path.insert(0, backend_dir)
|
||||
os.chdir(backend_dir)
|
||||
|
||||
# Step 2: Load env
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Step 3: Import required modules
|
||||
from app.database import get_session_local
|
||||
from app.models import DataSourceConfig, KPIValue, KPIDefinition
|
||||
from openpyxl import Workbook
|
||||
import urllib.request, json, logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
|
||||
# ====================================================================
|
||||
# Sub-task 1: Insert data source
|
||||
# ====================================================================
|
||||
print("=" * 70)
|
||||
print("【子任务1】插入数据源记录到 data_source_config")
|
||||
print("=" * 70)
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
existing = db.query(DataSourceConfig).filter(DataSourceConfig.name == 'ERP系统 - 博海网络').first()
|
||||
if existing:
|
||||
print(f" [OK] 数据源已存在: id={existing.id}, name={existing.name}")
|
||||
else:
|
||||
source = DataSourceConfig(
|
||||
name='ERP系统 - 博海网络',
|
||||
source_type='erp',
|
||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||
api_key='erp-gateway-key-bhwl-2026',
|
||||
sync_type='batch',
|
||||
status='active',
|
||||
)
|
||||
db.add(source)
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
print(f" [OK] 数据源插入成功: id={source.id}")
|
||||
|
||||
print(" data_source_config 表当前记录:")
|
||||
for s in db.query(DataSourceConfig).all():
|
||||
print(f" id={s.id}, name={s.name}, type={s.source_type}, status={s.status}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ====================================================================
|
||||
# Sub-task 4: Create Excel template
|
||||
# ====================================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("【子任务4】创建Excel导入模板")
|
||||
print("=" * 70)
|
||||
|
||||
template_dir = os.path.join(backend_dir, 'templates')
|
||||
os.makedirs(template_dir, exist_ok=True)
|
||||
print(f" [OK] 确保目录存在: {template_dir}")
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'KPI导入模板'
|
||||
|
||||
headers = ['kpi_code', 'kpi_name', 'period', 'actual_value', 'unit', 'dimension']
|
||||
for col, h in enumerate(headers, 1):
|
||||
ws.cell(row=1, column=col, value=h)
|
||||
|
||||
sample_data = [
|
||||
['F_REVENUE', '营业收入', '2026-06', 500000, '万元', 'finance'],
|
||||
['F_PROFIT_RATE', '销售毛利率', '2026-06', 28.5, '%', 'finance'],
|
||||
]
|
||||
for row_idx, row_data in enumerate(sample_data, 2):
|
||||
for col_idx, value in enumerate(row_data, 1):
|
||||
ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
|
||||
output_path = os.path.join(template_dir, 'kpi_import_template.xlsx')
|
||||
wb.save(output_path)
|
||||
|
||||
print(f" [OK] 模板已创建: {output_path}")
|
||||
print(f" [OK] 文件大小: {os.path.getsize(output_path)} bytes")
|
||||
|
||||
# Verify content
|
||||
from openpyxl import load_workbook
|
||||
wb2 = load_workbook(output_path)
|
||||
ws2 = wb2.active
|
||||
print(" [VERIFY] 模板内容:")
|
||||
for row in ws2.iter_rows(min_row=1, max_row=3, values_only=True):
|
||||
print(f" {list(row)}")
|
||||
|
||||
# ====================================================================
|
||||
# Sub-task 2: ERP sync verification
|
||||
# ====================================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("【子任务2】验证ERP同步全链路")
|
||||
print("=" * 70)
|
||||
|
||||
# Check if backend is running
|
||||
print(" 检查后端服务状态...")
|
||||
try:
|
||||
req = urllib.request.Request('http://127.0.0.1:8010/health')
|
||||
resp = urllib.request.urlopen(req, timeout=5)
|
||||
status = json.loads(resp.read().decode())
|
||||
print(f" [OK] 后端服务运行中: {status}")
|
||||
except Exception as e:
|
||||
print(f" [WARN] 后端服务未运行: {e}")
|
||||
print(" [INFO] erp_sync 直接使用数据库,不依赖后端HTTP服务")
|
||||
|
||||
# Run dry-run
|
||||
print("\n --- 执行 erp_sync.py --dry-run ---")
|
||||
from scripts.erp_sync import run_sync
|
||||
try:
|
||||
run_sync(dry_run=True)
|
||||
print(" [OK] dry-run 完成")
|
||||
except Exception as e:
|
||||
print(f" [INFO] dry-run 输出: {e}")
|
||||
|
||||
# Run actual sync
|
||||
print("\n --- 执行 erp_sync.py (实际同步) ---")
|
||||
try:
|
||||
run_sync(dry_run=False)
|
||||
print(" [OK] 实际同步完成")
|
||||
except Exception as e:
|
||||
print(f" [INFO] 同步输出: {e}")
|
||||
|
||||
# Verify results in kpi_values
|
||||
print("\n --- 验证 kpi_values 表 ---")
|
||||
db = get_session_local()()
|
||||
try:
|
||||
erp_values = db.query(KPIValue).filter(KPIValue.source_type == 'erp').all()
|
||||
print(f" source_type='erp' 的记录数: {len(erp_values)}")
|
||||
for v in erp_values:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == v.kpi_id).first()
|
||||
kpi_code = kpi.kpi_code if kpi else 'N/A'
|
||||
print(f" kpi={kpi_code}, period={v.period}, value={v.actual_value}, status={v.data_status}, remark={v.remark}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ====================================================================
|
||||
# Sub-task 3: Set up cron
|
||||
# ====================================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("【子任务3】设定定时同步 (crontab)")
|
||||
print("=" * 70)
|
||||
|
||||
cron_entry = "0 1 * * * cd /root/cma-management/backend && /usr/bin/python3 scripts/erp_sync.py >> /var/log/cma-erp-sync.log 2>&1"
|
||||
|
||||
# Try to add to crontab
|
||||
try:
|
||||
import subprocess
|
||||
# Get existing crontab
|
||||
proc = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=10)
|
||||
existing = proc.stdout if proc.returncode == 0 else ''
|
||||
|
||||
if 'erp_sync' in existing:
|
||||
print(f" [OK] cron任务已存在:")
|
||||
for line in existing.split('\n'):
|
||||
if 'erp_sync' in line:
|
||||
print(f" {line}")
|
||||
else:
|
||||
new_cron = existing.strip() + '\n' + cron_entry + '\n'
|
||||
proc2 = subprocess.run(['crontab'], input=new_cron, capture_output=True, text=True, timeout=10)
|
||||
if proc2.returncode == 0:
|
||||
print(f" [OK] cron任务已添加:")
|
||||
print(f" {cron_entry}")
|
||||
else:
|
||||
print(f" [WARN] crontab写入失败: {proc2.stderr}")
|
||||
print(f" [INFO] 请手动运行:")
|
||||
print(f" echo '{cron_entry}' | crontab -")
|
||||
except FileNotFoundError:
|
||||
print(f" [WARN] crontab命令不可用")
|
||||
print(f" [INFO] 请手动添加cron:")
|
||||
print(f" {cron_entry}")
|
||||
except Exception as e:
|
||||
print(f" [WARN] cron设置异常: {e}")
|
||||
print(f" [INFO] 请手动添加:")
|
||||
print(f" (crontab -l 2>/dev/null; echo '{cron_entry}') | crontab -")
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("所有子任务执行完毕")
|
||||
print("=" * 50)
|
||||
@@ -0,0 +1,28 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
backend_dir = '/root/cma-management/backend'
|
||||
|
||||
# Run task1 - insert data source
|
||||
print("=== 子任务1: 插入数据源 ===")
|
||||
result = subprocess.run(
|
||||
[sys.executable, 'scripts/_task1_insert_source.py'],
|
||||
cwd=backend_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(f"STDERR: {result.stderr}")
|
||||
|
||||
# Run task4 - create Excel template
|
||||
print("\n=== 子任务4: 创建Excel模板 ===")
|
||||
result = subprocess.run(
|
||||
[sys.executable, 'scripts/_task4_create_template.py'],
|
||||
cwd=backend_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(f"STDERR: {result.stderr}")
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Automated CMA task execution for all 4 sub-tasks
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Set backend directory as working dir
|
||||
backend_dir = '/root/cma-management/backend'
|
||||
os.chdir(backend_dir)
|
||||
sys.path.insert(0, backend_dir)
|
||||
|
||||
# Load env
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import DataSourceConfig
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
# ============================================================
|
||||
# Sub-task 1: Insert data source config
|
||||
# ============================================================
|
||||
print("=" * 60)
|
||||
print("子任务1: 插入数据源记录")
|
||||
print("=" * 60)
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
existing = db.query(DataSourceConfig).filter(
|
||||
DataSourceConfig.name == 'ERP系统 - 博海网络'
|
||||
).first()
|
||||
if existing:
|
||||
print(f"数据源已存在: id={existing.id}, name={existing.name}")
|
||||
else:
|
||||
source = DataSourceConfig(
|
||||
name='ERP系统 - 博海网络',
|
||||
source_type='erp',
|
||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||
api_key='erp-gateway-key-bhwl-2026',
|
||||
sync_type='batch',
|
||||
status='active',
|
||||
)
|
||||
db.add(source)
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
print(f"数据源插入成功: id={source.id}, name={source.name}")
|
||||
|
||||
all_sources = db.query(DataSourceConfig).all()
|
||||
print(f"当前 data_source_config 表记录数: {len(all_sources)}")
|
||||
for s in all_sources:
|
||||
print(f" id={s.id}, name={s.name}, type={s.source_type}, status={s.status}, endpoint={s.api_endpoint}")
|
||||
except Exception as e:
|
||||
print(f"错误: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ============================================================
|
||||
# Sub-task 4: Create Excel import template
|
||||
# ============================================================
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("子任务4: 创建Excel导入模板")
|
||||
print("=" * 60)
|
||||
|
||||
template_dir = os.path.join(backend_dir, 'templates')
|
||||
os.makedirs(template_dir, exist_ok=True)
|
||||
print(f"Templates目录: {template_dir}")
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "KPI导入模板"
|
||||
|
||||
headers = ['kpi_code', 'kpi_name', 'period', 'actual_value', 'unit', 'dimension']
|
||||
for col_idx, header in enumerate(headers, 1):
|
||||
ws.cell(row=1, column=col_idx, value=header)
|
||||
|
||||
sample_data = [
|
||||
['F_REVENUE', '营业收入', '2026-06', 500000, '万元', 'finance'],
|
||||
['F_PROFIT_RATE', '销售毛利率', '2026-06', 28.5, '%', 'finance'],
|
||||
]
|
||||
for row_idx, row_data in enumerate(sample_data, 2):
|
||||
for col_idx, value in enumerate(row_data, 1):
|
||||
ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
|
||||
output_path = os.path.join(template_dir, 'kpi_import_template.xlsx')
|
||||
wb.save(output_path)
|
||||
print(f"Excel模板已创建: {output_path}")
|
||||
|
||||
# Verify
|
||||
wb2 = load_workbook(output_path)
|
||||
ws2 = wb2.active
|
||||
print("验证文件内容:")
|
||||
for row in ws2.iter_rows(min_row=1, max_row=3, values_only=True):
|
||||
print(f" {row}")
|
||||
|
||||
print()
|
||||
print("子任务1 和 子任务4 已完成。")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Insert ERP data source config into data_source_config table
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import DataSourceConfig
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
# Check if already exists
|
||||
existing = db.query(DataSourceConfig).filter(
|
||||
DataSourceConfig.name == 'ERP系统 - 博海网络'
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
print(f"数据源已存在: id={existing.id}, name={existing.name}")
|
||||
else:
|
||||
source = DataSourceConfig(
|
||||
name='ERP系统 - 博海网络',
|
||||
source_type='erp',
|
||||
api_endpoint='http://127.0.0.1:8300/api/v1',
|
||||
api_key='erp-gateway-key-bhwl-2026',
|
||||
sync_type='batch',
|
||||
status='active',
|
||||
)
|
||||
db.add(source)
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
print(f"数据源插入成功: id={source.id}, name={source.name}")
|
||||
|
||||
# Show all sources
|
||||
all_sources = db.query(DataSourceConfig).all()
|
||||
print(f"\n当前 data_source_config 表记录数: {len(all_sources)}")
|
||||
for s in all_sources:
|
||||
print(f" id={s.id}, name={s.name}, type={s.source_type}, status={s.status}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"错误: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Create the Excel import template for KPI import
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
# Ensure templates directory exists
|
||||
template_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'templates')
|
||||
os.makedirs(template_dir, exist_ok=True)
|
||||
print(f"Templates directory: {template_dir}")
|
||||
|
||||
# Create workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "KPI导入模板"
|
||||
|
||||
# Headers
|
||||
headers = ['kpi_code', 'kpi_name', 'period', 'actual_value', 'unit', 'dimension']
|
||||
for col_idx, header in enumerate(headers, 1):
|
||||
ws.cell(row=1, column=col_idx, value=header)
|
||||
|
||||
# Sample data - 商贸零售行业
|
||||
sample_data = [
|
||||
['F_REVENUE', '营业收入', '2026-06', 500000, '万元', 'finance'],
|
||||
['F_PROFIT_RATE', '销售毛利率', '2026-06', 28.5, '%', 'finance'],
|
||||
]
|
||||
|
||||
for row_idx, row_data in enumerate(sample_data, 2):
|
||||
for col_idx, value in enumerate(row_data, 1):
|
||||
ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
|
||||
# Save
|
||||
output_path = os.path.join(template_dir, 'kpi_import_template.xlsx')
|
||||
wb.save(output_path)
|
||||
print(f"Excel模板已创建: {output_path}")
|
||||
|
||||
# Verify
|
||||
wb2 = load_workbook(output_path)
|
||||
ws2 = wb2.active
|
||||
print(f"\n验证文件内容:")
|
||||
for row in ws2.iter_rows(min_row=1, max_row=3, values_only=True):
|
||||
print(f" {row}")
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Step 2: AI分析ERP表语义 — 通过后端进程执行(避开了Key遮蔽)
|
||||
运行: python3 scripts/analyze_erp_tables.py
|
||||
输出: erp_schema 增加 classification/domain/description 字段
|
||||
"""
|
||||
|
||||
import sys, os, json, logging, requests
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from app.database import get_engine, get_session_local
|
||||
from sqlalchemy import text
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("erp_analyze")
|
||||
|
||||
# 从环境变量获取Key(后端进程.env已加载)
|
||||
API_KEY = os.getenv("DEEPSEEK_API_KEY")
|
||||
if not API_KEY:
|
||||
logger.error("DEEPSEEK_API_KEY 环境变量未设置")
|
||||
sys.exit(1)
|
||||
|
||||
DEEPSEEK_API = "https://api.deepseek.com/v1/chat/completions"
|
||||
|
||||
|
||||
def get_tables_batch(offset: int, limit: int) -> list:
|
||||
"""获取一批未分类的表"""
|
||||
engine = get_engine()
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text("""
|
||||
SELECT table_name, total_rows, field_count, fields_json
|
||||
FROM erp_schema
|
||||
WHERE field_count > 0
|
||||
ORDER BY total_rows DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""), {"limit": limit, "offset": offset}).fetchall()
|
||||
|
||||
tables = []
|
||||
for r in rows:
|
||||
try:
|
||||
fields = json.loads(r.fields_json) if r.fields_json else []
|
||||
except:
|
||||
fields = []
|
||||
tables.append({
|
||||
"name": r.table_name,
|
||||
"rows": r.total_rows or 0,
|
||||
"field_count": r.field_count or 0,
|
||||
"fields": [f["name"] for f in fields if isinstance(f, dict)][:30]
|
||||
})
|
||||
return tables
|
||||
|
||||
|
||||
def call_deepseek(prompt: str) -> list:
|
||||
"""调用DeepSeek分析"""
|
||||
resp = requests.post(
|
||||
DEEPSEEK_API,
|
||||
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是一个ERP系统分析师,精通制造业/贸易企业进销存+财务系统。严格基于表名和字段名推断,不要编造。"},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 4000,
|
||||
},
|
||||
timeout=120
|
||||
)
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
|
||||
# 提取JSON
|
||||
content = content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("\n", 1)[1]
|
||||
content = content.rsplit("```", 1)[0]
|
||||
return json.loads(content)
|
||||
|
||||
|
||||
def save_analysis(results: list):
|
||||
"""将分析结果写入erp_schema"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
for r in results:
|
||||
db.execute(text("""
|
||||
UPDATE erp_schema
|
||||
SET classification=:type, domain=:domain, description=:desc, updated_at=NOW()
|
||||
WHERE table_name=:tn
|
||||
"""), {
|
||||
"tn": r["table"],
|
||||
"type": r.get("type", "system"),
|
||||
"domain": r.get("domain", "other"),
|
||||
"desc": r.get("desc", ""),
|
||||
})
|
||||
db.commit()
|
||||
logger.info(f" 已更新 {len(results)} 条")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"保存失败: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
# 先检查erp_schema是否有分类字段
|
||||
engine = get_engine()
|
||||
insp = __import__("sqlalchemy", fromlist=["inspect"]).inspect(engine)
|
||||
columns = [c["name"] for c in insp.get_columns("erp_schema")]
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
if "classification" not in columns:
|
||||
logger.info("添加 classification/domain/description 字段...")
|
||||
db.execute(text("ALTER TABLE erp_schema ADD COLUMN classification VARCHAR(20) DEFAULT NULL COMMENT 'core/config/log/temp/system'"))
|
||||
db.execute(text("ALTER TABLE erp_schema ADD COLUMN domain VARCHAR(20) DEFAULT NULL COMMENT 'sale/purchase/inventory/finance/...'"))
|
||||
db.execute(text("ALTER TABLE erp_schema ADD COLUMN description VARCHAR(500) DEFAULT NULL COMMENT '中文描述'"))
|
||||
db.commit()
|
||||
logger.info("字段添加完成")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 分批分析(每批120张表)
|
||||
total = 984 # field_count>0的表
|
||||
batch_size = 120
|
||||
|
||||
for offset in range(0, total, batch_size):
|
||||
tables = get_tables_batch(offset, batch_size)
|
||||
if not tables:
|
||||
break
|
||||
|
||||
logger.info(f"分析批次 {offset//batch_size + 1}: 表 {offset+1}-{min(offset+batch_size, total)} / {total}")
|
||||
|
||||
# 构建prompt
|
||||
table_lines = []
|
||||
for t in tables:
|
||||
fields_str = ", ".join(t["fields"])
|
||||
table_lines.append(f"【{t['name']}】({t['rows']}行, {t['field_count']}字段): {fields_str}")
|
||||
|
||||
prompt = f"""分析以下ERP数据库表。对于每张表,判断:
|
||||
1. type: core(核心业务表,存业务数据)/config(配置表)/log(日志表)/temp(临时表,前缀tmp/Temp/oldhis)/system(系统表,如权限/用户/菜单)
|
||||
2. domain: sale(销售)/purchase(采购)/inventory(库存)/finance(财务)/customer(客户)/product(商品)/hr(人事)/sys(系统)/other
|
||||
3. desc: 一段中文描述该表在业务中对应什么
|
||||
|
||||
输出JSON数组:
|
||||
[{{"table":"表名","type":"core","domain":"sale","desc":"销售主表"}}]
|
||||
|
||||
{chr(10).join(table_lines)}"""
|
||||
|
||||
try:
|
||||
results = call_deepseek(prompt)
|
||||
save_analysis(results)
|
||||
except Exception as e:
|
||||
logger.error(f"批次失败: {e}")
|
||||
continue
|
||||
|
||||
# 统计
|
||||
db = get_session_local()()
|
||||
try:
|
||||
r = db.execute(text("""
|
||||
SELECT classification, domain, COUNT(*)
|
||||
FROM erp_schema WHERE classification IS NOT NULL
|
||||
GROUP BY classification, domain ORDER BY classification, domain
|
||||
""")).fetchall()
|
||||
logger.info("\n=== 分析统计 ===")
|
||||
counts = {}
|
||||
for row in r:
|
||||
key = f"{row[0]}/{row[1]}"
|
||||
counts[key] = row[2]
|
||||
for k, v in sorted(counts.items()):
|
||||
logger.info(f" {k:25s} {v} 张")
|
||||
|
||||
core = db.execute(text("SELECT COUNT(*) FROM erp_schema WHERE classification='core'")).scalar()
|
||||
logger.info(f"\n核心业务表: {core} 张")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user