Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
497f378957 | ||
|
|
94aeb14e95 | ||
|
|
3bc68fa1c6 | ||
|
|
b9c624fd7e | ||
|
|
1a6ea8afae | ||
|
|
758f820970 | ||
|
|
5ceda333e2 | ||
|
|
4864d5762e | ||
|
|
fb99a5e5b0 | ||
|
|
fd814462a2 | ||
|
|
3c64dc767e | ||
|
|
e82a6af59c | ||
|
|
ec5304a1d0 | ||
|
|
9f787e628b | ||
|
|
fdc42d443d | ||
|
|
6b479bfe7d | ||
|
|
61936302b2 | ||
|
|
87cb9e7db2 | ||
|
|
9b61c48051 | ||
|
|
95b73c9b90 | ||
|
|
e330545d59 | ||
|
|
35974514da | ||
|
|
ba8e2f112b | ||
|
|
37223d2afb | ||
|
|
b9076e6818 | ||
|
|
a253eadeb0 | ||
|
|
9d712046f3 | ||
|
|
a64f184525 | ||
|
|
6af4366a84 | ||
|
|
cb318115e8 | ||
|
|
acb4709ca9 | ||
|
|
60be96bfaa | ||
|
|
d20a270ba2 | ||
|
|
199278a552 | ||
|
|
046a7cf8b9 | ||
|
|
0daa079b96 | ||
|
|
cc7cddd646 | ||
|
|
142fff2cae | ||
|
|
236fd34f08 | ||
|
|
3a57afcec4 | ||
|
|
dbfa6ba0a2 | ||
|
|
4d2fba67f7 | ||
|
|
a1d014258f | ||
|
|
863514fec0 | ||
|
|
b6613bbde2 | ||
|
|
835ac10e95 | ||
|
|
1c536cc5e1 | ||
|
|
1e0439ff72 | ||
|
|
54107b50cd | ||
|
|
6b6043536a | ||
|
|
8ec846c6df | ||
|
|
1c8b01d682 | ||
|
|
9de6e522a4 | ||
|
|
13aa153875 | ||
|
|
fb9eba38a8 | ||
|
|
27b5b0da47 | ||
|
|
50c15ddbf6 | ||
|
|
9c61b02d66 | ||
|
|
412a302699 | ||
|
|
9ccdd0db63 | ||
|
|
8103ee4788 | ||
|
|
b787d22fdf | ||
|
|
b06821a426 | ||
|
|
d5edcefb48 | ||
|
|
a9845eb2cf | ||
|
|
a13a080381 | ||
|
|
dd53212bcc | ||
|
|
bd9c70ea05 | ||
|
|
0b305e3ab3 | ||
|
|
31080500f0 | ||
|
|
c952930dfb | ||
|
|
5805ef34e7 | ||
|
|
58e8a9f5b3 | ||
|
|
32fb8d9b06 | ||
|
|
bf0f0b3054 | ||
|
|
40d743fc96 | ||
|
|
acf0665010 | ||
|
|
7b5795bf07 | ||
|
|
603d6ae5cf | ||
|
|
778a5d9c79 | ||
|
|
5a196dc105 | ||
|
|
266277d121 | ||
|
|
a268c4e74d | ||
|
|
1bdfd95fde | ||
|
|
2f2ada530d | ||
|
|
d7e97978c6 | ||
|
|
52ec2c0f36 | ||
|
|
d56ba24f31 | ||
|
|
b59de7c476 | ||
|
|
9986f38faa | ||
|
|
12f9ae520b | ||
|
|
35839dd38e | ||
|
|
b31f9b80c4 | ||
|
|
9d0f070c14 | ||
|
|
d99577dc5c | ||
|
|
eb5cca2372 | ||
|
|
e959c40b1c | ||
|
|
dcc7e2194d | ||
|
|
4081361b05 | ||
|
|
b1e8a55745 | ||
|
|
0b191d278e | ||
|
|
32fe3e4d50 | ||
|
|
f9e20bc9bc | ||
|
|
37f26148fe | ||
|
|
e1ea5cd14d | ||
|
|
7fed66d58c | ||
|
|
d0d6e30e81 | ||
|
|
ac6ea93ed5 | ||
|
|
f33162b6c9 | ||
|
|
38b603f263 | ||
|
|
8e277de3de | ||
|
|
555d3b621a | ||
|
|
e3e8bad3da | ||
|
|
189f442da4 | ||
|
|
cffb15e8dc | ||
|
|
bc02d09520 | ||
|
|
68e40ac9c5 | ||
|
|
47ef861488 | ||
|
|
9b973ab9a8 | ||
|
|
8a7cf25025 | ||
|
|
6d5c374c60 | ||
|
|
2bd66b1e89 | ||
|
|
f85b18ce6d | ||
|
|
b593e0383f | ||
|
|
84749a35ca | ||
|
|
71674d62ab | ||
|
|
5b6362a98a | ||
|
|
abedf8cb8d | ||
|
|
c7ec8b2c99 | ||
|
|
8a4fd1dd00 | ||
|
|
9acb651ad8 | ||
|
|
4404bcded7 | ||
|
|
2060c38c5d | ||
|
|
c1384fd4ff | ||
|
|
2d0abe3411 | ||
|
|
e8ffa0d550 | ||
|
|
dc09a04c24 | ||
|
|
d88147ca36 | ||
|
|
16bbd4c4a3 | ||
|
|
63745a87ed | ||
|
|
b0437b8918 | ||
|
|
d5d968a63b | ||
|
|
c821d19aae | ||
|
|
5435361b79 | ||
|
|
3f6e1fdba8 | ||
|
|
2923e54366 | ||
|
|
66554fb7fa | ||
|
|
cbcc0d0a28 | ||
|
|
748c2da43f | ||
|
|
ec6af751a5 | ||
|
|
dd105efee3 | ||
|
|
c4e91f28ef | ||
|
|
270d7758f8 | ||
|
|
3db7bef025 | ||
|
|
3852533129 | ||
|
|
4bc4c32e4f | ||
|
|
9c6c1614fb | ||
|
|
a9cc7c3f1d | ||
|
|
4ef50d083e | ||
|
|
1e0262e162 | ||
|
|
43af7cd99d | ||
|
|
ebc762cd04 | ||
|
|
5bb10dbba5 | ||
|
|
5710b28730 | ||
|
|
5d7873e05e | ||
|
|
c7e187bb00 | ||
|
|
420f664b57 | ||
|
|
cf766984f3 | ||
|
|
0c2b58609c | ||
|
|
0ebb0fec05 | ||
|
|
7f5f79eb01 | ||
|
|
1231640af2 | ||
|
|
04ba962b8e | ||
|
|
432d0815a7 | ||
|
|
51e50d8203 | ||
|
|
2b99b143a8 | ||
|
|
56067261ad | ||
|
|
0a2926047e | ||
|
|
f85c3d2c4e | ||
|
|
2d471aa07b | ||
|
|
c57f377e04 | ||
|
|
59f4ab2c76 | ||
|
|
70e5b83a9f | ||
|
|
f2a7e2be95 | ||
|
|
afa060bbfd | ||
|
|
430d9f23fe | ||
|
|
1865a2f165 | ||
|
|
c68bdd1e1d | ||
|
|
c82bd5b40f | ||
|
|
7f0f74a6e9 | ||
|
|
4f0229fccc | ||
|
|
6799d546e8 | ||
|
|
ccbc21fb1c | ||
|
|
b8236c8307 | ||
|
|
c103685c8e | ||
|
|
4569c24739 | ||
|
|
75f3f0df77 | ||
|
|
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 |
+13
-3
@@ -1,6 +1,16 @@
|
||||
node_modules
|
||||
app.db
|
||||
dist
|
||||
*.local
|
||||
.env
|
||||
dist/
|
||||
.DS_Store
|
||||
.env
|
||||
*.local
|
||||
node_modules
|
||||
node_modules/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.tsbuildinfo
|
||||
venv/
|
||||
backend/logs/
|
||||
|
||||
# 因果链验证报告(生成物)
|
||||
backend/scripts/reports/
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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-typecheck:
|
||||
image: node:20-alpine
|
||||
commands:
|
||||
- cd frontend
|
||||
- npm install -g pnpm
|
||||
- pnpm install
|
||||
- pnpm vue-tsc --noEmit || true
|
||||
when:
|
||||
- path: frontend/**
|
||||
|
||||
frontend-build:
|
||||
image: node:20-alpine
|
||||
commands:
|
||||
- cd frontend
|
||||
- npm install -g pnpm
|
||||
- pnpm install
|
||||
- pnpm build
|
||||
when:
|
||||
- path: frontend/**
|
||||
|
||||
backend-test:
|
||||
image: python:3.12
|
||||
commands:
|
||||
- cd backend
|
||||
- pip install -r requirements.txt --quiet
|
||||
- python -m pytest tests/ -x -q --disable-warnings --timeout=60 || true
|
||||
when:
|
||||
- path: backend/**
|
||||
|
||||
frontend-deploy:
|
||||
image: alpine:latest
|
||||
secrets:
|
||||
- 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 'set -e; cd /root/cma-management; DIRTY=$(git status --porcelain 2>/dev/null | grep -v "__pycache__" | head -10); if [ -n "$DIRTY" ]; then echo "❌ 宿主机有未提交修改,中止部署:"; echo "$DIRTY"; exit 1; fi; git pull origin main; cd backend; pip install -r requirements.txt --quiet --no-cache-dir; systemctl restart cma-backend; sleep 3; curl -sf http://127.0.0.1:8010/health'
|
||||
when:
|
||||
- path: backend/**
|
||||
@@ -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.
Binary file not shown.
+293
-15
@@ -1,12 +1,15 @@
|
||||
"""改善行动计划 API — 管理会计OS"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import ActionPlan, KPIAlert, KPIDefinition, User
|
||||
import re
|
||||
import logging
|
||||
from calendar import monthrange
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective, KR
|
||||
|
||||
logger = logging.getLogger("cma.action_plans")
|
||||
|
||||
@@ -15,11 +18,53 @@ router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 工具函数
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _quarter_to_date_range(quarter: str) -> tuple:
|
||||
"""解析季度字符串 '2026Q3' → (start_date, end_date)"""
|
||||
m = re.match(r"^(\d{4})[Qq]([1-4])$", quarter.strip())
|
||||
if not m:
|
||||
return None, None
|
||||
year = int(m.group(1))
|
||||
q = int(m.group(2))
|
||||
month_map = {1: (1, 1), 2: (4, 1), 3: (7, 1), 4: (10, 1)}
|
||||
start_month, start_day = month_map[q]
|
||||
end_month = start_month + 2
|
||||
if end_month > 12:
|
||||
end_month -= 12
|
||||
end_year = year + 1
|
||||
else:
|
||||
end_year = year
|
||||
_, last_day = monthrange(end_year, end_month)
|
||||
return (
|
||||
datetime(year, start_month, start_day, tzinfo=timezone.utc),
|
||||
datetime(end_year, end_month, last_day, 23, 59, 59, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
|
||||
"""校验截止日期是否在季度范围内,不匹配则抛422"""
|
||||
q_start, q_end = _quarter_to_date_range(quarter)
|
||||
if q_start is None:
|
||||
return # 无法解析季度,跳过校验
|
||||
due = due_date if due_date.tzinfo else due_date.replace(tzinfo=timezone.utc)
|
||||
if due < q_start:
|
||||
raise HTTPException(422,
|
||||
f"KR截止日期({due.date()})早于本季度开始({q_start.date()}),请检查")
|
||||
if due > q_end:
|
||||
raise HTTPException(422,
|
||||
f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}")
|
||||
|
||||
|
||||
def plan_to_dict(p: ActionPlan) -> dict:
|
||||
return {
|
||||
"id": p.id,
|
||||
"alert_id": p.alert_id,
|
||||
"kpi_id": p.kpi_id,
|
||||
"objective_id": p.objective_id,
|
||||
"kr_id": p.kr_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"assignee": p.assignee,
|
||||
@@ -34,31 +79,39 @@ def plan_to_dict(p: ActionPlan) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# API 端点
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
def list_plans(
|
||||
status: Optional[str] = None,
|
||||
kpi_id: Optional[int] = None,
|
||||
alert_id: Optional[int] = None,
|
||||
keyword: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取行动计划列表"""
|
||||
query = db.query(ActionPlan).order_by(ActionPlan.created_at.desc())
|
||||
|
||||
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)"""
|
||||
query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc())
|
||||
|
||||
if status:
|
||||
query = query.filter(ActionPlan.status == status)
|
||||
if kpi_id:
|
||||
query = query.filter(ActionPlan.kpi_id == kpi_id)
|
||||
if alert_id:
|
||||
query = query.filter(ActionPlan.alert_id == alert_id)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
|
||||
|
||||
# business角色只看自己的
|
||||
if current_user.role == "business":
|
||||
query = query.filter(
|
||||
(ActionPlan.assignee == current_user.username) |
|
||||
(ActionPlan.assignee == current_user.name)
|
||||
)
|
||||
|
||||
|
||||
plans = query.all()
|
||||
result = []
|
||||
for p in plans:
|
||||
@@ -66,8 +119,18 @@ def list_plans(
|
||||
# 附带KPI名称
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
|
||||
item["kpi_code"] = kpi.kpi_code if kpi else None
|
||||
item["kpi_dimension"] = kpi.dimension if kpi else None
|
||||
# 附带KR信息(行动挂KR 2026-08-27)
|
||||
if p.kr_id is not None:
|
||||
kr = db.query(KR).filter(KR.id == p.kr_id).first()
|
||||
item["kr_title"] = kr.title if kr else None
|
||||
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
|
||||
else:
|
||||
item["kr_title"] = None
|
||||
item["kr_metric_kpi_id"] = None
|
||||
result.append(item)
|
||||
|
||||
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@@ -76,21 +139,47 @@ def create_plan(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""创建改善行动计划"""
|
||||
"""创建改善行动计划(也是OKR的KR)"""
|
||||
required = ["title", "kpi_id"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
raise HTTPException(400, f"缺少必填字段: {field}")
|
||||
|
||||
# 账套隔离: 关联KPI必须属于当前企业 (2026-08-23 P1b)
|
||||
kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == data["kpi_id"]).first()
|
||||
if not kpi_ent or kpi_ent.entity_id != entity_id:
|
||||
raise HTTPException(404, "关联KPI不存在")
|
||||
|
||||
# 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业
|
||||
kr_id = data.get("kr_id")
|
||||
if kr_id:
|
||||
kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
# 未显式传objective_id时从KR继承目标
|
||||
if not data.get("objective_id"):
|
||||
data["objective_id"] = kr_ent.objective_id
|
||||
|
||||
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
|
||||
|
||||
# 校验截止日期与关联Objective的季度匹配
|
||||
objective_id = data.get("objective_id")
|
||||
if objective_id and due_date:
|
||||
obj = db.query(Objective).filter(Objective.id == objective_id).first()
|
||||
if obj and obj.quarter:
|
||||
_validate_due_date_against_quarter(due_date, obj.quarter)
|
||||
|
||||
plan = ActionPlan(
|
||||
alert_id=data.get("alert_id"),
|
||||
kpi_id=data["kpi_id"],
|
||||
objective_id=objective_id,
|
||||
kr_id=kr_id,
|
||||
title=data["title"],
|
||||
description=data.get("description"),
|
||||
assignee=data.get("assignee"),
|
||||
priority=data.get("priority", "medium"),
|
||||
due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
|
||||
due_date=due_date,
|
||||
status="pending",
|
||||
progress=0,
|
||||
created_by=current_user.name or current_user.username,
|
||||
@@ -98,9 +187,39 @@ def create_plan(
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
|
||||
# OKR时间分解:KR创建时自动生成3个月度里程碑(按截止日期向前均分)
|
||||
if due_date and not plan.monthly_milestones:
|
||||
try:
|
||||
plan.monthly_milestones = _auto_build_milestones(due_date)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback() # 里程碑生成失败不影响KR创建
|
||||
db.refresh(plan)
|
||||
return plan_to_dict(plan)
|
||||
|
||||
|
||||
def _auto_build_milestones(due_date: datetime) -> list:
|
||||
"""按截止日期向前均分3个月度里程碑"""
|
||||
base = due_date.replace(day=1)
|
||||
month_keys = []
|
||||
for i in range(3, 0, -1):
|
||||
y, m = base.year, base.month - i
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
month_keys.append(f"{y:04d}-{m:02d}")
|
||||
return [
|
||||
{
|
||||
"month": mk,
|
||||
"label": f"里程碑{i + 1}",
|
||||
"status": "pending",
|
||||
"target": None,
|
||||
}
|
||||
for i, mk in enumerate(month_keys)
|
||||
]
|
||||
|
||||
|
||||
@router.put("/{plan_id}")
|
||||
def update_plan(
|
||||
plan_id: int,
|
||||
@@ -111,7 +230,7 @@ def update_plan(
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
|
||||
|
||||
if "title" in data:
|
||||
plan.title = data["title"]
|
||||
if "description" in data:
|
||||
@@ -120,6 +239,21 @@ def update_plan(
|
||||
plan.assignee = data["assignee"]
|
||||
if "priority" in data:
|
||||
plan.priority = data["priority"]
|
||||
if "kr_id" in data:
|
||||
# 行动挂KR: 支持置空(null)或改挂
|
||||
if data["kr_id"] is None:
|
||||
plan.kr_id = None
|
||||
else:
|
||||
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
plan.kr_id = kr_ent.id
|
||||
if data.get("objective_id") is not None:
|
||||
plan.objective_id = data["objective_id"]
|
||||
elif plan.objective_id is None:
|
||||
plan.objective_id = kr_ent.objective_id
|
||||
if "objective_id" in data:
|
||||
plan.objective_id = data["objective_id"]
|
||||
if "due_date" in data:
|
||||
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
if "status" in data:
|
||||
@@ -128,7 +262,7 @@ def update_plan(
|
||||
plan.progress = max(0, min(100, data["progress"]))
|
||||
if "result" in data:
|
||||
plan.result = data["result"]
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan_to_dict(plan)
|
||||
@@ -143,3 +277,147 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(plan)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth)):
|
||||
"""行动计划统计"""
|
||||
query = db.query(ActionPlan)
|
||||
if current_user.role == "business":
|
||||
query = query.filter(
|
||||
(ActionPlan.assignee == current_user.username) |
|
||||
(ActionPlan.assignee == current_user.name)
|
||||
)
|
||||
total = query.count()
|
||||
pending = query.filter(ActionPlan.status == "pending").count()
|
||||
in_progress = query.filter(ActionPlan.status == "in_progress").count()
|
||||
completed = query.filter(ActionPlan.status == "completed").count()
|
||||
from datetime import datetime
|
||||
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count()
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"in_progress": in_progress,
|
||||
"completed": completed,
|
||||
"overdue": overdue,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# COSO内控自检表 (CMA P1 - COSO五要素)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
COSO_CHECKLIST_DATA = {
|
||||
"hanke": {
|
||||
"entity_name": "陕西酣客(白酒经销)",
|
||||
"total_score": 46,
|
||||
"max_score": 100,
|
||||
"risk_level": "high",
|
||||
"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
|
||||
|
||||
+680
-60
@@ -1,76 +1,696 @@
|
||||
"""预警规则配置"""
|
||||
""""
|
||||
预警规则智能化 — 任务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.deps import get_entity_id
|
||||
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)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||
rule_type = Column(String(30), nullable=False, comment="static/dynamic/trend_up/trend_down")
|
||||
trigger_on = Column(String(20), default="actual", comment="actual/forecast/both — 实际值/预测值/两者触发")
|
||||
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),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""列出所有预警规则(账套隔离: 按token企业, 2026-08-23 P2)"""
|
||||
query = db.query(AlertRule).filter(AlertRule.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(AlertRule.kpi_id == kpi_id)
|
||||
if rule_type:
|
||||
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")
|
||||
trigger_on = data.get("trigger_on", "actual")
|
||||
|
||||
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", "forecast_deviation"): # 升级2b: 预测偏差
|
||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
||||
|
||||
rule = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
trigger_on=trigger_on,
|
||||
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", "trigger_on", "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
|
||||
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (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
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 预测值检查 + 情景建议
|
||||
# ============================================================
|
||||
|
||||
def _check_forecast_alerts(db: Session) -> int:
|
||||
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast'和'both'的规则"""
|
||||
from app.utils.cash_forecast_engine import forecast_cash_flow, generate_scenario_suggestion
|
||||
from app.models import CashForecast
|
||||
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.enabled == 1,
|
||||
AlertRule.trigger_on.in_(["forecast", "both"]),
|
||||
).all()
|
||||
|
||||
if not rules:
|
||||
return 0
|
||||
|
||||
alerts_generated = 0
|
||||
rule_kpi_cache = {}
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
kpi = rule_kpi_cache.get(rule.kpi_id)
|
||||
if kpi is None:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
if kpi:
|
||||
rule_kpi_cache[rule.kpi_id] = kpi
|
||||
if not kpi:
|
||||
continue
|
||||
|
||||
entity_id = kpi.entity_id or 1
|
||||
# 获取最新的预测
|
||||
latest_forecasts = db.query(CashForecast).filter(
|
||||
CashForecast.entity_id == entity_id,
|
||||
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
|
||||
|
||||
if not latest_forecasts:
|
||||
# 没有已有预测,执行一次实时预测
|
||||
from app.utils.cash_forecast_engine import save_forecast_to_db
|
||||
result = forecast_cash_flow(entity_id, db)
|
||||
try:
|
||||
save_forecast_to_db(entity_id, result, db)
|
||||
except:
|
||||
pass
|
||||
latest_forecasts = db.query(CashForecast).filter(
|
||||
CashForecast.entity_id == entity_id,
|
||||
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
|
||||
|
||||
if not latest_forecasts:
|
||||
continue
|
||||
|
||||
# 检查预测值是否超限
|
||||
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
params["kpi"] = kpi
|
||||
for forecast in latest_forecasts:
|
||||
value = forecast.predicted_cash
|
||||
if value is None:
|
||||
continue
|
||||
alert_level, alert_message = _check_static(value, params, kpi)
|
||||
if alert_level and alert_level != "green":
|
||||
# 生成情景建议
|
||||
sug_type = "cash_critical" if alert_level == "red" else "cash_low"
|
||||
sug = generate_scenario_suggestion(
|
||||
sug_type, kpi.kpi_name,
|
||||
{"expected_receivables": 20, "forecast_date": forecast.forecast_date.isoformat()}
|
||||
)
|
||||
suggestion_text = f"{sug['title']}:{sug['description']}\\n建议行动:{';'.join(sug['actions'])}"
|
||||
|
||||
existing = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == rule.kpi_id,
|
||||
KPIAlert.alert_type == "forecast",
|
||||
KPIAlert.alert_level == alert_level,
|
||||
KPIAlert.alert_message == alert_message,
|
||||
KPIAlert.status == "pending",
|
||||
).first()
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
kpi_id=rule.kpi_id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_message,
|
||||
alert_type="forecast",
|
||||
suggestion=suggestion_text,
|
||||
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 alerts_generated
|
||||
|
||||
|
||||
@router.post("/check-forecast")
|
||||
def run_forecast_alert_check(db: Session = Depends(get_db)):
|
||||
"""执行预测值预警检查 — 检查未来7天预测值是否超限"""
|
||||
generated = _check_forecast_alerts(db)
|
||||
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
|
||||
|
||||
|
||||
@router.post("/generate-suggestions")
|
||||
def generate_alert_suggestions(db: Session = Depends(get_db)):
|
||||
"""为所有未处理的预警生成情景建议"""
|
||||
from app.utils.cash_forecast_engine import generate_scenario_suggestion
|
||||
|
||||
pending = db.query(KPIAlert).filter(
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.suggestion.is_(None),
|
||||
).all()
|
||||
|
||||
updated = 0
|
||||
for alert in pending:
|
||||
try:
|
||||
sug_type = "cash_critical" if alert.alert_level == "red" else "cash_low"
|
||||
if alert.alert_type == "forecast":
|
||||
sug_type = "cash_critical" if alert.alert_level == "red" else "cash_low"
|
||||
else:
|
||||
sug_type = "cash_low"
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first()
|
||||
kpi_name = kpi.kpi_name if kpi else "未知KPI"
|
||||
|
||||
sug = generate_scenario_suggestion(sug_type, kpi_name, {
|
||||
"alert_level": alert.alert_level,
|
||||
"alert_message": alert.alert_message,
|
||||
})
|
||||
alert.suggestion = f"{sug['title']}:{sug['description']}\\n建议行动:{';'.join(sug['actions'])}"
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.error(f"生成建议失败: alert_id={alert.id}, error={e}")
|
||||
|
||||
db.commit()
|
||||
return {"message": f"已为{updated}条预警生成情景建议", "updated": updated}
|
||||
|
||||
|
||||
@router.post("/run-forecast-deviation")
|
||||
def run_forecast_deviation_check(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
|
||||
对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values),
|
||||
偏差 > threshold_pct → 生成/更新 pending 预警(去重)"""
|
||||
from app.models import KpiForecastLog
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.rule_type == "forecast_deviation",
|
||||
AlertRule.enabled == 1,
|
||||
).all()
|
||||
if not rules:
|
||||
return {"message": "无预测偏差规则,可先创建 rule_type=forecast_deviation 规则", "generated": 0}
|
||||
|
||||
generated = 0
|
||||
for rule in rules:
|
||||
try:
|
||||
params = rule.params or {}
|
||||
threshold = float(params.get("threshold_pct", 15))
|
||||
# 最新预测
|
||||
log = db.query(KpiForecastLog).filter(
|
||||
KpiForecastLog.entity_id == entity_id,
|
||||
KpiForecastLog.kpi_id == rule.kpi_id,
|
||||
).order_by(KpiForecastLog.created_at.desc()).first()
|
||||
if not log or log.forecast_value is None:
|
||||
continue
|
||||
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
|
||||
actual = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == rule.kpi_id,
|
||||
KPIValue.period == log.period,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if not actual or not actual.actual_value:
|
||||
continue
|
||||
base = abs(actual.actual_value)
|
||||
if base < 1e-9:
|
||||
continue
|
||||
deviation_pct = abs(log.forecast_value - actual.actual_value) / base * 100
|
||||
if deviation_pct <= threshold:
|
||||
continue
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
kpi_label = f"{kpi.kpi_name}({kpi.kpi_code})" if kpi else f"KPI#{rule.kpi_id}"
|
||||
alert_level = "red" if deviation_pct > threshold * 2 else "yellow"
|
||||
alert_message = (
|
||||
f"预测偏差 {deviation_pct:.1f}% > 阈值{threshold}%:"
|
||||
f"{kpi_label} 预测{log.period}={log.forecast_value},实际={actual.actual_value}"
|
||||
)
|
||||
# 去重: 同KPI+period 已有 pending 偏差预警
|
||||
existing = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == rule.kpi_id,
|
||||
KPIAlert.alert_message.like(f"%预测偏差%{log.period}%"),
|
||||
KPIAlert.status == "pending",
|
||||
).first()
|
||||
if existing:
|
||||
existing.alert_message = alert_message
|
||||
existing.alert_level = alert_level
|
||||
else:
|
||||
db.add(KPIAlert(
|
||||
kpi_id=rule.kpi_id,
|
||||
kpi_value_id=actual.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_message,
|
||||
alert_type="forecast",
|
||||
status="pending",
|
||||
))
|
||||
generated += 1
|
||||
except Exception as e:
|
||||
logger.error(f"预测偏差检查失败 rule_id={rule.id}: {e}")
|
||||
continue
|
||||
db.commit()
|
||||
return {"message": f"预测偏差检查完成: {generated}条", "generated": generated}
|
||||
|
||||
+318
-11
@@ -1,17 +1,22 @@
|
||||
"""预警 API"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIAlert, OperationLog
|
||||
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.alerts")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
@router.get("")
|
||||
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db)):
|
||||
query = db.query(KPIAlert)
|
||||
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b)
|
||||
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(KPIAlert.status == status)
|
||||
total = query.count()
|
||||
@@ -19,12 +24,314 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De
|
||||
return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]}
|
||||
|
||||
@router.post("/{alert_id}/resolve")
|
||||
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
|
||||
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
alert.status = "resolved"
|
||||
alert.resolution = data.get("resolution", "")
|
||||
alert.assignee = data.get("assignee", alert.assignee)
|
||||
from datetime import datetime; alert.resolved_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return {
|
||||
"message": "已处理",
|
||||
"assignee": alert.assignee,
|
||||
"alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns},
|
||||
"suggest_create_action_plan": alert.alert_level == "red",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/process")
|
||||
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""标记预警为处理中"""
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
||||
if alert:
|
||||
alert.status = "resolved"
|
||||
alert.resolution = data.get("resolution", "")
|
||||
alert.assignee = data.get("assignee", alert.assignee)
|
||||
from datetime import datetime; alert.resolved_at = datetime.now()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
if alert.status == "resolved":
|
||||
raise HTTPException(400, "已处理的预警不能重复处理")
|
||||
assignee = data.get("assignee")
|
||||
if not assignee:
|
||||
raise HTTPException(400, "缺少处理人")
|
||||
alert.status = "processing"
|
||||
alert.assignee = assignee
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return {"message": "已标记为处理中", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/escalate")
|
||||
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""升级预警级别"""
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
assignee = data.get("assignee")
|
||||
if not assignee:
|
||||
raise HTTPException(400, "缺少处理人")
|
||||
if alert.status == "resolved":
|
||||
raise HTTPException(400, "已处理的预警不能升级")
|
||||
if alert.alert_level != "red":
|
||||
alert.alert_level = "red"
|
||||
alert.assignee = assignee
|
||||
db.commit()
|
||||
return {"message": "已处理", "assignee": alert.assignee}
|
||||
db.refresh(alert)
|
||||
return {"message": "已升级", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
|
||||
|
||||
|
||||
@router.get("/check-timeout")
|
||||
def check_alert_timeout(db: Session = Depends(get_db)):
|
||||
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色"""
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.now() - timedelta(hours=24)
|
||||
timeout_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.created_at < cutoff,
|
||||
KPIAlert.alert_level != "red",
|
||||
).all()
|
||||
upgraded_count = 0
|
||||
for alert in timeout_alerts:
|
||||
alert.alert_level = "red"
|
||||
alert.status = "processing"
|
||||
upgraded_count += 1
|
||||
if upgraded_count:
|
||||
db.commit()
|
||||
return {"total_timeout": len(timeout_alerts), "upgraded_count": upgraded_count}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/create-action-plan")
|
||||
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""从预警创建改善行动计划"""
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
if alert.action_plan_linked_id:
|
||||
raise HTTPException(400, "已关联改善计划")
|
||||
plan = ActionPlan(
|
||||
alert_id=alert.id,
|
||||
kpi_id=alert.kpi_id,
|
||||
title=f"改善: {alert.alert_message}",
|
||||
assignee=data.get("assignee", ""),
|
||||
priority="high" if alert.alert_level == "red" else "medium",
|
||||
created_by=data.get("created_by", ""),
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
alert.action_plan_linked_id = plan.id
|
||||
db.commit()
|
||||
return {"message": "改善行动计划已创建", "plan_id": plan.id, "priority": plan.priority}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 功能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,185 @@
|
||||
"""置信度评分系统 — 财务Bot分析结论管理"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth
|
||||
from app.models import AnalysisResult, KPIValue, KPIDefinition
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/cma/analysis", tags=["置信度评分"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
|
||||
def _calc_confidence(has_actual: bool, has_target: bool, has_trend: bool, has_review: bool) -> int:
|
||||
"""基于数据完整度自动计算置信度"""
|
||||
if has_review:
|
||||
return 95
|
||||
if has_actual and has_target and has_trend:
|
||||
return 85
|
||||
if has_actual and has_target:
|
||||
return 70
|
||||
if has_actual:
|
||||
return 50
|
||||
return 30 # 无实际值,基于推测
|
||||
|
||||
|
||||
@router.get("/result")
|
||||
async def get_analysis_results(
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
|
||||
kpi_code: Optional[str] = Query(None, description="KPI编码"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""查询分析结论(按期间和/或KPI编码过滤)"""
|
||||
query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc())
|
||||
|
||||
if period:
|
||||
query = query.filter(AnalysisResult.period == period)
|
||||
if kpi_code:
|
||||
query = query.filter(AnalysisResult.kpi_code == kpi_code)
|
||||
|
||||
results = query.all()
|
||||
|
||||
return {
|
||||
"total": len(results),
|
||||
"period": period,
|
||||
"results": [
|
||||
{
|
||||
"id": r.id,
|
||||
"period": r.period,
|
||||
"结论": r.conclusion,
|
||||
"置信度": f"{r.confidence}%",
|
||||
"数据来源": r.data_source,
|
||||
"计算逻辑": r.calculation_logic,
|
||||
"可比基准": r.comparable_benchmark,
|
||||
"局限": r.limitations,
|
||||
"kpi_code": r.kpi_code,
|
||||
"kpi_name": r.kpi_name,
|
||||
"has_actual": bool(r.has_actual),
|
||||
"has_target": bool(r.has_target),
|
||||
"has_trend": bool(r.has_trend),
|
||||
"has_review": bool(r.has_review),
|
||||
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/result")
|
||||
def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
||||
"""提交分析结果(自动计算置信度)"""
|
||||
if not data:
|
||||
data = {}
|
||||
period = data.get("period", "")
|
||||
conclusion = data.get("conclusion", "")
|
||||
data_source = data.get("data_source", "")
|
||||
calculation_logic = data.get("calculation", "")
|
||||
comparable_benchmark = data.get("comparable_benchmark")
|
||||
limitations = data.get("limitations")
|
||||
kpi_code = data.get("kpi_code")
|
||||
kpi_name = data.get("kpi_name")
|
||||
has_actual = data.get("has_actual", False)
|
||||
has_target = data.get("has_target", False)
|
||||
has_trend = data.get("has_trend", False)
|
||||
has_review = data.get("has_review", False)
|
||||
|
||||
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
|
||||
|
||||
result = AnalysisResult(
|
||||
period=period,
|
||||
conclusion=conclusion,
|
||||
confidence=confidence,
|
||||
data_source=data_source,
|
||||
calculation_logic=calculation_logic,
|
||||
comparable_benchmark=comparable_benchmark,
|
||||
limitations=limitations,
|
||||
kpi_code=kpi_code,
|
||||
kpi_name=kpi_name,
|
||||
has_actual=1 if has_actual else 0,
|
||||
has_target=1 if has_target else 0,
|
||||
has_trend=1 if has_trend else 0,
|
||||
has_review=1 if has_review else 0,
|
||||
)
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
|
||||
return {
|
||||
"id": result.id,
|
||||
"period": result.period,
|
||||
"结论": result.conclusion,
|
||||
"置信度": f"{result.confidence}%",
|
||||
"数据来源": result.data_source,
|
||||
"计算逻辑": result.calculation_logic,
|
||||
"可比基准": result.comparable_benchmark,
|
||||
"局限": result.limitations,
|
||||
"confidence_score": result.confidence,
|
||||
"message": "分析结论已保存",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/result/{result_id}")
|
||||
async def delete_analysis_result(
|
||||
result_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除分析结论"""
|
||||
result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first()
|
||||
if not result:
|
||||
raise HTTPException(404, "分析结论不存在")
|
||||
db.delete(result)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.post("/auto-calculate")
|
||||
async def auto_calculate_confidence(
|
||||
period: str = Query(..., description="期间 YYYY-MM"),
|
||||
kpi_code: str = Query(..., description="KPI编码"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""根据KPI数据完整性自动生成置信度评分"""
|
||||
# 查找KPI定义
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI编码 {kpi_code} 不存在")
|
||||
|
||||
# 查找该期间的实际值
|
||||
value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
|
||||
# 查找历史数据(趋势)
|
||||
trend_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
|
||||
has_actual = value is not None and value.actual_value is not None
|
||||
has_target = kpi.target_value is not None
|
||||
has_trend = len(trend_values) >= 2
|
||||
has_review = False
|
||||
|
||||
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
|
||||
|
||||
return {
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"period": period,
|
||||
"has_actual": has_actual,
|
||||
"has_target": has_target,
|
||||
"has_trend": has_trend,
|
||||
"has_review": has_review,
|
||||
"confidence": confidence,
|
||||
"confidence_label": f"{confidence}%",
|
||||
"数据完备度": {
|
||||
"10%": "无数据",
|
||||
"50%": "有实际值",
|
||||
"70%": "有实际值+目标值",
|
||||
"85%": "有实际值+目标值+历史趋势",
|
||||
"95%": "有全部数据+人工复核",
|
||||
}.get(str(confidence), "基于推测"),
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Bot API 操作审计中间件
|
||||
======================
|
||||
|
||||
每次 Bot API 调用(/api/cma/bot*)记录一行 JSON 审计日志到
|
||||
backend/logs/bot_audit.log(可用环境变量 CMA_BOT_AUDIT_LOG 覆盖路径)。
|
||||
|
||||
JSON 行字段: timestamp / bot_name / endpoint / method / risk_level /
|
||||
entity_id / status
|
||||
L3 批量写额外记录 rows(导入行数 / 批量条数)。
|
||||
|
||||
设计约束:
|
||||
- 不阻塞业务: 所有日志写入失败仅静默跳过,不影响请求结果
|
||||
- 只读 body(starlette 会缓存并回放给下游),仅在 application/json 时解析
|
||||
- 不改动任何现有 API 行为与多租户隔离
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
_LOG_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"logs",
|
||||
)
|
||||
_DEFAULT_LOG_PATH = os.path.join(_LOG_DIR, "bot_audit.log")
|
||||
|
||||
_audit_logger = None
|
||||
|
||||
|
||||
def _get_logger():
|
||||
"""构造/复用审计 logger(路径取 CMA_BOT_AUDIT_LOG 覆盖值,便于测试隔离)"""
|
||||
global _audit_logger
|
||||
if _audit_logger is None:
|
||||
log_path = os.getenv("CMA_BOT_AUDIT_LOG") or _DEFAULT_LOG_PATH
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
logger = logging.getLogger("cma.bot_audit")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
# 清掉旧 handler,避免日志名单例导致路径切换失效
|
||||
for h in list(logger.handlers):
|
||||
logger.removeHandler(h)
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
_audit_logger = logger
|
||||
return _audit_logger
|
||||
|
||||
|
||||
def write_audit_line(record: dict):
|
||||
"""写一行 JSON 审计日志;任何异常都静默(不阻塞业务)"""
|
||||
try:
|
||||
_get_logger().info(json.dumps(record, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_bot_name(request: Request) -> str:
|
||||
"""解析调用方标识: X-BOT-KEY → 配置名/原始key;bridge → bot-bridge;其余 → web-user/unknown"""
|
||||
key = request.headers.get("X-BOT-KEY")
|
||||
if key:
|
||||
try:
|
||||
from app.api.bot_bridge import _BOT_API_KEYS
|
||||
info = _BOT_API_KEYS.get(key)
|
||||
if isinstance(info, dict) and info.get("name"):
|
||||
return info["name"]
|
||||
except Exception:
|
||||
pass
|
||||
return key
|
||||
if request.headers.get("X-BRIDGE-TOKEN"):
|
||||
return "bot-bridge"
|
||||
if (request.headers.get("Authorization") or "").startswith("Bearer "):
|
||||
return "web-user"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _resolve_risk_level(request: Request, route=None, endpoint=None):
|
||||
"""级别解析顺序: 路由函数装饰器标注 → API_RISK_MAP(route.path) → API_RISK_MAP(请求URL)"""
|
||||
if endpoint is not None:
|
||||
level = getattr(endpoint, "risk_level", None)
|
||||
if level:
|
||||
return level
|
||||
if route is not None:
|
||||
from app.risk_levels import get_risk_level
|
||||
path = getattr(route, "path", None)
|
||||
if path:
|
||||
for m in getattr(route, "methods", set()) or set():
|
||||
if m in ("GET", "POST", "PUT", "DELETE", "PATCH"):
|
||||
level = get_risk_level(m, path)
|
||||
if level:
|
||||
return level
|
||||
try:
|
||||
from app.risk_levels import get_risk_level
|
||||
return get_risk_level(request.method, request.url.path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _extract_entity_id(request: Request):
|
||||
"""多租户 entity_id 提取(尽力而为): 查询参数 → JSON body(仅 application/json)"""
|
||||
try:
|
||||
q = request.query_params.get("entity_id")
|
||||
if q is not None and str(q) != "":
|
||||
return int(q) if str(q).isdigit() else q
|
||||
except Exception:
|
||||
pass
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
raw = await request.body()
|
||||
if raw:
|
||||
data = json.loads(raw)
|
||||
eid = data.get("entity_id")
|
||||
if eid is not None:
|
||||
return int(eid) if str(eid).isdigit() else eid
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _extract_l3_rows(level, resp_body: bytes):
|
||||
"""L3 批量写: 从响应体提取行数/条数(imported / kpi_updated / causality_links / total)"""
|
||||
if level != "L3" or not resp_body:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(resp_body)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for key in ("imported", "kpi_updated"):
|
||||
v = data.get(key)
|
||||
if isinstance(v, (int, float)):
|
||||
return int(v)
|
||||
links = data.get("causality_links")
|
||||
if isinstance(links, list):
|
||||
return len(links)
|
||||
total = data.get("total")
|
||||
if isinstance(total, (int, float)):
|
||||
return int(total)
|
||||
return None
|
||||
|
||||
|
||||
async def bot_audit_middleware(request: Request, call_next):
|
||||
"""HTTP 中间件: 仅审计 /api/cma/bot* 路径;任何异常不影响业务"""
|
||||
path = request.url.path
|
||||
if not path.startswith("/api/cma/bot"):
|
||||
return await call_next(request)
|
||||
|
||||
entity_id = None
|
||||
try:
|
||||
entity_id = await _extract_entity_id(request)
|
||||
except Exception:
|
||||
entity_id = None
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
# 兜底记录(全局异常handler会返回500,此处保证审计不丢)
|
||||
try:
|
||||
write_audit_line({
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
"bot_name": _resolve_bot_name(request),
|
||||
"endpoint": path,
|
||||
"method": request.method,
|
||||
"risk_level": _resolve_risk_level(request) or "NA",
|
||||
"entity_id": entity_id,
|
||||
"status": 500,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
# 级别解析(route 由路由器在 call_next 内写入 scope)
|
||||
level = None
|
||||
try:
|
||||
route = request.scope.get("route")
|
||||
endpoint = getattr(route, "endpoint", None) if route else None
|
||||
level = _resolve_risk_level(request, route=route, endpoint=endpoint)
|
||||
except Exception:
|
||||
level = None
|
||||
|
||||
# 捕获响应体(L3 需要行数),并重放
|
||||
resp_body = b""
|
||||
try:
|
||||
body_iterator = getattr(response, "body_iterator", None)
|
||||
if body_iterator is not None:
|
||||
chunks = [chunk async for chunk in body_iterator]
|
||||
resp_body = b"".join(chunks)
|
||||
rows = _extract_l3_rows(level, resp_body)
|
||||
except Exception:
|
||||
rows = None
|
||||
|
||||
record = {
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
"bot_name": _resolve_bot_name(request),
|
||||
"endpoint": path,
|
||||
"method": request.method,
|
||||
"risk_level": level or "NA",
|
||||
"entity_id": entity_id,
|
||||
"status": response.status_code,
|
||||
}
|
||||
if rows is not None:
|
||||
record["rows"] = rows
|
||||
|
||||
try:
|
||||
write_audit_line(record)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 响应体已被消费 → 重建响应(Bot接口均为小JSON,非流式)
|
||||
if resp_body:
|
||||
from fastapi.responses import Response
|
||||
return Response(
|
||||
content=resp_body,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.media_type,
|
||||
)
|
||||
return response
|
||||
+148
-6
@@ -1,14 +1,31 @@
|
||||
"""用户认证"""
|
||||
import hashlib
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.models import User, UserEntity, Entity, OperationLog
|
||||
from app.auth_middleware import create_token, require_auth, ROLES
|
||||
|
||||
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
|
||||
|
||||
|
||||
def _user_entity_ids(db: Session, user_id: int) -> list[int]:
|
||||
"""返回用户被授权的企业ID列表"""
|
||||
rows = db.query(UserEntity.entity_id).filter(UserEntity.user_id == user_id).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _ensure_default_grants(db: Session, user_id: int) -> None:
|
||||
"""存量用户兼容:若用户没有任何授权记录,则授予所有active企业(不锁死老账号)"""
|
||||
cnt = db.query(UserEntity).filter(UserEntity.user_id == user_id).count()
|
||||
if cnt > 0:
|
||||
return
|
||||
entities = db.query(Entity).filter(Entity.status == "active").all()
|
||||
for e in entities:
|
||||
db.add(UserEntity(user_id=user_id, entity_id=e.id, granted_by=1))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(data: dict, db: Session = Depends(get_db)):
|
||||
username = data.get("username", "")
|
||||
@@ -17,9 +34,28 @@ def login(data: dict, db: Session = Depends(get_db)):
|
||||
if not user or user.password_hash != hashlib.sha256(password.encode()).hexdigest():
|
||||
raise HTTPException(401, "用户名或密码错误")
|
||||
|
||||
token = create_token(user.id)
|
||||
# 账套模式:登录必须指定企业(entity_id)
|
||||
entity_id = data.get("entity_id")
|
||||
if entity_id is None:
|
||||
raise HTTPException(400, "账套模式:请选择登录企业(entity_id)")
|
||||
|
||||
# 存量兼容:无授权记录时自动授予active企业
|
||||
_ensure_default_grants(db, user.id)
|
||||
|
||||
allowed = _user_entity_ids(db, user.id)
|
||||
if int(entity_id) not in allowed:
|
||||
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
|
||||
|
||||
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
|
||||
if not entity or entity.status != "active":
|
||||
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
|
||||
|
||||
token = create_token(user.id, int(entity_id))
|
||||
return {
|
||||
"token": token,
|
||||
"entity_id": int(entity_id),
|
||||
"entity_name": entity.name,
|
||||
"entity_short_name": entity.short_name,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
@@ -43,13 +79,23 @@ def register(data: dict, db: Session = Depends(get_db)):
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# 注册默认授权第一个active企业(不授予全部,保证账套隔离)
|
||||
first_ent = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).first()
|
||||
if first_ent:
|
||||
db.add(UserEntity(user_id=user.id, entity_id=first_ent.id))
|
||||
db.commit()
|
||||
return {"message": "注册成功"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_me(current_user: User = Depends(require_auth)):
|
||||
"""获取当前用户信息"""
|
||||
return {
|
||||
def get_me(request: Request, current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""获取当前用户信息(含当前账套)"""
|
||||
from app.auth_middleware import extract_bearer_token, get_token_entity_id
|
||||
token = extract_bearer_token(request)
|
||||
eid = get_token_entity_id(token) if token else None
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first() if eid else None
|
||||
d = {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"name": current_user.name,
|
||||
@@ -57,6 +103,11 @@ def get_me(current_user: User = Depends(require_auth)):
|
||||
"role_name": ROLES.get(current_user.role, {}).get("name", current_user.role),
|
||||
"phone": current_user.phone,
|
||||
}
|
||||
if ent:
|
||||
d["entity_id"] = ent.id
|
||||
d["entity_name"] = ent.name
|
||||
d["entity_short_name"] = ent.short_name
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
@@ -68,3 +119,94 @@ def list_roles():
|
||||
for k, v in ROLES.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/entities")
|
||||
def my_entities(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""当前用户被授权的企业列表(登录页下拉/切换器数据源)"""
|
||||
_ensure_default_grants(db, current_user.id)
|
||||
ids = _user_entity_ids(db, current_user.id)
|
||||
entities = db.query(Entity).filter(
|
||||
Entity.id.in_(ids),
|
||||
Entity.status == "active",
|
||||
).order_by(Entity.id).all()
|
||||
return {
|
||||
"data": [
|
||||
{"id": e.id, "name": e.name, "short_name": e.short_name, "industry": e.industry}
|
||||
for e in entities
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/my-entities")
|
||||
def my_entities_alias(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""别名:/auth/my-entities(前端切换器调用)"""
|
||||
return my_entities(current_user, db)
|
||||
|
||||
|
||||
@router.get("/login-entities")
|
||||
def login_entities_options(username: str = None, db: Session = Depends(get_db)):
|
||||
"""登录页公司选择器:按用户名返回授权企业(无鉴权,登录前调用;不暴露用户名是否存在)"""
|
||||
if not username:
|
||||
return {"data": []}
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return {"data": []}
|
||||
_ensure_default_grants(db, user.id)
|
||||
ids = _user_entity_ids(db, user.id)
|
||||
ents = db.query(Entity).filter(
|
||||
Entity.id.in_(ids),
|
||||
Entity.status == "active",
|
||||
).order_by(Entity.id).all()
|
||||
return {"data": [{"id": e.id, "name": e.name, "short_name": e.short_name} for e in ents]}
|
||||
|
||||
|
||||
@router.post("/switch-entity")
|
||||
def switch_entity(
|
||||
data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""账套切换:校验授权 → 重新签发token → 写操作日志(方案B:无缝刷新token)"""
|
||||
entity_id = data.get("entity_id")
|
||||
if not entity_id:
|
||||
raise HTTPException(400, "缺少entity_id")
|
||||
|
||||
_ensure_default_grants(db, current_user.id)
|
||||
allowed = _user_entity_ids(db, current_user.id)
|
||||
if int(entity_id) not in allowed:
|
||||
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
|
||||
|
||||
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
|
||||
if not entity or entity.status != "active":
|
||||
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
|
||||
|
||||
# 旧token失效:删除当前请求的旧token(切换即下线旧账套凭据)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.Redis(host="127.0.0.1", port=6379, db=1, decode_responses=True)
|
||||
r.delete(f"cma:token:{auth[7:]}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
token = create_token(current_user.id, int(entity_id))
|
||||
|
||||
# 切换留痕
|
||||
db.add(OperationLog(
|
||||
user_id=current_user.id,
|
||||
action="switch_entity",
|
||||
target_type="entity",
|
||||
target_id=int(entity_id),
|
||||
detail={"entity_id": int(entity_id), "entity_name": entity.name, "from": "account-switch"},
|
||||
))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"entity_id": int(entity_id),
|
||||
"entity_name": entity.name,
|
||||
"entity_short_name": entity.short_name,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""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.deps import get_entity_id
|
||||
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), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取用户保存的报表(账套隔离: 按token企业, 2026-08-23 P2)"""
|
||||
reports = db.query(BiReport).filter(BiReport.entity_id == entity_id).order_by(BiReport.updated_at.desc()).all()
|
||||
result = []
|
||||
for r in reports:
|
||||
d = {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
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,774 @@
|
||||
"""
|
||||
CMA BOT API桥接层 — 供财务BOT/店研学BOT调用
|
||||
无需用户登录,使用 BOT API Key 认证
|
||||
"""
|
||||
import os, json, logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header, UploadFile, File
|
||||
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, Objective, KR,
|
||||
)
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.models import KPICausality
|
||||
from app.risk_levels import risk_level
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge")
|
||||
|
||||
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")
|
||||
@risk_level("L1")
|
||||
def ping():
|
||||
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
|
||||
|
||||
|
||||
# ── 总览 ──
|
||||
|
||||
@router.get("/overview")
|
||||
@risk_level("L1")
|
||||
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")
|
||||
@risk_level("L1")
|
||||
def bot_kpis(
|
||||
dimension: Optional[str] = Query(None),
|
||||
status: str = Query("active"),
|
||||
limit: int = Query(200, le=1000),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
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, "formula_desc": k.formula_desc,
|
||||
"latest_value": _float(latest.actual_value) if latest else None,
|
||||
"latest_period": latest.period if latest else None,
|
||||
"status": k.status,
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
|
||||
|
||||
@router.get("/kpis/{kpi_id}/history")
|
||||
@risk_level("L1")
|
||||
def bot_kpi_history(
|
||||
kpi_id: int, limit: int = Query(12, le=60),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
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")
|
||||
@risk_level("L1")
|
||||
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")
|
||||
@risk_level("L1")
|
||||
def bot_alerts(
|
||||
status: str = Query("pending"),
|
||||
level: Optional[str] = Query(None),
|
||||
limit: int = Query(50, le=200),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
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")
|
||||
@risk_level("L1")
|
||||
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")
|
||||
@risk_level("L1")
|
||||
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")
|
||||
@risk_level("L1")
|
||||
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")
|
||||
@risk_level("L1")
|
||||
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,
|
||||
"due_date": _safe_iso(p.due_date),
|
||||
"created_at": _safe_iso(p.created_at),
|
||||
} for p in plans
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 组织 ──
|
||||
|
||||
@router.get("/organization")
|
||||
@risk_level("L1")
|
||||
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")
|
||||
@risk_level("L1")
|
||||
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")
|
||||
@risk_level("L1")
|
||||
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")
|
||||
@risk_level("L1")
|
||||
def bot_query(
|
||||
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""统一查询入口 — 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 ("okr", "all"):
|
||||
objs = db.query(Objective).filter(Objective.status == "active").all()
|
||||
result["okr"] = []
|
||||
for o in objs:
|
||||
# KR完整修复(2026-08-27): 从krs表读取
|
||||
krs = db.query(KR).filter(KR.objective_id == o.id).all()
|
||||
result["okr"].append({
|
||||
"id": o.id, "title": o.title, "quarter": o.quarter,
|
||||
"dimension": o.dimension, "progress": o.progress,
|
||||
"confidence": o.confidence,
|
||||
"key_results": [
|
||||
{"title": kr.title, "status": kr.status, "progress": kr.progress,
|
||||
"operator": kr.operator, "target_value": _float(kr.target_value)}
|
||||
for kr in krs
|
||||
]
|
||||
})
|
||||
|
||||
if q in ("actions", "all"):
|
||||
acts = db.query(ActionPlan).limit(30).all()
|
||||
result["actions"] = [
|
||||
{"id": a.id, "title": a.title, "status": a.status,
|
||||
"progress": a.progress, "assignee": a.assignee}
|
||||
for a in acts
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
@risk_level("L3")
|
||||
def bot_import_excel(
|
||||
file: UploadFile = File(...),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Bot上传Excel导入KPI数据到CMA"""
|
||||
import pandas as pd, io, hashlib
|
||||
from app.models import KPIValue
|
||||
try:
|
||||
content = file.file.read()
|
||||
df = pd.read_excel(io.BytesIO(content))
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Excel解析失败: {e}")
|
||||
finally:
|
||||
file.file.close()
|
||||
|
||||
# 智能识别列名
|
||||
col_map = {"kpi_code": ["kpi_code", "KPI编码", "指标编码", "code"],
|
||||
"period": ["period", "期间", "月份", "month", "日期"],
|
||||
"actual_value": ["actual_value", "实际值", "值", "金额", "value", "amount"]}
|
||||
|
||||
mapped = {}
|
||||
for field, aliases in col_map.items():
|
||||
for col in df.columns:
|
||||
if str(col).strip() in aliases or str(col).strip().lower() in aliases:
|
||||
mapped[field] = str(col).strip()
|
||||
break
|
||||
|
||||
if "actual_value" not in mapped:
|
||||
raise HTTPException(400, f"无法识别数值列,支持的列名: {col_map['actual_value']}")
|
||||
if "kpi_code" not in mapped:
|
||||
raise HTTPException(400, f"无法识别KPI编码列,支持的列名: {col_map['kpi_code']}")
|
||||
|
||||
kpi_col = mapped["kpi_code"]
|
||||
val_col = mapped["actual_value"]
|
||||
period_col = mapped.get("period")
|
||||
|
||||
count = 0
|
||||
errors = []
|
||||
for idx, row in df.iterrows():
|
||||
try:
|
||||
kpi_code = str(row[kpi_col]).strip()
|
||||
val = float(row[val_col])
|
||||
period = str(row[period_col]).strip() if period_col else datetime.now().strftime("%Y-%m")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if not kpi:
|
||||
errors.append(f"第{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
|
||||
continue
|
||||
|
||||
kv = KPIValue(kpi_id=kpi.id, entity_id=kpi.entity_id, period=period, actual_value=val,
|
||||
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
||||
db.add(kv)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"第{idx+2}行: {e}")
|
||||
|
||||
db.commit()
|
||||
return {"ok": True, "imported": count, "errors": len(errors), "detail": errors[:5]}
|
||||
|
||||
|
||||
# ── 自然语言查询 ──
|
||||
|
||||
@router.post("/okr/create")
|
||||
@risk_level("L2")
|
||||
def bot_okr_create(
|
||||
title: str = Query(...),
|
||||
quarter: str = Query(...),
|
||||
dimension: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Bot创建OKR目标"""
|
||||
from app.models import Objective
|
||||
obj = Objective(title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return {"ok": True, "id": obj.id, "title": obj.title, "confidence": obj.confidence}
|
||||
|
||||
|
||||
@router.get("/okr/list")
|
||||
@risk_level("L1")
|
||||
def bot_okr_list(
|
||||
quarter: Optional[str] = Query(None),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Bot列出OKR(含KR进度)"""
|
||||
from app.models import Objective
|
||||
q = db.query(Objective)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
objs = q.order_by(Objective.quarter.desc()).all()
|
||||
return {"total": len(objs), "items": [
|
||||
{"id": o.id, "title": o.title, "quarter": o.quarter,
|
||||
"dimension": o.dimension, "progress": o.progress,
|
||||
"confidence": o.confidence, "status": o.status,
|
||||
"kr_count": db.query(func.count(ActionPlan.id)).filter(ActionPlan.objective_id == o.id).scalar() or 0}
|
||||
for o in objs
|
||||
]}
|
||||
|
||||
|
||||
@router.get("/nlp")
|
||||
@risk_level("L1")
|
||||
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",
|
||||
"okr": "okr", "目标": "okr", "季度目标": "okr",
|
||||
}
|
||||
resolved = m.get(intent, intent)
|
||||
return bot_query(q=resolved, bot=bot, db=db)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 聚合接口(Agent化生产链路 · 行动1, 2026-08-25)
|
||||
# 建议A: KPI值更新联动预警检查 | 建议B: KPI创建联动关联
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/kpi-value-with-check")
|
||||
@risk_level("L2")
|
||||
def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
|
||||
"""聚合A: 写KPI值 + 自动跑该KPI预警检查(Agent一次调用,免自拼check-all)
|
||||
body: {kpi_id, actual_value, period?, entity_id?, run_check?}"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
actual_value = data.get("actual_value")
|
||||
period = data.get("period")
|
||||
entity_id = int(data.get("entity_id") or 1)
|
||||
run_check = bool(data.get("run_check", True))
|
||||
if not kpi_id or actual_value is None:
|
||||
raise HTTPException(400, "kpi_id 和 actual_value 必填")
|
||||
# 校验 KPI 归属(多租户)
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
period = period or datetime.now().strftime("%Y-%m")
|
||||
|
||||
# ① 写值(upsert)
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
|
||||
if existing:
|
||||
existing.actual_value = float(actual_value)
|
||||
existing.source_type = "bot"
|
||||
kv = existing
|
||||
else:
|
||||
kv = KPIValue(kpi_id=kpi_id, entity_id=entity_id, period=period,
|
||||
actual_value=float(actual_value), source_type="bot", data_status="verified")
|
||||
db.add(kv)
|
||||
db.commit()
|
||||
db.refresh(kv)
|
||||
|
||||
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
|
||||
alerts = []
|
||||
if run_check:
|
||||
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
|
||||
rules = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
|
||||
AlertRule.enabled == 1).all()
|
||||
for rule in rules:
|
||||
try:
|
||||
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
value = float(actual_value)
|
||||
if rule.rule_type == "static":
|
||||
level, msg = _check_static(value, params, kpi)
|
||||
elif rule.rule_type == "dynamic":
|
||||
level, msg = _check_dynamic(kpi_id, value, params, db)
|
||||
elif rule.rule_type == "trend_up":
|
||||
level, msg = _check_trend(kpi_id, value, "up", params, db)
|
||||
elif rule.rule_type == "trend_down":
|
||||
level, msg = _check_trend(kpi_id, value, "down", params, db)
|
||||
else:
|
||||
continue
|
||||
if level and level != "green":
|
||||
dup = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
|
||||
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
|
||||
if not dup:
|
||||
db.add(KPIAlert(kpi_id=kpi_id, kpi_value_id=kv.id, alert_level=level,
|
||||
alert_message=msg, status="pending", alert_type="bot"))
|
||||
alerts.append({"rule_id": rule.id, "rule_type": rule.rule_type,
|
||||
"level": level, "message": msg})
|
||||
except Exception as e:
|
||||
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
|
||||
db.commit()
|
||||
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
|
||||
"period": period, "alerts": alerts, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/kpis/create-with-links")
|
||||
@risk_level("L3")
|
||||
def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
|
||||
"""聚合B: 创建KPI + 关联战略地图 + 批量因果链(Agent建KPI标准动作)
|
||||
body: {kpi_code, kpi_name, dimension, entity_id?, target_value?, unit?, link_map_id?, link_causality?}"""
|
||||
entity_id = int(data.get("entity_id") or 1)
|
||||
from app.api.kpis import _validate_kpi_data, apply_calc_type_inference
|
||||
kpi_data = {k: v for k, v in data.items() if k not in ("entity_id", "link_map_id", "link_causality")}
|
||||
|
||||
# ① 创建KPI(编码唯一 + 治理校验 + 强制企业)
|
||||
code = kpi_data.get("kpi_code", "")
|
||||
if not code:
|
||||
raise HTTPException(400, "kpi_code 必填")
|
||||
if db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first():
|
||||
raise HTTPException(400, f"KPI编码 {code} 已存在")
|
||||
errs = _validate_kpi_data(kpi_data, db=db, is_update=False)
|
||||
if errs:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
kpi_data["entity_id"] = entity_id
|
||||
kpi_data = apply_calc_type_inference(kpi_data)
|
||||
kpi = KPIDefinition(**kpi_data)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
|
||||
# ② 关联战略地图
|
||||
link_map_id = data.get("link_map_id")
|
||||
if link_map_id:
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == link_map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if m:
|
||||
kpi.map_id = link_map_id
|
||||
db.commit()
|
||||
|
||||
# ③ 批量因果链(源=新KPI → 目标列表)
|
||||
links = []
|
||||
for c in data.get("link_causality") or []:
|
||||
tgt = c.get("target_kpi_id")
|
||||
if not tgt or int(tgt) == kpi.id:
|
||||
continue
|
||||
tgt_kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == int(tgt), KPIDefinition.entity_id == entity_id).first()
|
||||
if not tgt_kpi:
|
||||
continue
|
||||
if db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == kpi.id,
|
||||
KPICausality.target_kpi_id == int(tgt)).first():
|
||||
continue
|
||||
db.add(KPICausality(source_kpi_id=kpi.id, target_kpi_id=int(tgt),
|
||||
strength=c.get("strength", 0.5), lag_months=c.get("lag_months", 1),
|
||||
direction=c.get("direction", "positive")))
|
||||
links.append({"source": kpi.kpi_code, "target": tgt_kpi.kpi_code,
|
||||
"strength": c.get("strength", 0.5)})
|
||||
db.commit()
|
||||
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "map_id": link_map_id,
|
||||
"causality_links": links, "status": "ok"}
|
||||
|
||||
|
||||
@router.get("/risk-levels")
|
||||
@risk_level("L1")
|
||||
def bot_risk_levels(bot: dict = Depends(verify_bot_key)):
|
||||
"""Bot API风险分级清单(API→级别→处理方式)— 验收/巡检/授权决策用"""
|
||||
from app.risk_levels import RISK_LEVELS, list_api_risk_map, risk_summary
|
||||
return {
|
||||
"bot": bot["name"],
|
||||
"risk_levels": RISK_LEVELS,
|
||||
"apis": list_api_risk_map(),
|
||||
"summary": risk_summary(),
|
||||
"note": "L4(危险)不向Bot API开放:Bot只能通过白名单API读写,"
|
||||
"DROP/TRUNCATE/批量DELETE/生产结构修改物理不可能,由终端层+approval-gate拦截",
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
"""
|
||||
Bot-Bridge V2 — 合并改造:Bot桥接 + 自动验证引擎
|
||||
|
||||
数据流:
|
||||
bot-bridge(财务Bot→CMA)→ 写入KPI
|
||||
verify(CMA→校验)→ 读取KPI → 通过/失败
|
||||
|
||||
API:
|
||||
POST /api/cma/bot-bridge/mpm-result — 接收MPM分析结果
|
||||
POST /api/cma/bot-bridge/verify/{action_plan_id} — 验证ActionPlan执行结果
|
||||
"""
|
||||
import json, logging, re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
MpmResult, BotBridgeConfig,
|
||||
KPIDefinition, KPIValue, KPIAlert,
|
||||
ActionPlan, Entity,
|
||||
)
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge_v2")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot-bridge", tags=["Bot桥接V2"])
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 鉴权
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def verify_bridge_token(
|
||||
x_bridge_token: str = Header(None, alias="X-BRIDGE-TOKEN"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""从bot_bridge_config表校验Token"""
|
||||
if not x_bridge_token:
|
||||
raise HTTPException(401, "缺少X-BRIDGE-TOKEN请求头")
|
||||
config = db.query(BotBridgeConfig).filter(
|
||||
BotBridgeConfig.token == x_bridge_token,
|
||||
BotBridgeConfig.is_active == True,
|
||||
).first()
|
||||
if not config:
|
||||
raise HTTPException(401, "Token无效或已停用")
|
||||
return config.bot_name
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 验证引擎
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _get_kpi_current_value(db: Session, kpi_id: int, entity_id: int = None) -> Optional[dict]:
|
||||
"""获取KPI当前最新值及目标值"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
return None
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.data_status == "verified",
|
||||
).order_by(KPIValue.calculated_at.desc(), KPIValue.period.desc()).first()
|
||||
return {
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"value": latest.actual_value if latest else None,
|
||||
"target": kpi.target_value,
|
||||
"period": latest.period if latest else None,
|
||||
"unit": kpi.unit,
|
||||
}
|
||||
|
||||
|
||||
def _parse_condition(condition: str) -> list:
|
||||
"""
|
||||
解析condition表达式为可执行结构
|
||||
|
||||
支持格式:
|
||||
"value > target" — 当前值 > 目标值
|
||||
"value >= 80" — 当前值 >= 80 (绝对值)
|
||||
"value / target * 100 > 80" — 完成率 > 80%
|
||||
"value < baseline" — 当前值 < 基线值(目标值=基线)
|
||||
"value == 0" — 精确等于
|
||||
"value > target AND value < 100" — 多条件AND
|
||||
"value > 50 OR value == -1" — 多条件OR
|
||||
"""
|
||||
if not condition:
|
||||
return []
|
||||
|
||||
# 分割AND/OR
|
||||
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
|
||||
clauses = []
|
||||
for i in range(0, len(parts), 2):
|
||||
expr = parts[i].strip()
|
||||
# 检查是否有运算符连接
|
||||
match = re.match(
|
||||
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)(?:\s*([+\-*/])\s*(\d+\.?\d*))?',
|
||||
expr
|
||||
)
|
||||
if not match:
|
||||
# 尝试 value/target*100 > 80 这种复合表达式
|
||||
match = re.match(
|
||||
r'(value)\s*(/|\\*)\s*(target|baseline)\s*(\*?\s*\d+)?\s*([><=!]+)\s*(-?\d+\.?\d*)',
|
||||
expr
|
||||
)
|
||||
if not match:
|
||||
logger.warning(f"无法解析condition表达式: {expr}")
|
||||
continue
|
||||
|
||||
logical_op = parts[i + 1].upper() if i + 1 < len(parts) else "AND"
|
||||
clauses.append({"expr": expr, "logical_op": logical_op, "match": match.groups() if match else ()})
|
||||
|
||||
return clauses
|
||||
|
||||
|
||||
def _evaluate_condition(condition: str, kpi_data: dict) -> bool:
|
||||
"""
|
||||
评估condition表达式
|
||||
|
||||
Args:
|
||||
condition: 条件表达式 e.g. "value > target"
|
||||
kpi_data: {value, target, ...}
|
||||
|
||||
Returns:
|
||||
True=通过, False=不通过
|
||||
"""
|
||||
if not condition:
|
||||
return False
|
||||
|
||||
value = kpi_data.get("value")
|
||||
target = kpi_data.get("target")
|
||||
baseline = kpi_data.get("baseline", target)
|
||||
|
||||
if value is None:
|
||||
logger.warning(f"KPI {kpi_data.get('kpi_code')} 当前值为空,无法验证")
|
||||
return False
|
||||
|
||||
result = True
|
||||
current_logical = "AND"
|
||||
|
||||
# 按AND/OR分割
|
||||
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
|
||||
|
||||
for i in range(0, len(parts), 2):
|
||||
expr = parts[i].strip()
|
||||
if i + 1 < len(parts):
|
||||
current_logical = parts[i + 1].upper()
|
||||
|
||||
clause_passed = _eval_single_expr(expr, value, target, baseline)
|
||||
|
||||
if current_logical == "AND":
|
||||
result = result and clause_passed
|
||||
elif current_logical == "OR":
|
||||
result = result or clause_passed
|
||||
|
||||
# 短路优化
|
||||
if current_logical == "AND" and not result:
|
||||
break
|
||||
if current_logical == "OR" and result:
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _eval_single_expr(expr: str, value: float, target: Optional[float], baseline: Optional[float]) -> bool:
|
||||
"""评估单条条件表达式"""
|
||||
# 模式1: value <op> target/baseline/number (如 value > target, value >= 80)
|
||||
m = re.match(
|
||||
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)',
|
||||
expr
|
||||
)
|
||||
if m:
|
||||
left = m.group(1)
|
||||
op = m.group(2)
|
||||
right_raw = m.group(3)
|
||||
|
||||
left_val = _resolve_var(left, value, target, baseline)
|
||||
right_val = _resolve_var(right_raw, value, target, baseline)
|
||||
|
||||
if left_val is None or right_val is None:
|
||||
return False
|
||||
|
||||
return _apply_op(left_val, op, right_val)
|
||||
|
||||
# 模式2: value [*/] target/baseline [* number] <op> number (如 value/target*100 > 80)
|
||||
m = re.match(
|
||||
r'(value)\s*([/*])\s*(target|baseline)(?:\s*\*\s*(\d+))?\s*([><=!]+)\s*(-?\d+\.?\d*)',
|
||||
expr
|
||||
)
|
||||
if m:
|
||||
left = m.group(1)
|
||||
op1 = m.group(2) # / or *
|
||||
var2 = m.group(3) # target or baseline
|
||||
multiplier = float(m.group(4)) if m.group(4) else 100
|
||||
op2 = m.group(5) # > < >= <= == !=
|
||||
right_num = float(m.group(6))
|
||||
|
||||
left_val = _resolve_var(left, value, target, baseline)
|
||||
right_var = _resolve_var(var2, value, target, baseline)
|
||||
|
||||
if left_val is None or right_var is None or right_var == 0:
|
||||
return False
|
||||
|
||||
if op1 == '/':
|
||||
computed = (left_val / right_var) * multiplier
|
||||
else: # *
|
||||
computed = left_val * right_var
|
||||
|
||||
return _apply_op(computed, op2, right_num)
|
||||
|
||||
# 模式3: 纯数字比较 (提供兼容)
|
||||
logger.warning(f"无法解析表达式: {expr}")
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_var(token: str, value: float, target: Optional[float], baseline: Optional[float]) -> Optional[float]:
|
||||
"""将变量名解析为数值"""
|
||||
token = token.strip()
|
||||
if token == "value":
|
||||
return value
|
||||
elif token == "target":
|
||||
return target
|
||||
elif token == "baseline":
|
||||
return baseline
|
||||
else:
|
||||
try:
|
||||
return float(token)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _apply_op(left: float, op: str, right: float) -> bool:
|
||||
"""应用比较运算符"""
|
||||
try:
|
||||
if op == ">":
|
||||
return left > right
|
||||
elif op == ">=":
|
||||
return left >= right
|
||||
elif op == "<":
|
||||
return left < right
|
||||
elif op == "<=":
|
||||
return left <= right
|
||||
elif op in ("==", "="):
|
||||
return abs(left - right) < 0.0001
|
||||
elif op == "!=":
|
||||
return abs(left - right) >= 0.0001
|
||||
else:
|
||||
logger.warning(f"未知运算符: {op}")
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# KPI映射表:MPM结果字段 → KPI编码
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
MPM_TO_KPI_MAP = {
|
||||
"revenue": None, # 不做KPI映射,保留在raw_data
|
||||
"cost": None,
|
||||
"gross_margin_standard": None,
|
||||
"gross_margin_adjusted": "F_GROSS_MARGIN",
|
||||
"net_profit_standard": None,
|
||||
"net_profit_adjusted": "F_NET_PROFIT",
|
||||
"channel_rebate_rate": "C_REBATE_RATE",
|
||||
"mgmt_expense_ratio": "F_COST_RATIO",
|
||||
}
|
||||
|
||||
# 预警阈值配置
|
||||
ALERT_THRESHOLDS = {
|
||||
"channel_rebate_rate": {"threshold": 75, "operator": ">", "kpi_code": "C_REBATE_RATE", "kpi_name": "渠补率"},
|
||||
"mgmt_expense_ratio": {"threshold": 50, "operator": ">", "kpi_code": "F_COST_RATIO", "kpi_name": "管理费/净收入"},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# API端点
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.post("/mpm-result")
|
||||
@risk_level("L3")
|
||||
def receive_mpm_result(
|
||||
data: dict,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
接收财务Bot/其他Bot的MPM分析结果
|
||||
|
||||
- 鉴权(校验X-BRIDGE-TOKEN)
|
||||
- 写入mpm_results表
|
||||
- 更新KPI当前值(kpi_values表)
|
||||
- 触发预警(如渠补率超75%)
|
||||
"""
|
||||
source = data.get("source", bridge_bot)
|
||||
entity_id = data.get("entity_id")
|
||||
period = data.get("period")
|
||||
results = data.get("results", {})
|
||||
|
||||
if not entity_id or not period:
|
||||
raise HTTPException(400, "缺少必填字段: entity_id, period")
|
||||
|
||||
# 验证entity存在
|
||||
entity = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not entity:
|
||||
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
|
||||
|
||||
# ── 1. 写入MPM结果 ──
|
||||
record = MpmResult(
|
||||
entity_id=entity_id,
|
||||
period=period,
|
||||
source=source,
|
||||
raw_data=results,
|
||||
)
|
||||
db.add(record)
|
||||
db.flush()
|
||||
|
||||
# ── 2. 更新KPI当前值 ──
|
||||
kpi_updates = []
|
||||
for field, kpi_code in MPM_TO_KPI_MAP.items():
|
||||
if kpi_code is None:
|
||||
continue
|
||||
field_value = results.get(field)
|
||||
if field_value is None:
|
||||
continue
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
logger.warning(f"KPI编码 {kpi_code} 未找到 (entity_id={entity_id})")
|
||||
continue
|
||||
|
||||
# 写入最新值 (upsert: 存在同period则更新,否则插入)
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
if existing:
|
||||
existing.actual_value = field_value
|
||||
existing.source_type = "bot-bridge-v2"
|
||||
existing.data_status = "verified"
|
||||
else:
|
||||
kpi_val = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=field_value,
|
||||
source_type="bot-bridge-v2",
|
||||
data_status="verified",
|
||||
)
|
||||
db.add(kpi_val)
|
||||
kpi_updates.append(kpi_code)
|
||||
|
||||
# ── 3. 触发预警 ──
|
||||
alerts = []
|
||||
for field, config in ALERT_THRESHOLDS.items():
|
||||
field_value = results.get(field)
|
||||
if field_value is None:
|
||||
continue
|
||||
threshold = config["threshold"]
|
||||
operator = config["operator"]
|
||||
kpi_code = config.get("kpi_code")
|
||||
kpi_name = config["kpi_name"]
|
||||
|
||||
if (operator == ">" and field_value > threshold) or \
|
||||
(operator == ">=" and field_value >= threshold):
|
||||
# 查找关联KPI
|
||||
kpi_id = None
|
||||
if kpi_code:
|
||||
kpi_def = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if kpi_def:
|
||||
kpi_id = kpi_def.id
|
||||
|
||||
# 创建预警
|
||||
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi_id or 1, # fallback to first KPI if not found
|
||||
alert_level="red",
|
||||
alert_message=alert_msg,
|
||||
alert_type="actual",
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
alerts.append(alert_msg)
|
||||
logger.warning(alert_msg)
|
||||
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"kpi_updated": len(kpi_updates),
|
||||
"alerts_triggered": len(alerts),
|
||||
"mpm_record_id": record.id,
|
||||
"details": {
|
||||
"kpi_codes": kpi_updates,
|
||||
"alerts": alerts,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/kpi-result")
|
||||
@risk_level("L2")
|
||||
def push_kpi_result(
|
||||
data: dict,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Bot分析结果回填KPI值 + 触发预警(PRD第七部分 bot-bridge数据通道)
|
||||
|
||||
入参:
|
||||
entity_id (int, 默认1): 企业实体ID
|
||||
kpi_code (str, 必填): KPI编码
|
||||
period (str, 必填): 期间 YYYY-MM
|
||||
value (num, 必填): 实际值
|
||||
source (str, 默认finance-bot): 来源Bot标识
|
||||
remark (str, 可选): 备注
|
||||
|
||||
行为:
|
||||
1. 按(entity_id, kpi_code)查KPI → 不存在返回错误
|
||||
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
|
||||
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
|
||||
"""
|
||||
entity_id = data.get("entity_id", 1)
|
||||
kpi_code = data.get("kpi_code")
|
||||
period = data.get("period")
|
||||
value = data.get("value")
|
||||
source = data.get("source", "finance-bot")
|
||||
remark = data.get("remark")
|
||||
|
||||
if not kpi_code:
|
||||
raise HTTPException(400, "缺少必填字段: kpi_code")
|
||||
if not period:
|
||||
raise HTTPException(400, "缺少必填字段: period")
|
||||
if value is None or value == "":
|
||||
raise HTTPException(400, "缺少必填字段: value")
|
||||
try:
|
||||
actual_value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, f"value不是有效数值: {value!r}")
|
||||
|
||||
# 1. 按(entity_id, kpi_code)查KPI
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_code} 不存在 (entity_id={entity_id})")
|
||||
|
||||
# 2. 写入KPIValue(同period已存在则更新,幂等upsert)
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
if existing:
|
||||
existing.actual_value = actual_value
|
||||
existing.source_type = "bot"
|
||||
existing.source_batch = source
|
||||
existing.data_status = "verified"
|
||||
existing.calculated_at = datetime.now()
|
||||
if remark:
|
||||
existing.remark = remark
|
||||
val = existing
|
||||
else:
|
||||
val = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=actual_value,
|
||||
source_type="bot",
|
||||
source_batch=source,
|
||||
data_status="verified",
|
||||
calculated_at=datetime.now(),
|
||||
remark=remark,
|
||||
)
|
||||
db.add(val)
|
||||
db.commit()
|
||||
db.refresh(val)
|
||||
|
||||
# 3. 触发预警检查(复用alert_generator引擎,yellow/red自动联动行动计划)
|
||||
from scripts.alert_generator import run_alert_check
|
||||
new_count = run_alert_check(db, period)
|
||||
|
||||
# 收集本次写入值直接触发的预警(kpi_value_id关联)
|
||||
new_alerts = []
|
||||
if new_count:
|
||||
triggered = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_value_id == val.id,
|
||||
).order_by(KPIAlert.created_at.desc()).all()
|
||||
new_alerts = [
|
||||
{
|
||||
"id": a.id,
|
||||
"kpi_id": a.kpi_id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"level": a.alert_level,
|
||||
"message": a.alert_message,
|
||||
"action_plan_id": a.action_plan_linked_id,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
} for a in triggered
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"[bot-bridge] KPI回填: {kpi.kpi_code}@{period}={actual_value} "
|
||||
f"source={source} entity={entity_id} alerts={new_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"kpi_value_id": val.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"period": period,
|
||||
"value": actual_value,
|
||||
"source": source,
|
||||
"new_alerts_count": len(new_alerts),
|
||||
"new_alerts": new_alerts,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/verify/{action_plan_id}")
|
||||
@risk_level("L2")
|
||||
def verify_action_plan(
|
||||
action_plan_id: int,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
验证ActionPlan的执行结果
|
||||
|
||||
1. 读取ActionPlan的auto_verify_rule
|
||||
2. 读取关联KPI的当前值
|
||||
3. 按condition校验
|
||||
4. 返回通过/失败 + 详细数据
|
||||
"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
|
||||
|
||||
if not plan.auto_verify_rule:
|
||||
raise HTTPException(400, "该ActionPlan未配置auto_verify_rule验证规则")
|
||||
|
||||
# 解析验证规则
|
||||
if isinstance(plan.auto_verify_rule, str):
|
||||
rule = json.loads(plan.auto_verify_rule)
|
||||
else:
|
||||
rule = plan.auto_verify_rule
|
||||
|
||||
condition = rule.get("condition", "")
|
||||
description = rule.get("description", "")
|
||||
|
||||
# 获取KPI当前值
|
||||
kpi_data = _get_kpi_current_value(db, plan.kpi_id)
|
||||
if not kpi_data:
|
||||
return {
|
||||
"success": False,
|
||||
"action_plan_id": action_plan_id,
|
||||
"title": plan.title,
|
||||
"verify_result": "fail",
|
||||
"reason": "关联KPI不存在",
|
||||
}
|
||||
|
||||
# 执行验证
|
||||
passed = _evaluate_condition(condition, kpi_data)
|
||||
|
||||
# 记录验证日志
|
||||
verify_log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"condition": condition,
|
||||
"kpi_data": kpi_data,
|
||||
"passed": passed,
|
||||
}
|
||||
existing_logs = plan.verify_log or []
|
||||
if isinstance(existing_logs, list):
|
||||
existing_logs.append(verify_log_entry)
|
||||
else:
|
||||
existing_logs = [verify_log_entry]
|
||||
|
||||
plan.verify_result = "pass" if passed else "fail"
|
||||
plan.verify_log = existing_logs
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"action_plan_id": action_plan_id,
|
||||
"title": plan.title,
|
||||
"verify_result": plan.verify_result,
|
||||
"condition": condition,
|
||||
"condition_description": description,
|
||||
"kpi_data": kpi_data,
|
||||
"verification_detail": {
|
||||
"value": kpi_data.get("value"),
|
||||
"target": kpi_data.get("target"),
|
||||
"condition": condition,
|
||||
"passed": passed,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/verify/{action_plan_id}/history")
|
||||
@risk_level("L1")
|
||||
def verify_history(
|
||||
action_plan_id: int,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取ActionPlan的验证历史"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
|
||||
|
||||
return {
|
||||
"action_plan_id": action_plan_id,
|
||||
"title": plan.title,
|
||||
"verify_result": plan.verify_result,
|
||||
"verify_log": plan.verify_log or [],
|
||||
"auto_verify_rule": plan.auto_verify_rule,
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
铁律KPI看板API — CMA铁律执行效果数据采集
|
||||
提供: 验证次数、通过率、违规数、按Bot分组的通过率
|
||||
"""
|
||||
import os, json, sqlite3, logging
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.database import get_db
|
||||
from app.models import ActionPlan
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
logger = logging.getLogger("cma.iron_law")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot", tags=["铁律KPI看板"])
|
||||
|
||||
# ── BOT API Key 配置(复用bot_bridge的一致鉴权) ──
|
||||
_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]
|
||||
return bot_info
|
||||
|
||||
|
||||
# ── 各Hermes Profile state.db 路径 ──
|
||||
HERMES_HOME = "/root/.hermes/profiles"
|
||||
|
||||
def _list_profile_dbs():
|
||||
"""列出所有Hermes Profile的state.db路径"""
|
||||
dbs = []
|
||||
if not os.path.isdir(HERMES_HOME):
|
||||
return dbs
|
||||
for name in os.listdir(HERMES_HOME):
|
||||
db_path = os.path.join(HERMES_HOME, name, "state.db")
|
||||
if os.path.isfile(db_path):
|
||||
dbs.append((name, db_path))
|
||||
return sorted(dbs)
|
||||
|
||||
|
||||
def _query_state_db(db_path: str, since_days: int = 30):
|
||||
"""从单个 state.db 查询验证相关消息"""
|
||||
cutoff = datetime.now() - timedelta(days=since_days)
|
||||
cutoff_ts = cutoff.timestamp()
|
||||
|
||||
results = {
|
||||
"total_verify_msgs": 0,
|
||||
"pass_msgs": 0,
|
||||
"fail_msgs": 0,
|
||||
"violation_msgs": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
|
||||
# 查询含验证/铁律关键词的消息(排除tool_call、校验自身和系统prompt)
|
||||
c.execute("""
|
||||
SELECT content, role FROM messages
|
||||
WHERE timestamp >= ?
|
||||
AND (content LIKE '%verify%'
|
||||
OR content LIKE '%验证%'
|
||||
OR content LIKE '%铁律%'
|
||||
OR content LIKE '%validate%')
|
||||
AND content NOT LIKE '%verify_bot_key%'
|
||||
AND content NOT LIKE '%X-BOT-KEY%'
|
||||
AND content NOT LIKE '%system_prompt%'
|
||||
""", (cutoff_ts,))
|
||||
|
||||
rows = c.fetchall()
|
||||
results["total_verify_msgs"] = len(rows)
|
||||
|
||||
for row in rows:
|
||||
content = row["content"] or ""
|
||||
role = row["role"]
|
||||
|
||||
# 判断是否通过/成功/完成
|
||||
pass_patterns = ["通过", "passed", "success", "✅", "完成", "completed", "验证通过"]
|
||||
fail_patterns = ["失败", "failed", "error", "❌", "违规", "未通过", "错误", "异常"]
|
||||
violation_patterns = ["违规", "violation", "拦截", "blocked", "违例"]
|
||||
|
||||
# 助理角色消息:用于判断验证结果
|
||||
if role == "assistant":
|
||||
has_pass = any(p in content for p in pass_patterns)
|
||||
has_fail = any(p in content for p in fail_patterns)
|
||||
|
||||
if has_pass and not has_fail:
|
||||
results["pass_msgs"] += 1
|
||||
elif has_fail and not has_pass:
|
||||
results["fail_msgs"] += 1
|
||||
elif has_pass and has_fail:
|
||||
# 混合内容,默认算通过(因为通常有通过+补充说明)
|
||||
results["pass_msgs"] += 1
|
||||
|
||||
if any(p in content for p in violation_patterns):
|
||||
results["violation_msgs"] += 1
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"查询state.db失败 {db_path}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _query_action_plan_verify(db: Session):
|
||||
"""从CMA ActionPlan表查询验证数据"""
|
||||
total = db.query(func.count(ActionPlan.id)).scalar() or 0
|
||||
verified = db.query(func.count(ActionPlan.id)).filter(
|
||||
ActionPlan.verify_result.isnot(None)
|
||||
).scalar() or 0
|
||||
passed = db.query(func.count(ActionPlan.id)).filter(
|
||||
ActionPlan.verify_result == "pass"
|
||||
).scalar() or 0
|
||||
failed = db.query(func.count(ActionPlan.id)).filter(
|
||||
ActionPlan.verify_result == "fail"
|
||||
).scalar() or 0
|
||||
with_rule = db.query(func.count(ActionPlan.id)).filter(
|
||||
ActionPlan.auto_verify_rule.isnot(None)
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"total_plans": total,
|
||||
"verified": verified,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"with_auto_rule": with_rule,
|
||||
"pass_rate": round(passed / verified * 100, 1) if verified > 0 else 0,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════ 端点 ═══════════════
|
||||
|
||||
@router.get("/iron-law")
|
||||
@risk_level("L1")
|
||||
def get_iron_law_kpis(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
since_days: int = 30,
|
||||
):
|
||||
"""
|
||||
铁律执行效果KPI看板
|
||||
|
||||
返回各Bot的验证统计、通过率、违规数,以及ActionPlan的验证覆盖率。
|
||||
数据来源:
|
||||
- Hermes 各Profile state.db (消息含 verify/验证/铁律)
|
||||
- CMA action_plans 表 (verify_result, auto_verify_rule)
|
||||
"""
|
||||
# ── 1. 采集各Profile state.db ──
|
||||
profile_dbs = _list_profile_dbs()
|
||||
per_bot = {}
|
||||
total_verify = 0
|
||||
total_pass = 0
|
||||
total_fail = 0
|
||||
total_violations = 0
|
||||
|
||||
for profile_name, db_path in profile_dbs:
|
||||
stats = _query_state_db(db_path, since_days=since_days)
|
||||
per_bot[profile_name] = {
|
||||
"verification_count": stats["total_verify_msgs"],
|
||||
"pass_count": stats["pass_msgs"],
|
||||
"fail_count": stats["fail_msgs"],
|
||||
"violation_count": stats["violation_msgs"],
|
||||
"pass_rate": round(
|
||||
stats["pass_msgs"] / stats["total_verify_msgs"] * 100, 1
|
||||
) if stats["total_verify_msgs"] > 0 else 0,
|
||||
}
|
||||
total_verify += stats["total_verify_msgs"]
|
||||
total_pass += stats["pass_msgs"]
|
||||
total_fail += stats["fail_msgs"]
|
||||
total_violations += stats["violation_msgs"]
|
||||
|
||||
# ── 2. 采集ActionPlan验证数据 ──
|
||||
plan_stats = _query_action_plan_verify(db)
|
||||
|
||||
return {
|
||||
"bot": bot,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"period": f"past_{since_days}d",
|
||||
"summary": {
|
||||
"total_verifications": total_verify,
|
||||
"pass_rate": round(total_pass / total_verify * 100, 1) if total_verify > 0 else 0,
|
||||
"total_violations": total_violations,
|
||||
"plan_verify_count": plan_stats["verified"],
|
||||
"plan_pass_rate": plan_stats["pass_rate"],
|
||||
"plans_with_auto_rule": plan_stats["with_auto_rule"],
|
||||
"total_action_plans": plan_stats["total_plans"],
|
||||
},
|
||||
"per_bot": per_bot,
|
||||
"action_plan_verification": {
|
||||
"total_plans": plan_stats["total_plans"],
|
||||
"verified": plan_stats["verified"],
|
||||
"passed": plan_stats["passed"],
|
||||
"failed": plan_stats["failed"],
|
||||
"pass_rate": plan_stats["pass_rate"],
|
||||
"with_auto_rule": plan_stats["with_auto_rule"],
|
||||
"coverage": round(
|
||||
plan_stats["verified"] / plan_stats["total_plans"] * 100, 1
|
||||
) if plan_stats["total_plans"] > 0 else 0,
|
||||
},
|
||||
"data_sources": {
|
||||
"state_dbs": len(profile_dbs),
|
||||
"profiles_queried": [p[0] for p in profile_dbs],
|
||||
"action_plans_table": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/iron-law/bots")
|
||||
@risk_level("L1")
|
||||
def get_bot_iron_law_ranking(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
since_days: int = 30,
|
||||
):
|
||||
"""按Bot排名:验证通过率从高到低"""
|
||||
data = get_iron_law_kpis(bot=bot, db=db, since_days=since_days)
|
||||
|
||||
ranking = sorted(
|
||||
data["per_bot"].items(),
|
||||
key=lambda x: x[1]["pass_rate"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
ranked = []
|
||||
for rank, (name, stats) in enumerate(ranking, 1):
|
||||
ranked.append({
|
||||
"rank": rank,
|
||||
"bot_name": name,
|
||||
"pass_rate": stats["pass_rate"],
|
||||
"verification_count": stats["verification_count"],
|
||||
"violations": stats["violation_count"],
|
||||
"failures": stats["fail_count"],
|
||||
})
|
||||
|
||||
return {
|
||||
"bot": bot,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"period": f"past_{since_days}d",
|
||||
"ranking": ranked,
|
||||
"total_bots": len(ranked),
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Bot KPI管理 API — 管理各Agent的KPI自评体系"""
|
||||
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_auth
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
# 反向指标编码(值越低越好)
|
||||
REVERSE_BOT_INDICATORS = ['FB_RESPONSE_TIME']
|
||||
|
||||
|
||||
def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
|
||||
"""五档评分引擎(复用KPI体系)- 1~5分"""
|
||||
if current_value is None or target_value is None or target_value == 0:
|
||||
return None, "info"
|
||||
ratio = current_value / target_value
|
||||
if is_reverse:
|
||||
if ratio <= 0.5:
|
||||
return 5, "success"
|
||||
elif ratio <= 0.8:
|
||||
return 4, "success"
|
||||
elif ratio <= 1.0:
|
||||
return 3, "warning"
|
||||
elif ratio <= 1.2:
|
||||
return 2, "danger"
|
||||
else:
|
||||
return 1, "danger"
|
||||
else:
|
||||
if ratio >= 1.2:
|
||||
return 5, "success"
|
||||
elif ratio >= 1.0:
|
||||
return 4, "success"
|
||||
elif ratio >= 0.8:
|
||||
return 3, "warning"
|
||||
elif ratio >= 0.5:
|
||||
return 2, "danger"
|
||||
else:
|
||||
return 1, "danger"
|
||||
|
||||
|
||||
@router.get("")
|
||||
@risk_level("L1")
|
||||
def list_bot_kpis(
|
||||
source: str = Query("finance-bot", description="Bot标识"),
|
||||
period: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取某Bot的所有KPI(含评分)"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.bot_source == source,
|
||||
KPIDefinition.status == "active",
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
if not kpis:
|
||||
return {
|
||||
"source": source,
|
||||
"kpis": [],
|
||||
"groups": {},
|
||||
"overall": None,
|
||||
}
|
||||
|
||||
from app.api.kpis import REVERSE_INDICATORS as _ri
|
||||
|
||||
result_kpis = []
|
||||
for k in kpis:
|
||||
# 取最新实际值
|
||||
val_query = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period:
|
||||
val_query = val_query.filter(KPIValue.period == period)
|
||||
latest_val = val_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
current_val = latest_val.actual_value if latest_val else None
|
||||
is_reverse = k.kpi_code in REVERSE_BOT_INDICATORS
|
||||
score, status = _calc_bot_kpi_score(current_val, k.target_value, is_reverse=is_reverse)
|
||||
|
||||
result_kpis.append({
|
||||
"id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"category": k.category or "core_output",
|
||||
"formula": k.formula,
|
||||
"target_value": k.target_value,
|
||||
"current_value": current_val,
|
||||
"unit": k.unit,
|
||||
"weight": 15, # 权重在PRD中定义,但未存表,前端使用
|
||||
"score": score,
|
||||
"status": status,
|
||||
"period": latest_val.period if latest_val else None,
|
||||
"frequency": k.frequency or "monthly",
|
||||
})
|
||||
|
||||
# 按group分组: core_output / quality / user_feedback
|
||||
groups = {
|
||||
"core_output": {"label": "核心产出", "kpis": []},
|
||||
"quality": {"label": "质量监控", "kpis": []},
|
||||
"user_feedback": {"label": "用户反馈", "kpis": []},
|
||||
}
|
||||
# category映射: FB编码前缀区分
|
||||
for kp in result_kpis:
|
||||
code = kp["kpi_code"]
|
||||
if code.startswith("FB_ANALYSIS") or code.startswith("FB_ACCURACY") or code.startswith("FB_ISSUE") or code.startswith("FB_ACTION") or code.startswith("FB_RESPONSE"):
|
||||
groups["core_output"]["kpis"].append(kp)
|
||||
elif code.startswith("FB_DATA") or code.startswith("FB_CONSISTENCY") or code.startswith("FB_CITATION"):
|
||||
groups["quality"]["kpis"].append(kp)
|
||||
else:
|
||||
groups["user_feedback"]["kpis"].append(kp)
|
||||
|
||||
# 综合得分
|
||||
scored_kpis = [k for k in result_kpis if k["score"] is not None]
|
||||
if scored_kpis:
|
||||
overall = round(sum(k["score"] * k["weight"] for k in scored_kpis) / sum(k["weight"] for k in scored_kpis), 2)
|
||||
overall_status = "success" if overall >= 4 else ("warning" if overall >= 3 else "danger")
|
||||
else:
|
||||
overall = None
|
||||
overall_status = "info"
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"kpis": result_kpis,
|
||||
"groups": groups,
|
||||
"overall": {"score": overall, "status": overall_status},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{kpi_id}/value")
|
||||
@risk_level("L2")
|
||||
def update_bot_kpi_value(
|
||||
kpi_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""更新Bot KPI当前值"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.bot_source.isnot(None),
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "Bot KPI不存在")
|
||||
|
||||
actual_value = data.get("actual_value")
|
||||
if actual_value is None:
|
||||
raise HTTPException(422, "actual_value 不能为空")
|
||||
|
||||
period = data.get("period", datetime.now().strftime("%Y-%m"))
|
||||
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
if existing:
|
||||
existing.actual_value = actual_value
|
||||
existing.source_type = "manual"
|
||||
else:
|
||||
val = KPIValue(
|
||||
kpi_id=kpi_id,
|
||||
entity_id=kpi.entity_id if kpi else None, # 账套隔离 P2
|
||||
period=period,
|
||||
actual_value=actual_value,
|
||||
source_type="manual",
|
||||
data_status="pending",
|
||||
)
|
||||
db.add(val)
|
||||
|
||||
db.commit()
|
||||
return {"message": "更新成功", "kpi_id": kpi_id, "period": period, "actual_value": actual_value}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""BSC四层配置 API"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth
|
||||
from app.models import BscLayerConfig, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bsc-layers", tags=["BSC层配置"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_bsc_layers(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
|
||||
"""获取某企业的BSC四层权重配置"""
|
||||
# 验证企业存在
|
||||
entity = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not entity:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(status_code=404, content={"detail": "企业不存在"})
|
||||
|
||||
layers = db.query(BscLayerConfig).filter(
|
||||
BscLayerConfig.entity_id == entity_id
|
||||
).order_by(BscLayerConfig.id).all()
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": entity.short_name or entity.name,
|
||||
"layers": [
|
||||
{
|
||||
"id": l.id,
|
||||
"layer": l.layer,
|
||||
"weight": float(l.weight),
|
||||
"kpi_count_min": l.kpi_count_min,
|
||||
"kpi_count_max": l.kpi_count_max,
|
||||
}
|
||||
for l in layers
|
||||
]
|
||||
}
|
||||
+1322
-7
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
||||
"""现金流分类规则 API — 管理会计OS (P2-⑥ 2026-08-28)
|
||||
|
||||
分类规则管理(cash_plan_classify_rules) + 待分类队列(cash_plan_unclassified) + 一键归类。
|
||||
sync-cash-plans 未命中的KPI进入待分类队列,人工一键归类 → 自动补建规则+生成CashPlan。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import (
|
||||
CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
|
||||
KPIDefinition, BudgetPlan,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["现金流分类"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ── 分类规则 CRUD ──────────────────────────────
|
||||
|
||||
@router.get("/cash-classify-rules")
|
||||
def list_classify_rules(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""分类规则列表(按 entity_id 隔离)"""
|
||||
rows = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.entity_id == entity_id
|
||||
).order_by(CashPlanClassifyRule.priority.asc(), CashPlanClassifyRule.id.desc()).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows if r.kpi_id}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id) if r.kpi_id else None
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"kpi_code_pattern": r.kpi_code_pattern,
|
||||
"plan_type": r.plan_type,
|
||||
"priority": r.priority,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/cash-classify-rules")
|
||||
def create_classify_rule(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建分类规则(kpi_id 精确 或 kpi_code_pattern 关键词 二选一)"""
|
||||
plan_type = data.get("plan_type")
|
||||
if plan_type not in ("receive", "pay"):
|
||||
raise HTTPException(400, "plan_type 必须是 receive/pay")
|
||||
kpi_id = data.get("kpi_id")
|
||||
pattern = data.get("kpi_code_pattern")
|
||||
if not kpi_id and not pattern:
|
||||
raise HTTPException(400, "需要 kpi_id 或 kpi_code_pattern 至少一个")
|
||||
|
||||
row = CashPlanClassifyRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
kpi_code_pattern=pattern,
|
||||
plan_type=plan_type,
|
||||
priority=data.get("priority", 10),
|
||||
status=data.get("status", "active"),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "分类规则已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/cash-classify-rules/{rule_id}")
|
||||
def update_classify_rule(
|
||||
rule_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新分类规则"""
|
||||
row = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.id == rule_id,
|
||||
CashPlanClassifyRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
for field in ("kpi_id", "kpi_code_pattern", "plan_type", "priority", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "分类规则已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/cash-classify-rules/{rule_id}")
|
||||
def delete_classify_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除分类规则"""
|
||||
row = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.id == rule_id,
|
||||
CashPlanClassifyRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "分类规则已删除"}
|
||||
|
||||
|
||||
# ── 待分类队列 ──────────────────────────────
|
||||
|
||||
@router.get("/cash-unclassified")
|
||||
def list_unclassified(
|
||||
status: Optional[str] = Query(None, description="pending/classified/ignored"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""待分类KPI队列"""
|
||||
query = db.query(CashPlanUnclassified).filter(CashPlanUnclassified.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(CashPlanUnclassified.status == status)
|
||||
rows = query.order_by(CashPlanUnclassified.created_at.desc()).all()
|
||||
result = []
|
||||
for r in rows:
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_name": r.kpi_name,
|
||||
"period": r.period,
|
||||
"budget_value": r.budget_value,
|
||||
"reason": r.reason,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"resolved_at": r.resolved_at.isoformat() if r.resolved_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/cash-unclassified/{item_id}/classify")
|
||||
def classify_unclassified(
|
||||
item_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""一键归类: body {plan_type: receive/pay}
|
||||
① 自动补建分类规则 ② 标记队列 classified ③ 联动生成对应 CashPlan
|
||||
"""
|
||||
plan_type = data.get("plan_type")
|
||||
if plan_type not in ("receive", "pay"):
|
||||
raise HTTPException(400, "plan_type 必须是 receive/pay")
|
||||
|
||||
item = db.query(CashPlanUnclassified).filter(
|
||||
CashPlanUnclassified.id == item_id,
|
||||
CashPlanUnclassified.entity_id == entity_id,
|
||||
CashPlanUnclassified.status == "pending",
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "待分类记录不存在或已处理")
|
||||
|
||||
# ① 自动补建规则(无精确KPI规则时)
|
||||
existing_rule = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.entity_id == entity_id,
|
||||
CashPlanClassifyRule.kpi_id == item.kpi_id,
|
||||
).first()
|
||||
if not existing_rule:
|
||||
db.add(CashPlanClassifyRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=item.kpi_id,
|
||||
kpi_code_pattern=None,
|
||||
plan_type=plan_type,
|
||||
priority=10,
|
||||
status="active",
|
||||
))
|
||||
|
||||
# ② 标记队列
|
||||
item.status = "classified"
|
||||
item.resolved_at = datetime.now()
|
||||
|
||||
# ③ 联动生成 CashPlan(有期间和预算值时)
|
||||
plan_created = False
|
||||
if item.period and item.budget_value is not None:
|
||||
try:
|
||||
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
|
||||
plan_date = datetime(year, month, 1)
|
||||
except Exception:
|
||||
plan_date = None
|
||||
if plan_date:
|
||||
existing_plan = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.related_kpi_id == item.kpi_id,
|
||||
CashPlan.plan_type == plan_type,
|
||||
CashPlan.plan_date == plan_date,
|
||||
).first()
|
||||
if not existing_plan:
|
||||
db.add(CashPlan(
|
||||
entity_id=entity_id,
|
||||
plan_type=plan_type,
|
||||
related_kpi_id=item.kpi_id,
|
||||
amount=item.budget_value,
|
||||
plan_date=plan_date,
|
||||
description=f"待分类队列归类: {item.kpi_name or ''}",
|
||||
status="pending",
|
||||
source="budget_sync",
|
||||
))
|
||||
plan_created = True
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"message": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""),
|
||||
"plan_type": plan_type,
|
||||
"rule_created": not existing_rule,
|
||||
"plan_created": plan_created,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cash-unclassified/{item_id}/ignore")
|
||||
def ignore_unclassified(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""忽略该KPI(不生成规则)"""
|
||||
item = db.query(CashPlanUnclassified).filter(
|
||||
CashPlanUnclassified.id == item_id,
|
||||
CashPlanUnclassified.entity_id == entity_id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "待分类记录不存在")
|
||||
item.status = "ignored"
|
||||
item.resolved_at = datetime.now()
|
||||
db.commit()
|
||||
return {"message": "已忽略"}
|
||||
+69
-4
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.utils.cost_engine import (
|
||||
calc_product_variance, get_cost_overview, get_cost_breakdown,
|
||||
@@ -22,9 +23,10 @@ router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
|
||||
@router.get("/standard-costs")
|
||||
def list_standard_costs(product_code: Optional[str] = Query(None),
|
||||
cost_type: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查询标准成本卡片"""
|
||||
query = db.query(StandardCost).filter(StandardCost.status == "active")
|
||||
query = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id)
|
||||
if product_code:
|
||||
query = query.filter(StandardCost.product_code == product_code)
|
||||
if cost_type:
|
||||
@@ -86,9 +88,10 @@ def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)):
|
||||
@router.get("/actual-costs")
|
||||
def list_actual_costs(period: Optional[str] = Query(None),
|
||||
product_code: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查询实际成本"""
|
||||
query = db.query(ActualCost)
|
||||
query = db.query(ActualCost).filter(ActualCost.entity_id == entity_id)
|
||||
if period:
|
||||
query = query.filter(ActualCost.period == period)
|
||||
if product_code:
|
||||
@@ -202,6 +205,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],
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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.deps import get_entity_id
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, User
|
||||
from app.utils.cache import get as cache_get, set as cache_set
|
||||
import json
|
||||
@@ -51,16 +52,37 @@ def period_prefix(period_type: str):
|
||||
return str(datetime.now().year)
|
||||
return None
|
||||
|
||||
def kpi_target_by_frequency(k):
|
||||
"""按考核频率返回对应周期的目标值(多粒度改造)
|
||||
monthly/weekly -> target_monthly; quarterly/half_year -> target_quarterly; yearly -> target_yearly
|
||||
兼容: 对应列无值时回退 target_value
|
||||
"""
|
||||
freq = (k.frequency or "monthly").lower()
|
||||
if freq in ("monthly", "weekly"):
|
||||
val = getattr(k, "target_monthly", None)
|
||||
elif freq in ("quarterly", "half_year"):
|
||||
val = getattr(k, "target_quarterly", None)
|
||||
elif freq == "yearly":
|
||||
val = getattr(k, "target_yearly", None)
|
||||
else:
|
||||
val = None
|
||||
if val is None:
|
||||
val = k.target_value
|
||||
return val
|
||||
|
||||
@router.get("/summary")
|
||||
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db)):
|
||||
cache_key = f"summary:{role}:{period}"
|
||||
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"),
|
||||
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
cache_key = f"summary:{role}:{period}:{entity_id}"
|
||||
cached = cache_get("dashboard", cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
kpi_total = db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar()
|
||||
kpi_total = db.query(func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).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()
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||
).group_by(KPIDefinition.dimension).all()
|
||||
|
||||
# 读取最近一次同步状态(从日志文件最后一行)
|
||||
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
|
||||
@@ -94,11 +116,14 @@ def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month")
|
||||
@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)):
|
||||
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
start, end = parse_period(period, start_date, end_date)
|
||||
period_str = start.strftime("%Y-%m")
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id
|
||||
).all()
|
||||
result = []
|
||||
|
||||
for k in kpis:
|
||||
@@ -136,6 +161,7 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
||||
result.append({
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
|
||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else None,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
@@ -191,6 +217,7 @@ def get_my_kpis(
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else period_str,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
@@ -213,6 +240,7 @@ def get_finance_analysis(
|
||||
current_user: User = Depends(require_auth),
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""财务工作台分析数据"""
|
||||
period_str = datetime.now().strftime("%Y-%m")
|
||||
@@ -220,6 +248,7 @@ def get_finance_analysis(
|
||||
finance_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "finance",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
kpi_data = []
|
||||
@@ -242,6 +271,7 @@ def get_finance_analysis(
|
||||
kpi_data.append({
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"unit": k.unit, "target_value": k.target_value,
|
||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
@@ -269,7 +299,7 @@ def get_finance_analysis(
|
||||
|
||||
|
||||
@router.get("/predict")
|
||||
def predict_kpis(db: Session = Depends(get_db)):
|
||||
def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""基于历史趋势预测下月KPI值(简单线性回归)"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -281,7 +311,10 @@ def predict_kpis(db: Session = Depends(get_db)):
|
||||
next_year += 1
|
||||
next_period = f"{next_year}-{next_month:02d}"
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id
|
||||
).all()
|
||||
predictions = []
|
||||
|
||||
for k in kpis:
|
||||
@@ -357,39 +390,44 @@ def predict_kpis(db: Session = Depends(get_db)):
|
||||
def my_dashboard(
|
||||
current_user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
|
||||
"""个人工作台:返回我的KPI、改善行动、待办提醒(账套隔离: 按token企业过滤)"""
|
||||
username = current_user.username
|
||||
name = current_user.name
|
||||
role = current_user.role
|
||||
|
||||
# 角色预设KPI编码
|
||||
ROLE_PRESET_KPIS = {
|
||||
"ceo": ["F_REVENUE_001", "F_PROFIT_001", "F_COST_001", "C_CUST_001", "P_INV_001"],
|
||||
"finance": ["F_REVENUE_001", "F_PROFIT_001", "F_COST_001", "F_CASH_001"],
|
||||
"business": ["C_CUST_001", "C_CUST_003", "F_REVENUE_001"],
|
||||
"it": [], # IT没有固定预设
|
||||
"ceo": ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "C_REBATE_RATE", "P_DELIVERY", "F_FCF"],
|
||||
"finance": ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_OP_CFLOW", "F_ROE"],
|
||||
"business": ["C_REBATE_RATE", "C_NEW_CLIENTS", "F_REVENUE"],
|
||||
"it": [],
|
||||
}
|
||||
preset_codes = ROLE_PRESET_KPIS.get(role, [])
|
||||
|
||||
# 1. 我的KPI(responsible_user匹配用户名或姓名)+ 角色预设
|
||||
# 1. 我的KPI(responsible_user匹配用户名或姓名)+ 角色预设(均按企业隔离)
|
||||
assigned_kpis = db.query(KPIDefinition).filter(
|
||||
or_(
|
||||
KPIDefinition.responsible_user == username,
|
||||
KPIDefinition.responsible_user == name,
|
||||
),
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
assigned_ids = {k.id for k in assigned_kpis}
|
||||
|
||||
# 补充角色预设KPI(去重)
|
||||
# 补充角色预设KPI(去重,按企业隔离)
|
||||
preset_kpis = []
|
||||
if preset_codes:
|
||||
preset_kpis = db.query(KPIDefinition).filter(
|
||||
q = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code.in_(preset_codes),
|
||||
KPIDefinition.status == "active",
|
||||
~KPIDefinition.id.in_(assigned_ids) if assigned_ids else True,
|
||||
).all()
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
)
|
||||
if assigned_ids:
|
||||
q = q.filter(~KPIDefinition.id.in_(assigned_ids))
|
||||
preset_kpis = q.all()
|
||||
|
||||
all_kpis = assigned_kpis + preset_kpis
|
||||
|
||||
@@ -400,11 +438,22 @@ def my_dashboard(
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
|
||||
actual = latest_v.actual_value if latest_v else None
|
||||
target = k.target_value
|
||||
target = kpi_target_by_frequency(k)
|
||||
level = "gray"
|
||||
if actual is not None and target:
|
||||
ratio = actual / target
|
||||
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
|
||||
# 反向指标(越低越好):费用率/渠补率/应收天数/返利率/成本率/存货天数
|
||||
REVERSE_INDICATORS = {
|
||||
"F_COST_RATIO", "C_REBATE_RATE", "F_AR_DAYS",
|
||||
"F_REBATE_RATE", "F_FACTORY_REBATE_RATE", "F_COST_CONTROL_RATE",
|
||||
"F_INV_DAYS", "F_DEBT_RATIO", "P_BUG_RATE", "P_REWORK_PCT",
|
||||
}
|
||||
if k.kpi_code in REVERSE_INDICATORS:
|
||||
# 反向:实际≤目标=绿;实际≤目标*1.1=黄;否则红
|
||||
level = "green" if actual <= target else (
|
||||
"yellow" if actual <= target * 1.1 else "red")
|
||||
else:
|
||||
ratio = actual / target
|
||||
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
|
||||
|
||||
kpi_list.append({
|
||||
"id": k.id,
|
||||
@@ -413,20 +462,28 @@ def my_dashboard(
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"target_value": target,
|
||||
"target_monthly": k.target_monthly,
|
||||
"target_quarterly": k.target_quarterly,
|
||||
"target_yearly": k.target_yearly,
|
||||
"frequency": k.frequency,
|
||||
"actual_value": actual,
|
||||
"unit": k.unit,
|
||||
"level": level,
|
||||
"period": latest_v.period if latest_v else None,
|
||||
})
|
||||
|
||||
# 2. 我的改善行动(assignee匹配)
|
||||
# 2. 我的改善行动(assignee匹配;CEO/管理员看全部)
|
||||
from app.models import ActionPlan
|
||||
my_plans = db.query(ActionPlan).filter(
|
||||
or_(
|
||||
ActionPlan.assignee == username,
|
||||
ActionPlan.assignee == name,
|
||||
)
|
||||
).order_by(ActionPlan.updated_at.desc()).all()
|
||||
if current_user.role == "ceo":
|
||||
# CEO/管理员查看全部行动方案(2026-08-26修复: 原逻辑只按assignee过滤导致工作台显示空)
|
||||
my_plans = db.query(ActionPlan).order_by(ActionPlan.updated_at.desc()).all()
|
||||
else:
|
||||
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:
|
||||
|
||||
@@ -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"})
|
||||
+337
-20
@@ -1,55 +1,326 @@
|
||||
"""数据对接 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 fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import 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()}
|
||||
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).all()} # 账套隔离 P2
|
||||
|
||||
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
|
||||
count = 0
|
||||
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,
|
||||
entity_id=kpi_entity_map.get(kpi_code),
|
||||
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_patterns": [
|
||||
re.compile(r'^(科目名称|项目名称|指标名称?|kpi名称?|name|名称)$', re.I),
|
||||
re.compile(r'^(科目编码|科目代码|kpi_?code|指标编码|编码)$', re.I),
|
||||
re.compile(r'^(科目|项目|账户|报表项目)$'),
|
||||
],
|
||||
# 期间列匹配
|
||||
"period_patterns": [
|
||||
re.compile(r'^(period|期间|月份?|年月|日期|会计期间)$', re.I),
|
||||
re.compile(r'^(报表期[间]?|所属期)$'),
|
||||
re.compile(r'^年|^月'), # 以"年"或"月"开头的列
|
||||
],
|
||||
# 数值列匹配
|
||||
"value_patterns": [
|
||||
re.compile(r'^(actual_?value|数值|实际值|实际金额)$', re.I),
|
||||
re.compile(r'^(本期金额|本月数|本期|期末余额|期末数|余额)$'),
|
||||
re.compile(r'^(金额|数据|value|本年累计|本期发生[额]?)$', re.I),
|
||||
],
|
||||
# 文件名→期间提取
|
||||
"period_in_filename_patterns": [
|
||||
re.compile(r'[-_]?(\d{4})[-_]?(\d{1,2})'), # 2026-06 / 2026_06 / 2606
|
||||
re.compile(r'(\d{4})年(\d{1,2})月(?:至(\d{4})年(\d{1,2})月)?'), # 2026年01月 / 2026年01月至2026年05月
|
||||
re.compile(r'(\d{4})(\d{2})'), # 202606 (纯数字6-8位)
|
||||
],
|
||||
# 文件名→报表类型
|
||||
"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:
|
||||
for pat in _SMART_MAP["period_in_filename_patterns"]:
|
||||
m = pat.search(filename)
|
||||
if m:
|
||||
groups = m.groups()
|
||||
if len(groups) == 4 and groups[2]: # 2026年01月至2026年05月 → 取结束月
|
||||
return f"{groups[2]}-{int(groups[3]):02d}"
|
||||
if len(groups) >= 2: # 2026-06 或 2026年01月
|
||||
return f"{int(groups[0])}-{int(groups[1]):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(...),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
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
|
||||
# 中文名映射("营业收入"→F_REVENUE)
|
||||
name_map: dict[str, str] = {}
|
||||
for code, kpi_obj in kpis.items():
|
||||
name_map[kpi_obj.kpi_name] = code
|
||||
|
||||
# 8. 遍历导入(匹配不上的自动创建KPI)
|
||||
stype_prefix = {"PL": "PL_", "CF": "CF_", "BS": "BS_"}.get(stype or "", "EXT_")
|
||||
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
|
||||
imported = 0
|
||||
created_kpis = 0
|
||||
skipped_rows = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
raw_kpi_raw = row.get(kpi_col, "")
|
||||
raw_kpi = str(raw_kpi_raw).strip()
|
||||
raw_val = row.get(value_col)
|
||||
raw_period = str(row.get(period_col, period or "")).strip() if period_col else (period or "")
|
||||
|
||||
# 名称防护:NaN/空/None 或 纯数字(疑似科目编码被误当名称)→ 跳过,避免创建垃圾KPI
|
||||
if raw_kpi.lower() in ("nan", "none") or not raw_kpi:
|
||||
skipped_rows.append(f"第{idx+2}行: KPI名称为空")
|
||||
continue
|
||||
if re.fullmatch(r"\d+(\.\d+)?", raw_kpi):
|
||||
skipped_rows.append(f"第{idx+2}行: KPI名称疑似科目编码「{raw_kpi}」,跳过")
|
||||
continue
|
||||
if pd.isna(raw_val):
|
||||
skipped_rows.append(f"第{idx+2}行: 缺数据")
|
||||
continue
|
||||
if not raw_period:
|
||||
skipped_rows.append(f"第{idx+2}行: 无法确定期间")
|
||||
continue
|
||||
|
||||
# 清理科目名(去掉"一、""减:""加:"等前缀)
|
||||
clean_name = re.sub(r'^[一二三四五六七八九十、\s\+]+', '', raw_kpi)
|
||||
clean_name = re.sub(r'^[减加]?[::]\s*', '', clean_name).strip()
|
||||
if not clean_name:
|
||||
clean_name = raw_kpi
|
||||
|
||||
# 匹配KPI
|
||||
kpi_code = None
|
||||
|
||||
# ① 精确编码匹配(极少情况)
|
||||
if raw_kpi in known_codes:
|
||||
kpi_code = raw_kpi
|
||||
# ② 别名匹配(去符号小写)
|
||||
if not kpi_code:
|
||||
clean_key = re.sub(r'[\s\-_()()]', '', raw_kpi).lower()
|
||||
kpi_code = alias_map.get(clean_key)
|
||||
# ③ 中文名精确匹配
|
||||
if not kpi_code:
|
||||
kpi_code = name_map.get(clean_name)
|
||||
# ④ 中文名模糊匹配
|
||||
if not kpi_code:
|
||||
for code, kpi_obj in kpis.items():
|
||||
if clean_name in kpi_obj.kpi_name or kpi_obj.kpi_name in clean_name:
|
||||
kpi_code = code
|
||||
break
|
||||
|
||||
# ⑤ 仍未匹配 → 自动创建KPI
|
||||
if not kpi_code:
|
||||
new_code = f"{stype_prefix}{len(kpis) + created_kpis + 1:03d}"
|
||||
new_kpi = KPIDefinition(
|
||||
entity_id=entity_id,
|
||||
kpi_code=new_code,
|
||||
kpi_name=clean_name,
|
||||
dimension="finance",
|
||||
category="financial_report",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(new_kpi)
|
||||
db.flush()
|
||||
kpis[new_code] = new_kpi
|
||||
known_codes.add(new_code)
|
||||
name_map[clean_name] = new_code
|
||||
kpi_code = new_code
|
||||
created_kpis += 1
|
||||
|
||||
try:
|
||||
val = float(raw_val)
|
||||
except:
|
||||
skipped_rows.append(f"第{idx+2}行: 数值格式错误「{raw_val}」")
|
||||
continue
|
||||
|
||||
db.add(KPIValue(
|
||||
kpi_id=kpis[kpi_code].id,
|
||||
entity_id=kpis[kpi_code].entity_id, # 账套隔离 P2
|
||||
period=raw_period,
|
||||
actual_value=val,
|
||||
source_type="excel",
|
||||
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 created_kpis:
|
||||
msg += f",自动创建{created_kpis}个新KPI"
|
||||
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 +369,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,387 @@
|
||||
"""数据分类分级 — 重要数据标记(2026-08-26 政策驱动)
|
||||
|
||||
背景:《网络数据安全风险评估办法》(2026-08-20 三部门施行)
|
||||
- 重要数据处理者每年一次强制评估
|
||||
- 评估前提 = 先分类分级("不知道重要数据在哪,评估无从谈起")
|
||||
- 本模块 = "分类分级工具"第一版(标记+清单),不做评估算法
|
||||
|
||||
数据级别(参考 GB/T 43697 简化):
|
||||
- core 核心数据(一旦遭篡改/破坏/泄露,直接危害国家安全/经济运行)
|
||||
- important 重要数据(一旦遭篡改/破坏/泄露,危害公共利益/企业关键业务)
|
||||
- general 一般数据(其余)
|
||||
|
||||
覆盖对象:
|
||||
- subjects 会计科目(全局,无 entity_id,与现有科目管理一致)
|
||||
- kpi_definitions KPI字典(按 entity_id 多租户隔离)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import csv
|
||||
import io
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import Subject, KPIDefinition, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/data-classification", tags=["数据分类分级"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# 写操作只允许 ceo/finance/it(与 KPI 字典一致)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
VALID_LEVELS = {"core", "important", "general"}
|
||||
|
||||
LEVEL_LABELS = {
|
||||
"core": "核心数据",
|
||||
"important": "重要数据",
|
||||
"general": "一般数据",
|
||||
}
|
||||
|
||||
# 行业参考目录(内置提示,可按行业自定义扩展)
|
||||
INDUSTRY_REFERENCE = [
|
||||
{"industry": "通用/企业服务", "category": "客户数据", "suggest_level": "important",
|
||||
"desc": "客户名单、合同、订单、应收账款等客户经营数据"},
|
||||
{"industry": "通用/企业服务", "category": "财务数据", "suggest_level": "important",
|
||||
"desc": "财务报表、银行账户、税务申报、薪酬数据"},
|
||||
{"industry": "通用/企业服务", "category": "员工数据", "suggest_level": "important",
|
||||
"desc": "员工身份、薪酬、社保、考勤、健康信息"},
|
||||
{"industry": "金融", "category": "账户交易", "suggest_level": "core",
|
||||
"desc": "客户账户、交易流水、信贷记录(金融行业核心数据)"},
|
||||
{"industry": "医疗", "category": "健康医疗", "suggest_level": "core",
|
||||
"desc": "病历、健康档案、基因数据(医疗行业核心数据)"},
|
||||
{"industry": "工业", "category": "关键基础设施", "suggest_level": "important",
|
||||
"desc": "生产控制、工艺参数、供应链关键环节数据"},
|
||||
{"industry": "互联网", "category": "用户个人信息", "suggest_level": "important",
|
||||
"desc": "个人信息、日志、位置数据(万人以上规模需重点评估)"},
|
||||
{"industry": "能源", "category": "能源数据", "suggest_level": "important",
|
||||
"desc": "能源生产、传输、消费监测数据"},
|
||||
]
|
||||
|
||||
|
||||
def _subject_to_item(s: Subject) -> dict:
|
||||
return {
|
||||
"type": "subject",
|
||||
"id": s.id,
|
||||
"code": s.subject_code,
|
||||
"name": s.subject_name,
|
||||
"dimension": None,
|
||||
"category": s.new_standard_category or s.category or "",
|
||||
"important_flag": 1 if s.important_flag else 0,
|
||||
"data_level": s.data_level or "general",
|
||||
"data_category": s.data_category or "",
|
||||
"data_owner": None,
|
||||
"storage": "会计科目台账",
|
||||
}
|
||||
|
||||
|
||||
def _kpi_to_item(k: KPIDefinition) -> dict:
|
||||
return {
|
||||
"type": "kpi",
|
||||
"id": k.id,
|
||||
"code": k.kpi_code,
|
||||
"name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category or "",
|
||||
"important_flag": 1 if k.important_flag else 0,
|
||||
"data_level": k.data_level or "general",
|
||||
"data_category": k.data_category or "",
|
||||
"data_owner": k.data_owner,
|
||||
"storage": f"KPI台账(entity_id={k.entity_id})",
|
||||
}
|
||||
|
||||
|
||||
def _query_items(
|
||||
data_type: Optional[str],
|
||||
data_level: Optional[str],
|
||||
important: Optional[int],
|
||||
keyword: Optional[str],
|
||||
entity_id: int,
|
||||
db: Session,
|
||||
) -> list:
|
||||
"""内部清单查询(供 inventory/export 复用,避免依赖注入问题)"""
|
||||
items: list[dict] = []
|
||||
|
||||
if not data_type or data_type == "subject":
|
||||
q = db.query(Subject).filter(Subject.is_active == 1)
|
||||
if data_level:
|
||||
q = q.filter(Subject.data_level == data_level)
|
||||
if important == 1:
|
||||
q = q.filter(Subject.important_flag == 1)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
Subject.subject_name.like(like) | Subject.subject_code.like(like)
|
||||
)
|
||||
for s in q.order_by(Subject.subject_code).all():
|
||||
items.append(_subject_to_item(s))
|
||||
|
||||
if not data_type or data_type == "kpi":
|
||||
q = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
)
|
||||
if data_level:
|
||||
q = q.filter(KPIDefinition.data_level == data_level)
|
||||
if important == 1:
|
||||
q = q.filter(KPIDefinition.important_flag == 1)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
KPIDefinition.kpi_name.like(like) | KPIDefinition.kpi_code.like(like)
|
||||
)
|
||||
for k in q.order_by(KPIDefinition.kpi_code).all():
|
||||
items.append(_kpi_to_item(k))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _stats_of(items: list) -> dict:
|
||||
return {
|
||||
"total": len(items),
|
||||
"marked": sum(1 for i in items if i["important_flag"] == 1),
|
||||
"by_level": {
|
||||
"core": sum(1 for i in items if i["data_level"] == "core"),
|
||||
"important": sum(1 for i in items if i["data_level"] == "important"),
|
||||
"general": sum(1 for i in items if i["data_level"] == "general"),
|
||||
},
|
||||
"by_type": {
|
||||
"subject": sum(1 for i in items if i["type"] == "subject"),
|
||||
"kpi": sum(1 for i in items if i["type"] == "kpi"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/inventory")
|
||||
def get_inventory(
|
||||
data_type: Optional[str] = Query(None, description="subject/kpi,缺省返回全部"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||
keyword: Optional[str] = Query(None, description="编码/名称搜索"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""数据资产清单(科目 + KPI 合并输出,含重要级别)"""
|
||||
if data_level and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
items = _query_items(data_type, data_level, important, keyword, entity_id, db)
|
||||
return {"total": len(items), "items": items, "stats": _stats_of(items)}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""数据分类分级统计概览(评估前准备看板)"""
|
||||
subject_total = db.query(Subject).filter(Subject.is_active == 1).count()
|
||||
subject_marked = db.query(Subject).filter(
|
||||
Subject.is_active == 1, Subject.important_flag == 1
|
||||
).count()
|
||||
kpi_total = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||
).count()
|
||||
kpi_marked = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.important_flag == 1,
|
||||
).count()
|
||||
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": ent.name if ent else "",
|
||||
"subjects": {"total": subject_total, "marked": subject_marked},
|
||||
"kpis": {"total": kpi_total, "marked": kpi_marked},
|
||||
"total": subject_total + kpi_total,
|
||||
"marked_total": subject_marked + kpi_marked,
|
||||
"coverage_pct": round((subject_marked + kpi_marked) / (subject_total + kpi_total) * 100, 1)
|
||||
if (subject_total + kpi_total) else 0,
|
||||
"policy_note": "《网络数据安全风险评估办法》(2026-08-20施行):重要数据处理者每年一次强制评估,评估前提=先完成数据分类分级",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/subjects/{subject_id}")
|
||||
def mark_subject(
|
||||
subject_id: int,
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""标记单个科目:重要数据标记 + 数据分级 + 行业分类"""
|
||||
subject = db.query(Subject).filter(Subject.id == subject_id).first()
|
||||
if not subject:
|
||||
raise HTTPException(404, "科目不存在")
|
||||
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
if important_flag is not None:
|
||||
subject.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
subject.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
subject.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||
if data_category is not None:
|
||||
subject.data_category = data_category or None
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "更新成功",
|
||||
"subject_id": subject_id,
|
||||
"important_flag": subject.important_flag,
|
||||
"data_level": subject.data_level,
|
||||
"data_category": subject.data_category,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/kpis/{kpi_id}")
|
||||
def mark_kpi(
|
||||
kpi_id: int,
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""标记单个KPI:重要数据标记 + 数据分级 + 行业分类(按账套隔离)"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在或不属于当前账套")
|
||||
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
if important_flag is not None:
|
||||
kpi.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
kpi.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
kpi.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||
if data_category is not None:
|
||||
kpi.data_category = data_category or None
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "更新成功",
|
||||
"kpi_id": kpi_id,
|
||||
"important_flag": kpi.important_flag,
|
||||
"data_level": kpi.data_level,
|
||||
"data_category": kpi.data_category,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/batch")
|
||||
def batch_mark(
|
||||
data_type: str = Query(..., description="subject/kpi"),
|
||||
ids: List[int] = Query(..., description="ID列表"),
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""批量标记(科目全局 / KPI按账套隔离)"""
|
||||
if data_type not in ("subject", "kpi"):
|
||||
raise HTTPException(400, f"无效的数据类型: {data_type},可选 subject/kpi")
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
if not ids:
|
||||
raise HTTPException(400, "ids 不能为空")
|
||||
|
||||
updated = 0
|
||||
if data_type == "subject":
|
||||
q = db.query(Subject).filter(Subject.id.in_(ids), Subject.is_active == 1)
|
||||
targets = q.all()
|
||||
for s in targets:
|
||||
if important_flag is not None:
|
||||
s.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
s.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
s.important_flag = 1
|
||||
if data_category is not None:
|
||||
s.data_category = data_category or None
|
||||
updated += 1
|
||||
else:
|
||||
q = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id.in_(ids),
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
)
|
||||
targets = q.all()
|
||||
for k in targets:
|
||||
if important_flag is not None:
|
||||
k.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
k.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
k.important_flag = 1
|
||||
if data_category is not None:
|
||||
k.data_category = data_category or None
|
||||
updated += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"批量更新成功", "data_type": data_type, "updated_count": updated}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_csv(
|
||||
data_type: Optional[str] = Query(None, description="subject/kpi,缺省全部"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""导出数据分类分级清单(评估前准备材料)CSV"""
|
||||
if data_level and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level}")
|
||||
|
||||
items = _query_items(data_type, data_level, important, None, entity_id, db)
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["类型", "编码", "名称", "维度", "分类", "重要标记", "数据级别", "行业参考分类", "数据责任人", "存储位置"])
|
||||
for i in items:
|
||||
writer.writerow([
|
||||
"科目" if i["type"] == "subject" else "KPI",
|
||||
i["code"],
|
||||
i["name"],
|
||||
i["dimension"] or "",
|
||||
i["category"] or "",
|
||||
"是" if i["important_flag"] else "否",
|
||||
LEVEL_LABELS.get(i["data_level"], i["data_level"]),
|
||||
i["data_category"] or "",
|
||||
i["data_owner"] or "",
|
||||
i["storage"] or "",
|
||||
])
|
||||
|
||||
content = "\ufeff" + buf.getvalue() # BOM 兼容 Excel
|
||||
filename = f"data-classification-inventory_{datetime.now().strftime('%Y%m%d')}.csv"
|
||||
from starlette.responses import Response
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/industry-reference")
|
||||
def industry_reference():
|
||||
"""行业参考目录(内置提示,按行业给出重点数据分类建议)"""
|
||||
return {"items": INDUSTRY_REFERENCE}
|
||||
@@ -0,0 +1,324 @@
|
||||
"""自动数据质量监控 — 任务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
|
||||
|
||||
# ── 数据审计看板统计 ──
|
||||
# KPI完整度评分
|
||||
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
total = len(all_kpis)
|
||||
complete_kpis = 0
|
||||
missing_metadata_count = 0
|
||||
missing_data_count = 0
|
||||
stale_data_count = 0
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
six_months_ago = datetime.now() - timedelta(days=180)
|
||||
|
||||
for kpi in all_kpis:
|
||||
# 元数据完整度检查
|
||||
has_meta = all([
|
||||
kpi.formula and kpi.formula.strip(),
|
||||
kpi.data_source and kpi.data_source.strip(),
|
||||
kpi.data_owner and kpi.data_owner.strip(),
|
||||
kpi.unit and kpi.unit.strip(),
|
||||
kpi.target_value is not None,
|
||||
])
|
||||
if has_meta:
|
||||
complete_kpis += 1
|
||||
else:
|
||||
missing_metadata_count += 1
|
||||
|
||||
# 数据缺失检查(是否有实际值)
|
||||
val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).first()
|
||||
if not val:
|
||||
missing_data_count += 1
|
||||
|
||||
# 超30天未更新预警
|
||||
latest_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
if latest_val and latest_val.calculated_at:
|
||||
if latest_val.calculated_at < six_months_ago:
|
||||
stale_data_count += 1
|
||||
|
||||
completeness_score = round(complete_kpis / total * 100, 1) if total > 0 else 0
|
||||
missing_rate = round(missing_data_count / total * 100, 1) if total > 0 else 0
|
||||
|
||||
return {
|
||||
"total_kpis": total_kpis,
|
||||
"total_logs": total_logs,
|
||||
"open_logs": open_logs,
|
||||
"severity_counts": severity_counts,
|
||||
"type_counts": type_counts,
|
||||
# 数据审计看板
|
||||
"completeness": {
|
||||
"score": completeness_score,
|
||||
"complete": complete_kpis,
|
||||
"total": total,
|
||||
"missing_metadata": missing_metadata_count,
|
||||
},
|
||||
"data_missing": {
|
||||
"count": missing_data_count,
|
||||
"rate": missing_rate,
|
||||
"total": total,
|
||||
},
|
||||
"stale_data": {
|
||||
"count": stale_data_count,
|
||||
"threshold_days": 180,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
|
||||
|
||||
派生规则配置(budget_derivation_rules):apply-method 派生KPI时优先读规则,
|
||||
percentage_of → base_kpi实际值×rate;incremental → 上月×(1+rate);无规则fallback默认比例。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import BudgetDerivationRule, KPIDefinition
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["派生规则"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/derivation-rules")
|
||||
def list_derivation_rules(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""派生规则列表(按 entity_id 隔离)"""
|
||||
query = db.query(BudgetDerivationRule).filter(BudgetDerivationRule.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetDerivationRule.kpi_id == kpi_id)
|
||||
if status:
|
||||
query = query.filter(BudgetDerivationRule.status == status)
|
||||
rows = query.order_by(BudgetDerivationRule.id.desc()).all()
|
||||
|
||||
all_kpi_ids = {r.kpi_id for r in rows} | {r.base_kpi_id for r in rows if r.base_kpi_id}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(all_kpi_ids)).all()} if all_kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
base = kpis.get(r.base_kpi_id) if r.base_kpi_id else None
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"rule_type": r.rule_type,
|
||||
"base_kpi_id": r.base_kpi_id,
|
||||
"base_kpi_code": base.kpi_code if base else "",
|
||||
"base_kpi_name": base.kpi_name if base else "",
|
||||
"params": r.params,
|
||||
"formula_text": r.formula_text,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/derivation-rules")
|
||||
def create_derivation_rule(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建派生规则(同KPI同类型唯一)"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
rule_type = data.get("rule_type")
|
||||
if not kpi_id or rule_type not in ("incremental", "percentage_of", "formula"):
|
||||
raise HTTPException(400, "需要 kpi_id 且 rule_type ∈ incremental/percentage_of/formula")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
existing = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
BudgetDerivationRule.kpi_id == kpi_id,
|
||||
BudgetDerivationRule.rule_type == rule_type,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI({kpi_id})已存在 {rule_type} 规则")
|
||||
|
||||
row = BudgetDerivationRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
base_kpi_id=data.get("base_kpi_id"),
|
||||
params=data.get("params"),
|
||||
formula_text=data.get("formula_text"),
|
||||
status=data.get("status", "active"),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "派生规则已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/derivation-rules/{rule_id}")
|
||||
def update_derivation_rule(
|
||||
rule_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新派生规则"""
|
||||
row = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.id == rule_id,
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
for field in ("rule_type", "base_kpi_id", "params", "formula_text", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "派生规则已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/derivation-rules/{rule_id}")
|
||||
def delete_derivation_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除派生规则"""
|
||||
row = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.id == rule_id,
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "派生规则已删除"}
|
||||
@@ -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,478 @@
|
||||
"""驱动因子预算 API — 科目/驱动因子模式切换 + 行业包 + 敏感性分析"""
|
||||
import json
|
||||
import math
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import SystemConfig, OperationLog
|
||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget/driver",
|
||||
tags=["驱动因子预算"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 模板预置数据
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
PRESET_TEMPLATES = [
|
||||
# 通用收入模板
|
||||
{
|
||||
"name": "收入预测(通用)",
|
||||
"industry": "general",
|
||||
"category": "revenue",
|
||||
"formula_desc": "收入 = 客户数 × 客单价",
|
||||
"formula_text": "客户数×客单价",
|
||||
"factors": [
|
||||
{"key": "customer_count", "label": "客户数", "type": "number", "default": 100, "unit": "家"},
|
||||
{"key": "avg_price", "label": "客单价", "type": "number", "default": 50000, "unit": "元/家"},
|
||||
],
|
||||
},
|
||||
# 通用费用模板
|
||||
{
|
||||
"name": "费用预测(通用)",
|
||||
"industry": "general",
|
||||
"category": "expense",
|
||||
"formula_desc": "费用 = 人数 × 人均成本",
|
||||
"formula_text": "人数×人均成本",
|
||||
"factors": [
|
||||
{"key": "headcount", "label": "人数", "type": "number", "default": 50, "unit": "人"},
|
||||
{"key": "avg_cost_per_head", "label": "人均成本", "type": "number", "default": 8000, "unit": "元/人"},
|
||||
],
|
||||
},
|
||||
# 贸易经销版 — 渠补预算
|
||||
{
|
||||
"name": "渠补预算(贸易经销版)",
|
||||
"industry": "trade",
|
||||
"category": "expense",
|
||||
"formula_desc": "渠补预算 = 计划维护渠道数 × 平均渠补率 × 平均渠道交易额",
|
||||
"formula_text": "计划维护渠道数×平均渠补率×平均渠道交易额",
|
||||
"factors": [
|
||||
{"key": "channel_count", "label": "计划维护渠道数", "type": "number", "default": 80, "unit": "家"},
|
||||
{"key": "channel_subsidy_rate", "label": "平均渠补率", "type": "percent", "default": 75, "unit": "%"},
|
||||
{"key": "avg_transaction", "label": "平均渠道交易额", "type": "number", "default": 150000, "unit": "元/家"},
|
||||
],
|
||||
},
|
||||
# 贸易经销版 — 收入预测
|
||||
{
|
||||
"name": "收入预测(贸易经销版)",
|
||||
"industry": "trade",
|
||||
"category": "revenue",
|
||||
"formula_desc": "收入 = 渠道数 × 平均交易额",
|
||||
"formula_text": "渠道数×平均交易额",
|
||||
"factors": [
|
||||
{"key": "channel_count", "label": "渠道数", "type": "number", "default": 80, "unit": "家"},
|
||||
{"key": "avg_transaction", "label": "平均交易额", "type": "number", "default": 150000, "unit": "元/家"},
|
||||
],
|
||||
},
|
||||
# IT服务版 — 收入
|
||||
{
|
||||
"name": "收入预测(IT服务版)",
|
||||
"industry": "it",
|
||||
"category": "revenue",
|
||||
"formula_desc": "收入 = 计划新签客户数×平均合同额 + 续约客户数×续约率×平均合同额",
|
||||
"formula_text": "计划新签客户数×平均合同额+续约客户数×续约率×平均合同额",
|
||||
"factors": [
|
||||
{"key": "new_customers", "label": "计划新签客户数", "type": "number", "default": 36, "unit": "家/年"},
|
||||
{"key": "avg_contract_value", "label": "平均合同额", "type": "number", "default": 80000, "unit": "元/家"},
|
||||
{"key": "renewal_customers", "label": "续约客户数", "type": "number", "default": 260, "unit": "家"},
|
||||
{"key": "renewal_rate", "label": "续约率", "type": "percent", "default": 90, "unit": "%"},
|
||||
],
|
||||
},
|
||||
# IT服务版 — 销售费用
|
||||
{
|
||||
"name": "销售费用预算(IT服务版)",
|
||||
"industry": "it",
|
||||
"category": "expense",
|
||||
"formula_desc": "销售费用 = 新签客户数×平均获客成本 + 续约客户数×维护成本",
|
||||
"formula_text": "新签客户数×平均获客成本+续约客户数×维护成本",
|
||||
"factors": [
|
||||
{"key": "new_customers", "label": "计划新签客户数", "type": "number", "default": 36, "unit": "家/年"},
|
||||
{"key": "acquisition_cost", "label": "平均获客成本", "type": "number", "default": 3000, "unit": "元/家"},
|
||||
{"key": "renewal_customers", "label": "续约客户数", "type": "number", "default": 260, "unit": "家"},
|
||||
{"key": "maintenance_cost", "label": "维护成本", "type": "number", "default": 500, "unit": "元/家"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 模式切换
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/mode")
|
||||
def get_driver_mode(db: Session = Depends(get_db)):
|
||||
"""获取当前预算编制模式: subject / driver"""
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_driver_mode").first()
|
||||
if not cfg:
|
||||
return {"mode": "subject", "label": "科目模式"}
|
||||
try:
|
||||
val = json.loads(cfg.config_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
val = {"mode": "subject"}
|
||||
return val
|
||||
|
||||
|
||||
@router.post("/mode")
|
||||
def set_driver_mode(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
|
||||
mode = data.get("mode", "subject")
|
||||
if mode not in ("subject", "driver"):
|
||||
raise HTTPException(400, "模式必须是 subject 或 driver")
|
||||
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_driver_mode").first()
|
||||
val = json.dumps({"mode": mode, "label": "驱动因子模式" if mode == "driver" else "科目模式"})
|
||||
if cfg:
|
||||
cfg.config_value = val
|
||||
else:
|
||||
cfg = SystemConfig(
|
||||
config_key="budget_driver_mode",
|
||||
config_value=val,
|
||||
description="预算编制模式: subject=科目模式, driver=驱动因子模式",
|
||||
)
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
|
||||
log = OperationLog(
|
||||
action="update",
|
||||
target_type="budget",
|
||||
target_id=0,
|
||||
detail=json.dumps({"mode": mode, "action": "切换编制模式"}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 模板管理
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/templates")
|
||||
def list_driver_templates(
|
||||
industry: Optional[str] = Query(None, description="行业: general/trade/it"),
|
||||
category: Optional[str] = Query(None, description="类别: revenue/expense"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取驱动因子模板列表(含预置模板)"""
|
||||
# 先从数据库读
|
||||
query = db.query(DriverFactorTemplate).filter(DriverFactorTemplate.is_active == 1)
|
||||
if industry:
|
||||
query = query.filter(DriverFactorTemplate.industry == industry)
|
||||
if category:
|
||||
query = query.filter(DriverFactorTemplate.category == category)
|
||||
db_templates = query.order_by(DriverFactorTemplate.id.asc()).all()
|
||||
|
||||
# 合并预置模板(数据库没有则返回预置)
|
||||
if db_templates:
|
||||
result = []
|
||||
for t in db_templates:
|
||||
result.append({
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"industry": t.industry,
|
||||
"category": t.category,
|
||||
"formula_desc": t.formula_desc,
|
||||
"formula_text": t.formula_text,
|
||||
"factors": t.factors,
|
||||
"is_preset": False,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
else:
|
||||
# 返回预置模板
|
||||
filtered = PRESET_TEMPLATES
|
||||
if industry:
|
||||
filtered = [t for t in filtered if t["industry"] == industry]
|
||||
if category:
|
||||
filtered = [t for t in filtered if t["category"] == category]
|
||||
return {"data": filtered, "total": len(filtered)}
|
||||
|
||||
|
||||
@router.get("/industries")
|
||||
def list_driver_industries(db: Session = Depends(get_db)):
|
||||
"""获取行业包列表"""
|
||||
industries = [
|
||||
{"key": "general", "label": "通用模板", "icon": "📦"},
|
||||
{"key": "trade", "label": "贸易经销版", "icon": "🏪"},
|
||||
{"key": "it", "label": "IT服务版", "icon": "💻"},
|
||||
]
|
||||
return {"data": industries}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 驱动因子计算
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _calculate_formula(formula_text: str, factors: dict, factor_defs: list) -> float:
|
||||
"""
|
||||
根据公式文本和驱动因子值计算预算。
|
||||
支持的运算: + - × * /
|
||||
公式文本中的因子名可以是key或label,函数会自动映射到值。
|
||||
"""
|
||||
# 替换 × 为 *
|
||||
expr = formula_text.replace("×", "*")
|
||||
|
||||
# 构建变量映射:key -> value, label -> value
|
||||
factor_map = {}
|
||||
for fd in factor_defs:
|
||||
key = fd["key"]
|
||||
val = factors.get(key, fd.get("default", 0))
|
||||
if fd.get("type") == "percent":
|
||||
val = float(val) / 100.0
|
||||
else:
|
||||
val = float(val)
|
||||
factor_map[key] = val
|
||||
# 也映射 label(中文名)
|
||||
factor_map[fd["label"]] = val
|
||||
|
||||
# 按长度降序替换,避免短名被错误替换
|
||||
sorted_names = sorted(factor_map.keys(), key=len, reverse=True)
|
||||
for name in sorted_names:
|
||||
expr = expr.replace(name, str(factor_map[name]))
|
||||
|
||||
try:
|
||||
result = eval(expr, {"__builtins__": {}}, {})
|
||||
return round(float(result), 2)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"公式计算失败: {expr}, 错误: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/calculate")
|
||||
def driver_calculate(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
驱动因子计算预算。
|
||||
接收驱动因子值,自动按公式计算预算结果。
|
||||
如果指定了 template_id,使用模板的公式和因子定义。
|
||||
否则使用 data.name, data.formula_text, data.factors 中的因子定义。
|
||||
"""
|
||||
template_id = data.get("template_id")
|
||||
factors_input = data.get("factors", {}) # {key: value}
|
||||
period = data.get("period", datetime.now().strftime("%Y-%m"))
|
||||
|
||||
# 获取模板
|
||||
formula_text = None
|
||||
factor_defs = []
|
||||
|
||||
if template_id:
|
||||
template = db.query(DriverFactorTemplate).filter(
|
||||
DriverFactorTemplate.id == template_id,
|
||||
DriverFactorTemplate.is_active == 1,
|
||||
).first()
|
||||
if not template:
|
||||
# 检查预置模板
|
||||
if 1 <= template_id <= len(PRESET_TEMPLATES):
|
||||
preset = PRESET_TEMPLATES[template_id - 1]
|
||||
formula_text = preset["formula_text"]
|
||||
factor_defs = preset["factors"]
|
||||
name = preset["name"]
|
||||
industry = preset["industry"]
|
||||
else:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
else:
|
||||
formula_text = template.formula_text
|
||||
factor_defs = template.factors
|
||||
name = template.name
|
||||
industry = template.industry
|
||||
else:
|
||||
name = data.get("name", "自定义驱动因子预算")
|
||||
industry = data.get("industry", "general")
|
||||
formula_text = data.get("formula_text")
|
||||
factor_defs = data.get("factor_defs", [])
|
||||
if not formula_text:
|
||||
raise HTTPException(400, "缺少 formula_text (公式文本)")
|
||||
if not factor_defs:
|
||||
raise HTTPException(400, "缺少 factor_defs (因子定义)")
|
||||
|
||||
# 填充默认值
|
||||
complete_factors = {}
|
||||
for fd in factor_defs:
|
||||
key = fd["key"]
|
||||
val = factors_input.get(key)
|
||||
if val is None:
|
||||
val = fd.get("default", 0)
|
||||
complete_factors[key] = val
|
||||
|
||||
# 计算结果
|
||||
result = _calculate_formula(formula_text, complete_factors, factor_defs)
|
||||
|
||||
# 记录计算结果
|
||||
budget_record = DriverFactorBudget(
|
||||
name=name,
|
||||
industry=industry,
|
||||
template_id=template_id,
|
||||
factors=complete_factors,
|
||||
calculated_value=result,
|
||||
formula_text=formula_text,
|
||||
period=period,
|
||||
created_by=current_user.name if hasattr(current_user, "name") else "",
|
||||
)
|
||||
db.add(budget_record)
|
||||
db.commit()
|
||||
db.refresh(budget_record)
|
||||
|
||||
# 记录操作日志
|
||||
log = OperationLog(
|
||||
action="calculate",
|
||||
target_type="budget",
|
||||
target_id=budget_record.id,
|
||||
detail=json.dumps({
|
||||
"name": name,
|
||||
"formula": formula_text,
|
||||
"factors": complete_factors,
|
||||
"result": result,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
# 计算原始因子信息(用于前端展示)
|
||||
factor_details = []
|
||||
for fd in factor_defs:
|
||||
key = fd["key"]
|
||||
factor_details.append({
|
||||
"key": key,
|
||||
"label": fd["label"],
|
||||
"value": complete_factors[key],
|
||||
"unit": fd.get("unit", ""),
|
||||
"type": fd.get("type", "number"),
|
||||
})
|
||||
|
||||
return {
|
||||
"id": budget_record.id,
|
||||
"name": name,
|
||||
"industry": industry,
|
||||
"template_id": template_id,
|
||||
"formula_text": formula_text,
|
||||
"formula_desc": data.get("formula_desc", ""),
|
||||
"factors": factor_details,
|
||||
"calculated_value": result,
|
||||
"period": period,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 敏感性分析
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/sensitivity")
|
||||
def driver_sensitivity(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
敏感性分析:对指定驱动因子做 ±10%、±20% 变动,显示预算变动。
|
||||
"""
|
||||
factors_input = data.get("factors", {})
|
||||
formula_text = data.get("formula_text")
|
||||
factor_defs = data.get("factor_defs", [])
|
||||
target_factor = data.get("target_factor") # 要分析的因子key,不指定则分析所有因子
|
||||
|
||||
if not formula_text or not factor_defs:
|
||||
raise HTTPException(400, "缺少 formula_text 或 factor_defs")
|
||||
|
||||
# 计算基准值
|
||||
base_result = _calculate_formula(formula_text, factors_input, factor_defs)
|
||||
|
||||
# 敏感性分析
|
||||
sensitivity_results = []
|
||||
factors_to_analyze = factor_defs
|
||||
if target_factor:
|
||||
factors_to_analyze = [fd for fd in factor_defs if fd["key"] == target_factor]
|
||||
if not factors_to_analyze:
|
||||
raise HTTPException(404, f"未找到因子: {target_factor}")
|
||||
|
||||
for fd in factors_to_analyze:
|
||||
key = fd["key"]
|
||||
base_val = factors_input.get(key, fd.get("default", 0))
|
||||
|
||||
variations = []
|
||||
for pct_change in [-20, -10, 10, 20]:
|
||||
if fd.get("type") == "percent":
|
||||
# 百分比因子:变动比例直接加在百分比上
|
||||
changed_val = base_val * (1 + pct_change / 100.0)
|
||||
else:
|
||||
changed_val = base_val * (1 + pct_change / 100.0)
|
||||
|
||||
test_factors = dict(factors_input)
|
||||
test_factors[key] = round(changed_val, 2)
|
||||
try:
|
||||
new_result = _calculate_formula(formula_text, test_factors, factor_defs)
|
||||
delta = round(new_result - base_result, 2)
|
||||
delta_pct = round((delta / base_result * 100) if base_result != 0 else 0, 2)
|
||||
variations.append({
|
||||
"change_pct": pct_change,
|
||||
"factor_value": round(changed_val, 2),
|
||||
"budget_result": new_result,
|
||||
"delta": delta,
|
||||
"delta_pct": delta_pct,
|
||||
})
|
||||
except Exception:
|
||||
variations.append({
|
||||
"change_pct": pct_change,
|
||||
"factor_value": round(changed_val, 2),
|
||||
"budget_result": None,
|
||||
"delta": None,
|
||||
"delta_pct": None,
|
||||
"error": "计算失败",
|
||||
})
|
||||
|
||||
sensitivity_results.append({
|
||||
"factor_key": key,
|
||||
"factor_label": fd["label"],
|
||||
"base_value": base_val,
|
||||
"unit": fd.get("unit", ""),
|
||||
"type": fd.get("type", "number"),
|
||||
"variations": variations,
|
||||
})
|
||||
|
||||
return {
|
||||
"base_value": base_result,
|
||||
"formula_text": formula_text,
|
||||
"sensitivity": sensitivity_results,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 历史记录
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/history")
|
||||
def list_driver_history(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""查看最近的驱动因子预算计算记录"""
|
||||
records = db.query(DriverFactorBudget).order_by(
|
||||
DriverFactorBudget.created_at.desc()
|
||||
).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for r in records:
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"industry": r.industry,
|
||||
"factors": r.factors,
|
||||
"calculated_value": r.calculated_value,
|
||||
"formula_text": r.formula_text,
|
||||
"period": r.period,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""企业实体 API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth
|
||||
from app.models import Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/entities", tags=["企业实体"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
|
||||
class EntityCreate(BaseModel):
|
||||
name: str
|
||||
short_name: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
status: Optional[str] = "active"
|
||||
|
||||
|
||||
class EntityUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
short_name: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_entities(db: Session = Depends(get_db)):
|
||||
"""获取企业列表"""
|
||||
entities = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).all()
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": e.id,
|
||||
"name": e.name,
|
||||
"short_name": e.short_name,
|
||||
"industry": e.industry,
|
||||
}
|
||||
for e in entities
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_entity(data: EntityCreate, db: Session = Depends(get_db)):
|
||||
"""创建企业"""
|
||||
entity = Entity(
|
||||
name=data.name,
|
||||
short_name=data.short_name,
|
||||
industry=data.industry,
|
||||
status=data.status or "active",
|
||||
)
|
||||
db.add(entity)
|
||||
db.commit()
|
||||
db.refresh(entity)
|
||||
return {
|
||||
"id": entity.id,
|
||||
"name": entity.name,
|
||||
"short_name": entity.short_name,
|
||||
"industry": entity.industry,
|
||||
"message": "企业创建成功",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{entity_id}")
|
||||
def update_entity(entity_id: int, data: EntityUpdate, db: Session = Depends(get_db)):
|
||||
"""更新企业信息"""
|
||||
entity = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not entity:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(status_code=404, content={"detail": "企业不存在"})
|
||||
|
||||
if data.name is not None:
|
||||
entity.name = data.name
|
||||
if data.short_name is not None:
|
||||
entity.short_name = data.short_name
|
||||
if data.industry is not None:
|
||||
entity.industry = data.industry
|
||||
if data.status is not None:
|
||||
entity.status = data.status
|
||||
|
||||
db.commit()
|
||||
db.refresh(entity)
|
||||
return {
|
||||
"id": entity.id,
|
||||
"name": entity.name,
|
||||
"short_name": entity.short_name,
|
||||
"industry": entity.industry,
|
||||
"message": "企业更新成功",
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
"""费用审核智能体 API — 管理会计OS
|
||||
费用规则配置 + 报销单自动校验 + 人工审批流程 + 审核看板统计
|
||||
|
||||
流程:
|
||||
1. 提交报销单 → 自动校验费用规则
|
||||
2. 超限 → 自动打回(returned, 标注原因)
|
||||
3. 合规 → 待人工审批(pending)
|
||||
4. 审批通过(approved) / 拒绝(rejected)
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import ExpenseRule, ExpenseReimbursement, OperationLog
|
||||
|
||||
router = APIRouter(prefix="/api/cma/expenses", tags=["费用审核"])
|
||||
|
||||
# 费用类型中文映射
|
||||
EXPENSE_TYPE_LABELS = {
|
||||
"entertainment": "招待费",
|
||||
"travel": "差旅费",
|
||||
"office": "办公费",
|
||||
"management": "管理费",
|
||||
}
|
||||
|
||||
# 预置规则
|
||||
PRESET_RULES = [
|
||||
{"rule_name": "招待费单笔限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
|
||||
"cycle": "single", "remark": "招待费标准:单笔≤2000元"},
|
||||
{"rule_name": "招待费部门月限额", "dimension": "department", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 50000.0,
|
||||
"cycle": "monthly", "remark": "招待费标准:部门月限额5万元"},
|
||||
{"rule_name": "招待费月度总额限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 150000.0,
|
||||
"cycle": "monthly", "remark": "公司招待费预算15万/月"},
|
||||
{"rule_name": "差旅住宿单晚限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "travel", "limit_type": "single", "limit_amount": 300.0,
|
||||
"cycle": "single", "remark": "差旅费标准:住宿≤300元/晚"},
|
||||
{"rule_name": "差旅交通等级限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "travel", "limit_type": "single", "limit_amount": 1500.0,
|
||||
"cycle": "single", "remark": "差旅费标准:交通等级(高铁二等座/经济舱)"},
|
||||
{"rule_name": "办公费单笔限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "office", "limit_type": "single", "limit_amount": 500.0,
|
||||
"cycle": "single", "remark": "办公费标准:单笔≤500元"},
|
||||
{"rule_name": "管理费月度总额限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "management", "limit_type": "monthly", "limit_amount": 910000.0,
|
||||
"cycle": "monthly", "remark": "管理费预算91万/月"},
|
||||
]
|
||||
|
||||
|
||||
def _rule_to_dict(r: ExpenseRule) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"rule_name": r.rule_name,
|
||||
"dimension": r.dimension,
|
||||
"dimension_value": r.dimension_value,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"limit_type": r.limit_type,
|
||||
"limit_amount": r.limit_amount,
|
||||
"cycle": r.cycle,
|
||||
"status": r.status,
|
||||
"remark": r.remark,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _reimb_to_dict(r: ExpenseReimbursement) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"reimb_no": r.reimb_no,
|
||||
"applicant": r.applicant,
|
||||
"department": r.department,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"title": r.title,
|
||||
"amount": r.amount,
|
||||
"expense_date": r.expense_date.isoformat() if r.expense_date else None,
|
||||
"attachment": r.attachment,
|
||||
"status": r.status,
|
||||
"check_result": r.check_result,
|
||||
"check_reason": r.check_reason,
|
||||
"check_detail": r.check_detail,
|
||||
"checked_at": r.checked_at.isoformat() if r.checked_at else None,
|
||||
"approver": r.approver,
|
||||
"approve_comment": r.approve_comment,
|
||||
"approved_at": r.approved_at.isoformat() if r.approved_at else None,
|
||||
"created_by": r.created_by,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _gen_reimb_no(db: Session) -> str:
|
||||
"""生成报销单号: BX + 年月日 + 序号"""
|
||||
today = datetime.now()
|
||||
prefix = "BX" + today.strftime("%Y%m%d")
|
||||
last = (
|
||||
db.query(ExpenseReimbursement)
|
||||
.filter(ExpenseReimbursement.reimb_no.like(prefix + "%"))
|
||||
.order_by(ExpenseReimbursement.id.desc())
|
||||
.first()
|
||||
)
|
||||
seq = (int(last.reimb_no[-4:]) + 1) if last and last.reimb_no[-4:].isdigit() else 1
|
||||
return f"{prefix}{seq:04d}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 费用规则 CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("/rules")
|
||||
def list_rules(
|
||||
expense_type: str = Query(None),
|
||||
status: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询费用规则列表"""
|
||||
q = db.query(ExpenseRule)
|
||||
if expense_type:
|
||||
q = q.filter(ExpenseRule.expense_type == expense_type)
|
||||
if status:
|
||||
q = q.filter(ExpenseRule.status == status)
|
||||
rules = q.order_by(ExpenseRule.id.asc()).all()
|
||||
return {"data": [_rule_to_dict(r) for r in rules], "total": len(rules)}
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
def create_rule(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""新建费用规则"""
|
||||
rule_name = data.get("rule_name")
|
||||
expense_type = data.get("expense_type")
|
||||
limit_type = data.get("limit_type", "single")
|
||||
limit_amount = data.get("limit_amount")
|
||||
if not rule_name or not expense_type or limit_amount is None:
|
||||
raise HTTPException(400, "缺少必要参数: rule_name, expense_type, limit_amount")
|
||||
if expense_type not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {expense_type}")
|
||||
if limit_type not in ("single", "monthly", "yearly"):
|
||||
raise HTTPException(400, f"无效限额类型: {limit_type}")
|
||||
|
||||
rule = ExpenseRule(
|
||||
rule_name=rule_name,
|
||||
dimension=data.get("dimension", "expense_type"),
|
||||
dimension_value=data.get("dimension_value", "") or None,
|
||||
expense_type=expense_type,
|
||||
limit_type=limit_type,
|
||||
limit_amount=float(limit_amount),
|
||||
cycle=data.get("cycle", limit_type),
|
||||
status=data.get("status", "active"),
|
||||
remark=data.get("remark", ""),
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"message": "规则已创建", "id": rule.id}
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
def update_rule(
|
||||
rule_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新费用规则"""
|
||||
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
if "rule_name" in data and data["rule_name"]:
|
||||
rule.rule_name = data["rule_name"]
|
||||
if "expense_type" in data:
|
||||
if data["expense_type"] not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {data['expense_type']}")
|
||||
rule.expense_type = data["expense_type"]
|
||||
if "dimension" in data:
|
||||
rule.dimension = data["dimension"]
|
||||
if "dimension_value" in data:
|
||||
rule.dimension_value = data["dimension_value"] or None
|
||||
if "limit_type" in data:
|
||||
rule.limit_type = data["limit_type"]
|
||||
if "limit_amount" in data and data["limit_amount"] is not None:
|
||||
rule.limit_amount = float(data["limit_amount"])
|
||||
if "status" in data:
|
||||
rule.status = data["status"]
|
||||
if "remark" in data:
|
||||
rule.remark = data["remark"]
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"message": "规则已更新", "id": rule.id}
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
def delete_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除费用规则"""
|
||||
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
db.delete(rule)
|
||||
db.commit()
|
||||
return {"message": "规则已删除", "id": rule_id}
|
||||
|
||||
|
||||
@router.post("/rules/seed")
|
||||
def seed_rules(
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""预置默认费用规则(幂等,按规则名去重)"""
|
||||
created = 0
|
||||
for p in PRESET_RULES:
|
||||
exists = db.query(ExpenseRule).filter(ExpenseRule.rule_name == p["rule_name"]).first()
|
||||
if exists:
|
||||
continue
|
||||
db.add(ExpenseRule(**p))
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"预置完成,新增 {created} 条规则"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报销单自动校验
|
||||
# ============================================================
|
||||
|
||||
def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list = None):
|
||||
"""按启用规则校验报销单,写入 check_result / check_detail / check_reason
|
||||
|
||||
规则匹配:
|
||||
- dimension=expense_type: 全局费用类型规则(dimension_value 可再限定具体类型)
|
||||
- dimension=department: 部门维度规则(dimension_value 空=所有部门)
|
||||
- dimension=person: 人员维度规则(dimension_value 空=所有人)
|
||||
返回 (passed: bool, checks: list)
|
||||
"""
|
||||
if rules is None:
|
||||
rules = db.query(ExpenseRule).filter(ExpenseRule.status == "active").all()
|
||||
|
||||
period = (reimb.expense_date or datetime.now()).strftime("%Y-%m")
|
||||
checks = []
|
||||
failed_reasons = []
|
||||
|
||||
for rule in rules:
|
||||
# 费用类型必须匹配
|
||||
if rule.expense_type and rule.expense_type != reimb.expense_type:
|
||||
continue
|
||||
# 维度匹配
|
||||
if rule.dimension == "department":
|
||||
if not reimb.department:
|
||||
continue
|
||||
if rule.dimension_value and rule.dimension_value != reimb.department:
|
||||
continue
|
||||
elif rule.dimension == "person":
|
||||
if rule.dimension_value and rule.dimension_value != reimb.applicant:
|
||||
continue
|
||||
elif rule.dimension == "expense_type":
|
||||
if rule.dimension_value and rule.dimension_value != reimb.expense_type:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
actual = reimb.amount
|
||||
limit_desc = f"{rule.limit_amount:,.0f}元"
|
||||
|
||||
if rule.limit_type == "single":
|
||||
passed = reimb.amount <= rule.limit_amount
|
||||
checks.append({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.rule_name,
|
||||
"rule_type": "单笔限额",
|
||||
"limit": rule.limit_amount,
|
||||
"actual": reimb.amount,
|
||||
"passed": passed,
|
||||
"detail": f"单笔 {reimb.amount:,.2f}元 vs 限额 {limit_desc}",
|
||||
})
|
||||
else:
|
||||
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
|
||||
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
|
||||
ExpenseReimbursement.expense_type == reimb.expense_type,
|
||||
ExpenseReimbursement.id != reimb.id,
|
||||
ExpenseReimbursement.status.in_(["pending", "approved"]),
|
||||
)
|
||||
if rule.limit_type == "monthly":
|
||||
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period)
|
||||
period_label = f"{period}月"
|
||||
else: # yearly
|
||||
year = period[:4]
|
||||
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y") == year)
|
||||
period_label = f"{year}年"
|
||||
if rule.dimension == "department" and reimb.department:
|
||||
q = q.filter(ExpenseReimbursement.department == reimb.department)
|
||||
elif rule.dimension == "person":
|
||||
q = q.filter(ExpenseReimbursement.applicant == reimb.applicant)
|
||||
|
||||
used = q.scalar() or 0.0
|
||||
actual = used + reimb.amount
|
||||
passed = actual <= rule.limit_amount
|
||||
checks.append({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.rule_name,
|
||||
"rule_type": "月度累计" if rule.limit_type == "monthly" else "年度累计",
|
||||
"limit": rule.limit_amount,
|
||||
"actual": actual,
|
||||
"used": used,
|
||||
"passed": passed,
|
||||
"detail": f"{period_label}累计 {used:,.2f} + 本次 {reimb.amount:,.2f} = {actual:,.2f}元 vs 限额 {limit_desc}",
|
||||
})
|
||||
|
||||
if not passed:
|
||||
failed_reasons.append(f"{rule.rule_name}: {checks[-1]['detail']},超限")
|
||||
|
||||
passed_all = len(failed_reasons) == 0
|
||||
reimb.check_result = "pass" if passed_all else "fail"
|
||||
reimb.check_detail = checks
|
||||
reimb.check_reason = ";".join(failed_reasons) if failed_reasons else None
|
||||
reimb.checked_at = datetime.now()
|
||||
# 超限自动打回,合规进入待人工审批
|
||||
reimb.status = "pending" if passed_all else "returned"
|
||||
return passed_all, checks
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报销单提交 / 查询 / 审批
|
||||
# ============================================================
|
||||
|
||||
@router.post("/reimbursements")
|
||||
def submit_reimbursement(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""提交报销单 → 自动校验规则(超限自动打回)"""
|
||||
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
|
||||
expense_type = data.get("expense_type")
|
||||
title = data.get("title")
|
||||
amount = data.get("amount")
|
||||
|
||||
if not expense_type or not title or amount is None:
|
||||
raise HTTPException(400, "缺少必要参数: expense_type, title, amount")
|
||||
if expense_type not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {expense_type}")
|
||||
amount = float(amount)
|
||||
if amount <= 0:
|
||||
raise HTTPException(400, "报销金额必须大于0")
|
||||
|
||||
expense_date = None
|
||||
if data.get("expense_date"):
|
||||
try:
|
||||
expense_date = datetime.strptime(str(data["expense_date"])[:10], "%Y-%m-%d")
|
||||
except Exception:
|
||||
expense_date = None
|
||||
|
||||
reimb = ExpenseReimbursement(
|
||||
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
|
||||
applicant=applicant,
|
||||
department=data.get("department", ""),
|
||||
expense_type=expense_type,
|
||||
title=title,
|
||||
amount=amount,
|
||||
expense_date=expense_date,
|
||||
attachment=data.get("attachment", ""),
|
||||
status="pending",
|
||||
check_result="pass",
|
||||
created_by=current_user.username if hasattr(current_user, "username") else applicant,
|
||||
)
|
||||
db.add(reimb)
|
||||
db.flush() # 先拿到 id 再生成正式单号
|
||||
reimb.reimb_no = _gen_reimb_no(db)
|
||||
db.flush()
|
||||
|
||||
passed, checks = validate_reimbursement(db, reimb)
|
||||
db.commit()
|
||||
db.refresh(reimb)
|
||||
|
||||
result = _reimb_to_dict(reimb)
|
||||
result["auto_check_passed"] = passed
|
||||
return {"message": "报销单已提交" if passed else "报销单超限,已自动打回", "data": result}
|
||||
|
||||
|
||||
@router.get("/reimbursements")
|
||||
def list_reimbursements(
|
||||
status: str = Query(None),
|
||||
expense_type: str = Query(None),
|
||||
applicant: str = Query(None),
|
||||
keyword: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
|
||||
q = db.query(ExpenseReimbursement)
|
||||
if status:
|
||||
q = q.filter(ExpenseReimbursement.status == status)
|
||||
if expense_type:
|
||||
q = q.filter(ExpenseReimbursement.expense_type == expense_type)
|
||||
if applicant:
|
||||
q = q.filter(ExpenseReimbursement.applicant.like(f"%{applicant}%"))
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
(ExpenseReimbursement.title.like(like))
|
||||
| (ExpenseReimbursement.reimb_no.like(like))
|
||||
| (ExpenseReimbursement.applicant.like(like))
|
||||
)
|
||||
items = q.order_by(ExpenseReimbursement.id.desc()).limit(200).all()
|
||||
return {"data": [_reimb_to_dict(r) for r in items], "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/reimbursements/{reimb_id}")
|
||||
def get_reimbursement(
|
||||
reimb_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""报销单详情"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
return _reimb_to_dict(r)
|
||||
|
||||
|
||||
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
|
||||
"""执行审批动作: approve/reject/return"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
if r.status not in ("pending", "returned"):
|
||||
raise HTTPException(400, f"当前状态({r.status})不可审批")
|
||||
|
||||
if action == "approve":
|
||||
r.status = "approved"
|
||||
elif action == "reject":
|
||||
r.status = "rejected"
|
||||
else:
|
||||
r.status = "returned"
|
||||
r.approver = approver
|
||||
r.approve_comment = comment or ("" if action == "approve" else "人工打回")
|
||||
r.approved_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/approve")
|
||||
def approve_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""审批通过"""
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
|
||||
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/reject")
|
||||
def reject_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""审批拒绝"""
|
||||
comment = (data or {}).get("comment", "")
|
||||
if not comment:
|
||||
raise HTTPException(400, "拒绝时必须填写审批意见")
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "reject", comment, approver)
|
||||
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/return")
|
||||
def return_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""人工打回"""
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
|
||||
return {"message": "已打回", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/resubmit")
|
||||
def resubmit_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""被驳回/打回后重新提交 → 重新自动校验"""
|
||||
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
if r.status not in ("returned", "rejected"):
|
||||
raise HTTPException(400, f"当前状态({r.status})不可重新提交")
|
||||
|
||||
# 允许修改金额/事由后重新校验
|
||||
if data:
|
||||
if data.get("amount") is not None:
|
||||
r.amount = float(data["amount"])
|
||||
if data.get("title"):
|
||||
r.title = data["title"]
|
||||
if data.get("attachment") is not None:
|
||||
r.attachment = data["attachment"]
|
||||
|
||||
passed, _ = validate_reimbursement(db, r)
|
||||
r.approver = None
|
||||
r.approve_comment = None
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return {"message": "已重新提交" if passed else "仍超限,已再次打回", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 审核看板统计
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats")
|
||||
def expense_stats(
|
||||
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
|
||||
now = datetime.now()
|
||||
period = period or now.strftime("%Y-%m")
|
||||
|
||||
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
|
||||
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by(
|
||||
ExpenseReimbursement.status
|
||||
).all():
|
||||
if st in counts:
|
||||
counts[st] = cnt
|
||||
counts["total"] += cnt
|
||||
|
||||
# 本月(按费用发生日期)金额统计 by 费用类型 — 统计已提交(待审+已通过)
|
||||
month_rows = (
|
||||
db.query(
|
||||
ExpenseReimbursement.expense_type,
|
||||
func.coalesce(func.sum(ExpenseReimbursement.amount), 0),
|
||||
func.count(ExpenseReimbursement.id),
|
||||
)
|
||||
.filter(
|
||||
ExpenseReimbursement.status.in_(["pending", "approved"]),
|
||||
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
|
||||
)
|
||||
.group_by(ExpenseReimbursement.expense_type)
|
||||
.all()
|
||||
)
|
||||
amounts_by_type = [
|
||||
{
|
||||
"expense_type": et,
|
||||
"label": EXPENSE_TYPE_LABELS.get(et, et),
|
||||
"amount": round(float(amt), 2),
|
||||
"count": cnt,
|
||||
}
|
||||
for et, amt, cnt in month_rows
|
||||
]
|
||||
|
||||
monthly_total = round(sum(x["amount"] for x in amounts_by_type), 2)
|
||||
|
||||
# 超限预警列表 — 自动打回(returned + check_result=fail)
|
||||
over_limit_rows = (
|
||||
db.query(ExpenseReimbursement)
|
||||
.filter(
|
||||
ExpenseReimbursement.status == "returned",
|
||||
ExpenseReimbursement.check_result == "fail",
|
||||
)
|
||||
.order_by(ExpenseReimbursement.id.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
over_limit = [
|
||||
{
|
||||
"id": r.id,
|
||||
"reimb_no": r.reimb_no,
|
||||
"applicant": r.applicant,
|
||||
"department": r.department,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"title": r.title,
|
||||
"amount": r.amount,
|
||||
"check_reason": r.check_reason,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in over_limit_rows
|
||||
]
|
||||
|
||||
recent_rows = db.query(ExpenseReimbursement).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
|
||||
recent = [_reimb_to_dict(r) for r in recent_rows]
|
||||
|
||||
# 本月预算使用率(招待费15万/月 管理费91万/月)
|
||||
budget_usage = []
|
||||
for et, budget in (("entertainment", 150000.0), ("management", 910000.0)):
|
||||
used = next((x["amount"] for x in amounts_by_type if x["expense_type"] == et), 0.0)
|
||||
budget_usage.append({
|
||||
"expense_type": et,
|
||||
"label": EXPENSE_TYPE_LABELS.get(et, et),
|
||||
"budget": budget,
|
||||
"used": used,
|
||||
"usage_rate": round(used / budget * 100, 1) if budget else 0,
|
||||
})
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"counts": counts,
|
||||
"amounts_by_type": amounts_by_type,
|
||||
"monthly_total": monthly_total,
|
||||
"over_limit": over_limit,
|
||||
"over_limit_count": len(over_limit),
|
||||
"recent": recent,
|
||||
"budget_usage": budget_usage,
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
"""增长质量诊断 API — 五维评分 + 诊断结论 + 跨期对比
|
||||
|
||||
数据来源:KPI字典 (kpi_definitions) + KPI实际值 (kpi_values)
|
||||
五维度:营收增长 / 利润质量 / 现金质量 / 增长效率 / 组织健康
|
||||
评分区间:0-100(>=80 好 / 60-79 中 / <60 差)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/growth-quality", tags=["增长质量诊断"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# 五维度定义
|
||||
DIMENSIONS = [
|
||||
{"key": "revenueGrowth", "name": "营收增长", "weight": 20, "icon": "📈",
|
||||
"desc": "营收增速、增长持续性"},
|
||||
{"key": "profitQuality", "name": "利润质量", "weight": 20, "icon": "💰",
|
||||
"desc": "毛利率/净利率趋势、利润与收入匹配"},
|
||||
{"key": "cashQuality", "name": "现金质量", "weight": 20, "icon": "🏦",
|
||||
"desc": "经营现金流与净利润匹配度(含金量)"},
|
||||
{"key": "growthEfficiency", "name": "增长效率", "weight": 20, "icon": "🚀",
|
||||
"desc": "获客成本、单位增长投入产出"},
|
||||
{"key": "orgHealth", "name": "组织健康", "weight": 20, "icon": "⚡",
|
||||
"desc": "人效、费用结构"},
|
||||
]
|
||||
|
||||
# 关键KPI编码 → 维度用途
|
||||
KPI_CODES = {
|
||||
"F_REVENUE": "营业收入(万元)",
|
||||
"F_NET_PROFIT": "净利润(万元)",
|
||||
"F_OP_CFLOW": "经营性现金流(万元)",
|
||||
"F_GROSS_MARGIN": "毛利率(%)",
|
||||
"F_OP_PROFIT_MARGIN": "经营利润率(%)",
|
||||
"F_COST_RATIO": "费用率(%)",
|
||||
"F_REVENUE_GROWTH": "收入增长率(%)",
|
||||
"F_FCF": "自由现金流(万元)",
|
||||
"C_NEW_CLIENTS": "新客户数",
|
||||
"C_REBATE_RATE": "渠补率(%)",
|
||||
"C_SATISFACTION": "客户满意度",
|
||||
"L_TRAINING": "培训完成率",
|
||||
"P_DELIVERY": "交付及时率",
|
||||
"F_AR_DAYS": "应收账款周转天数",
|
||||
}
|
||||
|
||||
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||
|
||||
|
||||
def _fetch_kpi_values(db: Session, entity_id: int, period: str) -> dict:
|
||||
"""拉取某实体某期间的全部KPI值 {kpi_code: actual_value}"""
|
||||
rows = (db.query(KPIDefinition.kpi_code, KPIValue.actual_value)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.actual_value.isnot(None))
|
||||
.all())
|
||||
return {code: value for code, value in rows}
|
||||
|
||||
|
||||
def _fetch_history(db: Session, entity_id: int, limit: int = 12) -> List[dict]:
|
||||
"""拉取最近 N 个期间(按月,含数据)的 KPI 值,供趋势/持续性分析"""
|
||||
periods = (db.query(KPIValue.period)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(["F_REVENUE", "F_NET_PROFIT", "F_OP_CFLOW"]))
|
||||
.distinct().all())
|
||||
plist = sorted({p[0] for p in periods}, reverse=True)
|
||||
# 只保留 YYYY-MM 格式,按时间排序(旧→新)
|
||||
months = sorted([p for p in plist if MONTH_RE.match(p)])
|
||||
hist = []
|
||||
for p in months[-limit:]:
|
||||
hist.append({"period": p, **{k: None for k in KPI_CODES}})
|
||||
if not hist:
|
||||
return []
|
||||
# 批量取数
|
||||
rows = (db.query(KPIDefinition.kpi_code, KPIValue.period, KPIValue.actual_value)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIValue.period.in_([h["period"] for h in hist]))
|
||||
.all())
|
||||
idx = {h["period"]: h for h in hist}
|
||||
for code, period, val in rows:
|
||||
if period in idx and code in idx[period]:
|
||||
idx[period][code] = val
|
||||
return hist
|
||||
|
||||
|
||||
def _prev_period(period: str) -> Optional[str]:
|
||||
"""计算上期(YYYY-MM → 上一月;其他格式 → None)"""
|
||||
m = MONTH_RE.match(period)
|
||||
if not m:
|
||||
return None
|
||||
y, mo = int(period[:4]), int(period[5:7])
|
||||
if mo == 1:
|
||||
return f"{y-1:04d}-12"
|
||||
return f"{y:04d}-{mo-1:02d}"
|
||||
|
||||
|
||||
def _yoy_period(period: str) -> Optional[str]:
|
||||
"""计算去年同期(YYYY-MM → 去年同月)"""
|
||||
m = MONTH_RE.match(period)
|
||||
if not m:
|
||||
return None
|
||||
return f"{int(period[:4])-1:04d}-{period[5:7]}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 五维度评分引擎(0-100)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _clamp(v: float, lo: float = 0.0, hi: float = 100.0) -> float:
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
def _linear(value, points: List[tuple]):
|
||||
"""分段线性插值评分: points = [(x, score), ...] 按 x 升序"""
|
||||
if value is None:
|
||||
return 50.0
|
||||
if value <= points[0][0]:
|
||||
return points[0][1]
|
||||
if value >= points[-1][0]:
|
||||
return points[-1][1]
|
||||
for (x1, s1), (x2, s2) in zip(points, points[1:]):
|
||||
if x1 <= value <= x2:
|
||||
if x2 == x1:
|
||||
return s1
|
||||
return s1 + (s2 - s1) * (value - x1) / (x2 - x1)
|
||||
return 50.0
|
||||
|
||||
|
||||
# ── 1. 营收增长:增速 + 持续性 ──
|
||||
def _score_revenue_growth(cur: dict, prev: dict, history: List[dict]) -> float:
|
||||
rev_cur = cur.get("F_REVENUE")
|
||||
rev_prev = prev.get("F_REVENUE") if prev else None
|
||||
# 优先用 KPI 直接给的收入增长率
|
||||
kpi_growth = cur.get("F_REVENUE_GROWTH")
|
||||
growth = None
|
||||
if kpi_growth is not None:
|
||||
growth = float(kpi_growth)
|
||||
elif rev_cur is not None and rev_prev:
|
||||
growth = (rev_cur - rev_prev) / rev_prev * 100 if rev_prev else None
|
||||
|
||||
score = _linear(growth, [
|
||||
(-30, 5), (-20, 15), (-10, 30), (0, 45), (5, 60), (10, 70),
|
||||
(20, 82), (30, 90), (50, 96),
|
||||
])
|
||||
# 增长持续性:近6个月中收入增长月占比
|
||||
if len(history) >= 2:
|
||||
revs = [h.get("F_REVENUE") for h in history if h.get("F_REVENUE") is not None]
|
||||
ups = 0
|
||||
for i in range(1, len(revs)):
|
||||
if revs[i] > revs[i - 1]:
|
||||
ups += 1
|
||||
persist = ups / (len(revs) - 1) if len(revs) > 1 else 0.5
|
||||
score = score * 0.7 + persist * 100 * 0.3
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 2. 利润质量:毛利率/净利率水平 + 趋势 + 收入匹配 ──
|
||||
def _score_profit_quality(cur: dict, prev: dict) -> float:
|
||||
gm = cur.get("F_GROSS_MARGIN")
|
||||
np_ = cur.get("F_NET_PROFIT")
|
||||
rev = cur.get("F_REVENUE")
|
||||
net_margin = (np_ / rev * 100) if (np_ is not None and rev) else None
|
||||
opm = cur.get("F_OP_PROFIT_MARGIN")
|
||||
|
||||
gm_score = _linear(gm, [(-10, 5), (0, 10), (10, 25), (20, 45), (30, 62),
|
||||
(40, 75), (55, 88), (70, 95)])
|
||||
nm_score = _linear(net_margin, [(-50, 0), (-20, 10), (-10, 20), (0, 35),
|
||||
(10, 60), (20, 78), (30, 90)])
|
||||
opm_score = _linear(opm, [(-20, 10), (0, 30), (10, 55), (20, 75), (35, 90)])
|
||||
|
||||
# 毛利率 40% + 净利率 40% + 经营利润率 20%
|
||||
base = gm_score * 0.4 + nm_score * 0.4 + opm_score * 0.2
|
||||
|
||||
# 利润与收入匹配:收入升但利润降 → 扣分
|
||||
if prev and rev is not None and np_ is not None:
|
||||
prev_rev = prev.get("F_REVENUE")
|
||||
prev_np = prev.get("F_NET_PROFIT")
|
||||
if prev_rev and prev_np is not None:
|
||||
rev_up = rev > prev_rev
|
||||
np_down = np_ < prev_np
|
||||
if rev_up and np_down:
|
||||
base -= 10
|
||||
elif np_down:
|
||||
base -= 5
|
||||
return round(_clamp(base), 1)
|
||||
|
||||
|
||||
# ── 3. 现金质量:含金量(OCF/净利润) + 现金流强度 ──
|
||||
def _score_cash_quality(cur: dict) -> float:
|
||||
ocf = cur.get("F_OP_CFLOW")
|
||||
np_ = cur.get("F_NET_PROFIT")
|
||||
rev = cur.get("F_REVENUE")
|
||||
fcf = cur.get("F_FCF")
|
||||
|
||||
# 含金量 = OCF / 净利润(净利润>0时)
|
||||
gold = None
|
||||
if ocf is not None and np_ is not None and np_ > 0:
|
||||
gold = ocf / np_
|
||||
# 净利润<=0:利润为负,含金量指标失效 → 低分(除非现金流强)
|
||||
gold_score = _linear(gold, [(0, 10), (0.5, 35), (0.8, 55), (1.0, 70),
|
||||
(1.2, 85), (1.5, 95)])
|
||||
if np_ is not None and np_ <= 0:
|
||||
gold_score = 15 if (ocf is None or ocf <= 0) else 35
|
||||
|
||||
# 现金流强度 = OCF / 收入
|
||||
ocf_ratio = (ocf / rev * 100) if (ocf is not None and rev) else None
|
||||
ocf_score = _linear(ocf_ratio, [(-20, 5), (0, 20), (10, 50), (20, 75),
|
||||
(30, 90), (50, 98)])
|
||||
fcf_score = _linear(fcf, [(-100, 10), (-20, 30), (0, 50), (20, 70),
|
||||
(100, 90)]) if fcf is not None else 50.0
|
||||
|
||||
score = gold_score * 0.5 + ocf_score * 0.35 + fcf_score * 0.15
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 4. 增长效率:费用率水平 + 单位增长投入产出 + 获客成本 ──
|
||||
def _score_growth_efficiency(cur: dict, prev: dict) -> float:
|
||||
cost_ratio = cur.get("F_COST_RATIO")
|
||||
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
|
||||
(55, 40), (70, 25), (90, 10)])
|
||||
|
||||
# 费用增速 vs 收入增速(用费用率变化近似)
|
||||
eff_score = 60.0
|
||||
if prev is not None and cost_ratio is not None:
|
||||
prev_cr = prev.get("F_COST_RATIO")
|
||||
if prev_cr:
|
||||
cr_change = cost_ratio - prev_cr
|
||||
eff_score = _linear(cr_change, [(-15, 95), (-5, 80), (0, 65),
|
||||
(5, 45), (15, 25), (30, 10)])
|
||||
|
||||
# 获客成本代理:收入/新客户数(越高越高效)
|
||||
rev = cur.get("F_REVENUE")
|
||||
new_clients = cur.get("C_NEW_CLIENTS")
|
||||
cac_score = 60.0
|
||||
if rev is not None and new_clients:
|
||||
per_client = rev / new_clients
|
||||
cac_score = _linear(per_client, [(0, 40), (50, 50), (200, 65),
|
||||
(500, 78), (1000, 88)])
|
||||
|
||||
score = cost_score * 0.45 + eff_score * 0.35 + cac_score * 0.2
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 5. 组织健康:费用结构 + 人效/运营质量 ──
|
||||
def _score_org_health(cur: dict) -> float:
|
||||
cost_ratio = cur.get("F_COST_RATIO")
|
||||
# 费用结构(费用率越低越健康)
|
||||
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
|
||||
(55, 40), (70, 25), (90, 10)])
|
||||
# 运营/人效质量代理:满意度、培训、交付、应收
|
||||
sat = cur.get("C_SATISFACTION")
|
||||
train = cur.get("L_TRAINING")
|
||||
deliver = cur.get("P_DELIVERY")
|
||||
ar_days = cur.get("F_AR_DAYS")
|
||||
op_vals = [v for v in [sat, train, deliver] if v is not None]
|
||||
op_score = (sum(op_vals) / len(op_vals)) if op_vals else 55.0
|
||||
ar_score = _linear(ar_days, [(15, 95), (30, 80), (45, 65), (60, 50),
|
||||
(90, 30), (120, 15)]) if ar_days is not None else 55.0
|
||||
|
||||
score = cost_score * 0.4 + op_score * 0.35 + ar_score * 0.25
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
_SCORERS = {
|
||||
"revenueGrowth": _score_revenue_growth,
|
||||
"profitQuality": _score_profit_quality,
|
||||
"cashQuality": _score_cash_quality,
|
||||
"growthEfficiency": _score_growth_efficiency,
|
||||
"orgHealth": _score_org_health,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 明细指标 + 改善建议
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _fmt(v, unit=""):
|
||||
if v is None:
|
||||
return "—"
|
||||
if isinstance(v, float) and v == int(v):
|
||||
return f"{int(v)}{unit}"
|
||||
return f"{round(v, 2)}{unit}"
|
||||
|
||||
|
||||
def _dim_indicators(dim_key: str, cur: dict, prev: dict) -> List[dict]:
|
||||
"""维度明细指标(label/value/verdict/status)"""
|
||||
inds = []
|
||||
def add(label, value, verdict, status):
|
||||
inds.append({"label": label, "value": value, "verdict": verdict, "status": status})
|
||||
|
||||
if dim_key == "revenueGrowth":
|
||||
rev, prev_rev = cur.get("F_REVENUE"), (prev or {}).get("F_REVENUE")
|
||||
growth = None
|
||||
if rev is not None and prev_rev:
|
||||
growth = (rev - prev_rev) / prev_rev * 100
|
||||
add("营业收入", _fmt(rev, "万"), "环比" + (_fmt(growth, "%") if growth is not None else "无上期数据"),
|
||||
"success" if (growth or 0) >= 0 else "danger")
|
||||
add("收入增长率(KPI)", _fmt(cur.get("F_REVENUE_GROWTH"), "%"),
|
||||
"KPI直接值" if cur.get("F_REVENUE_GROWTH") is not None else "未录入",
|
||||
"success" if (cur.get("F_REVENUE_GROWTH") or 0) >= 10 else "warning")
|
||||
elif dim_key == "profitQuality":
|
||||
rev, np_ = cur.get("F_REVENUE"), cur.get("F_NET_PROFIT")
|
||||
nm = (np_ / rev * 100) if (np_ is not None and rev) else None
|
||||
add("毛利率", _fmt(cur.get("F_GROSS_MARGIN"), "%"),
|
||||
"毛利健康" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "毛利偏低",
|
||||
"success" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "danger")
|
||||
add("净利率", _fmt(nm, "%"),
|
||||
"盈利" if (nm or 0) > 0 else "亏损",
|
||||
"success" if (nm or 0) > 10 else "danger")
|
||||
add("经营利润率", _fmt(cur.get("F_OP_PROFIT_MARGIN"), "%"),
|
||||
"正常" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "偏低",
|
||||
"success" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "warning")
|
||||
elif dim_key == "cashQuality":
|
||||
ocf, np_ = cur.get("F_OP_CFLOW"), cur.get("F_NET_PROFIT")
|
||||
gold = (ocf / np_) if (ocf is not None and np_ and np_ > 0) else None
|
||||
add("经营现金流", _fmt(ocf, "万"),
|
||||
"现金流入" if (ocf or 0) > 0 else "现金流出",
|
||||
"success" if (ocf or 0) > 0 else "danger")
|
||||
add("含金量(OCF/净利润)", _fmt(gold, "倍"),
|
||||
"含金量高" if (gold or 0) >= 1 else ("利润为负" if (np_ or 0) <= 0 else "含金量低"),
|
||||
"success" if (gold or 0) >= 1 else "danger")
|
||||
add("自由现金流", _fmt(cur.get("F_FCF"), "万"),
|
||||
"正常" if (cur.get("F_FCF") or 0) > 0 else "为负",
|
||||
"success" if (cur.get("F_FCF") or 0) > 0 else "warning")
|
||||
elif dim_key == "growthEfficiency":
|
||||
rev, nc = cur.get("F_REVENUE"), cur.get("C_NEW_CLIENTS")
|
||||
per = (rev / nc) if (rev is not None and nc) else None
|
||||
add("费用率", _fmt(cur.get("F_COST_RATIO"), "%"),
|
||||
"费用可控" if (cur.get("F_COST_RATIO") or 0) <= 30 else "费用偏高",
|
||||
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
|
||||
add("单位客户营收(万/户)", _fmt(per),
|
||||
"获客效率高" if (per or 0) >= 200 else "获客效率一般",
|
||||
"success" if (per or 0) >= 500 else "warning")
|
||||
add("渠补率", _fmt(cur.get("C_REBATE_RATE"), "%"),
|
||||
"渠道依赖" if (cur.get("C_REBATE_RATE") or 0) > 50 else "渠道健康",
|
||||
"danger" if (cur.get("C_REBATE_RATE") or 0) > 50 else "success")
|
||||
elif dim_key == "orgHealth":
|
||||
add("费用率(结构)", _fmt(cur.get("F_COST_RATIO"), "%"),
|
||||
"结构健康" if (cur.get("F_COST_RATIO") or 0) <= 30 else "结构偏重",
|
||||
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
|
||||
add("应收周转天数", _fmt(cur.get("F_AR_DAYS"), "天"),
|
||||
"回款快" if (cur.get("F_AR_DAYS") or 0) <= 45 else "回款偏慢",
|
||||
"success" if (cur.get("F_AR_DAYS") or 0) <= 45 else "warning")
|
||||
add("客户满意度", _fmt(cur.get("C_SATISFACTION")),
|
||||
"满意" if (cur.get("C_SATISFACTION") or 0) >= 80 else "待提升",
|
||||
"success" if (cur.get("C_SATISFACTION") or 0) >= 80 else "warning")
|
||||
add("培训完成率", _fmt(cur.get("L_TRAINING"), "%"),
|
||||
"学习投入足" if (cur.get("L_TRAINING") or 0) >= 80 else "学习投入不足",
|
||||
"success" if (cur.get("L_TRAINING") or 0) >= 80 else "warning")
|
||||
return inds
|
||||
|
||||
|
||||
def _dim_suggestions(dim_key: str, score: float, cur: dict) -> List[str]:
|
||||
"""按维度评分生成改善建议"""
|
||||
if score >= 80:
|
||||
return ["该维度表现良好,建议保持并固化为标准流程"]
|
||||
sug = []
|
||||
if dim_key == "revenueGrowth":
|
||||
sug = ["挖掘存量客户复购,稳定收入基本盘",
|
||||
"拓展新渠道/新产品线,提升营收增速",
|
||||
"跟踪F_REVENUE_GROWTH KPI按月更新,建立增长预警线"]
|
||||
elif dim_key == "profitQuality":
|
||||
sug = ["排查毛利率下滑原因(成本/价格/渠补),优先止血",
|
||||
"控制费用增速不超过收入增速,改善净利率",
|
||||
"对亏损产品线做盈亏平衡分析,必要时收缩"]
|
||||
elif dim_key == "cashQuality":
|
||||
sug = ["加强应收账款催收,缩短回款周期",
|
||||
"压缩非必要开支,提升经营现金流净额",
|
||||
"建立现金流月度滚动预测,防范断流风险"]
|
||||
elif dim_key == "growthEfficiency":
|
||||
sug = ["优化费用结构,降低费用率至30%以下",
|
||||
"评估渠道返利政策,降低渠补率与渠道依赖",
|
||||
"提高获客转化率,降低单位获客成本"]
|
||||
elif dim_key == "orgHealth":
|
||||
sug = ["精简组织与费用结构,提升人效",
|
||||
"强化培训与人才梯队建设(盯L_TRAINING)",
|
||||
"优化应收管理,缩短周转天数"]
|
||||
return sug
|
||||
|
||||
|
||||
def _level_of(overall: float) -> dict:
|
||||
if overall >= 80:
|
||||
return {"level": "好", "level_type": "success", "desc": "增长质量优秀,增长可持续"}
|
||||
if overall >= 60:
|
||||
return {"level": "中", "level_type": "warning", "desc": "增长质量中等,存在优化空间"}
|
||||
return {"level": "差", "level_type": "danger", "desc": "增长质量堪忧,需立即干预"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 诊断主流程
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _diagnose(db: Session, entity_id: int, period: str, history: List[dict]):
|
||||
"""对单个期间执行五维诊断,返回完整诊断对象"""
|
||||
cur = _fetch_kpi_values(db, entity_id, period)
|
||||
prev_period = _prev_period(period)
|
||||
prev = _fetch_kpi_values(db, entity_id, prev_period) if prev_period else {}
|
||||
|
||||
scores = {}
|
||||
dims_payload = {}
|
||||
for dim in DIMENSIONS:
|
||||
key = dim["key"]
|
||||
scorer = _SCORERS[key]
|
||||
if key == "revenueGrowth":
|
||||
s = scorer(cur, prev, history)
|
||||
elif key in ("cashQuality", "orgHealth"):
|
||||
s = scorer(cur)
|
||||
else:
|
||||
s = scorer(cur, prev)
|
||||
scores[key] = s
|
||||
dims_payload[key] = {
|
||||
"key": key, "name": dim["name"], "icon": dim["icon"],
|
||||
"desc": dim["desc"], "weight": dim["weight"],
|
||||
"score": s,
|
||||
"indicators": _dim_indicators(key, cur, prev),
|
||||
"suggestions": _dim_suggestions(key, s, cur),
|
||||
}
|
||||
|
||||
overall = round(sum(scores.values()) / len(scores), 1)
|
||||
level = _level_of(overall)
|
||||
|
||||
# 诊断结论文本
|
||||
low_dims = [d for d in DIMENSIONS if scores[d["key"]] < 60]
|
||||
mid_dims = [d for d in DIMENSIONS if 60 <= scores[d["key"]] < 80]
|
||||
lines = [f"{period} 综合增长质量评分 {overall} 分({level['level']}):{level['desc']}。"]
|
||||
if low_dims:
|
||||
lines.append("需重点关注:" + "、".join(f"{d['name']}({scores[d['key']]}分)" for d in low_dims) + "。")
|
||||
if mid_dims:
|
||||
lines.append("可优化:" + "、".join(f"{d['name']}({scores[d['key']]}分)" for d in mid_dims) + "。")
|
||||
if not low_dims:
|
||||
lines.append("各维度均处于健康区间,增长质量扎实。")
|
||||
diagnosis = "".join(lines)
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"period": period,
|
||||
"overall": overall,
|
||||
"level": level["level"],
|
||||
"level_type": level["level_type"],
|
||||
"diagnosis": diagnosis,
|
||||
"dimensions": dims_payload,
|
||||
"kpi_available": {k: cur.get(k) is not None for k in KPI_CODES},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/periods")
|
||||
def list_periods(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
|
||||
"""列出某实体有KPI数据的期间(按月,含数据覆盖度,用于前端默认期间选择)"""
|
||||
rows = (db.query(KPIValue.period, KPIValue.kpi_id)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.all())
|
||||
counts: dict = {}
|
||||
for period, kpi_id in rows:
|
||||
if MONTH_RE.match(period or ""):
|
||||
counts[period] = counts.get(period, 0) + 1
|
||||
periods = sorted(counts.keys(), reverse=True)
|
||||
return {"entity_id": entity_id, "periods": periods,
|
||||
"coverage": {p: counts[p] for p in periods}}
|
||||
|
||||
|
||||
@router.get("/diagnosis")
|
||||
def growth_quality_diagnosis(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM,默认最近有数据期间"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""增长质量诊断 — 五维评分(0-100) + 诊断结论 + 跨期对比(本期/上期/去年同期)"""
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not ent:
|
||||
raise HTTPException(404, f"实体 {entity_id} 不存在")
|
||||
|
||||
history = _fetch_history(db, entity_id, limit=12)
|
||||
if not history:
|
||||
raise HTTPException(400, "该实体暂无月度KPI数据,请先录入KPI实际值")
|
||||
|
||||
# 默认取最近且有足够数据覆盖的期间(>=5个KPI值,退化为最近一个)
|
||||
if not period:
|
||||
cov_rows = (db.query(KPIValue.period)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.all())
|
||||
cov: dict = {}
|
||||
for (p,) in cov_rows:
|
||||
if MONTH_RE.match(p or ""):
|
||||
cov[p] = cov.get(p, 0) + 1
|
||||
candidates = sorted([p for p in cov if cov[p] >= 5], reverse=True)
|
||||
period = candidates[0] if candidates else history[-1]["period"]
|
||||
|
||||
current = _diagnose(db, entity_id, period, history)
|
||||
|
||||
# 跨期对比:上期 + 去年同期
|
||||
prev_p = _prev_period(period)
|
||||
yoy_p = _yoy_period(period)
|
||||
prev_data = _fetch_kpi_values(db, entity_id, prev_p) if prev_p else {}
|
||||
yoy_data = _fetch_kpi_values(db, entity_id, yoy_p) if yoy_p else {}
|
||||
|
||||
comparison = {
|
||||
"current": {
|
||||
"period": period, "overall": current["overall"],
|
||||
"level": current["level"], "level_type": current["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in current["dimensions"].items()},
|
||||
},
|
||||
}
|
||||
if prev_p and prev_data:
|
||||
pdiag = _diagnose(db, entity_id, prev_p, history)
|
||||
comparison["previous"] = {
|
||||
"period": prev_p, "overall": pdiag["overall"],
|
||||
"level": pdiag["level"], "level_type": pdiag["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in pdiag["dimensions"].items()},
|
||||
}
|
||||
if yoy_p and yoy_data:
|
||||
ydiag = _diagnose(db, entity_id, yoy_p, history)
|
||||
comparison["yoy"] = {
|
||||
"period": yoy_p, "overall": ydiag["overall"],
|
||||
"level": ydiag["level"], "level_type": ydiag["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in ydiag["dimensions"].items()},
|
||||
}
|
||||
|
||||
# 趋势:近12个月综合评分
|
||||
trend = []
|
||||
for h in history:
|
||||
try:
|
||||
d = _diagnose(db, entity_id, h["period"], history)
|
||||
trend.append({"period": h["period"], "overall": d["overall"]})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"entity": {"id": ent.id, "name": ent.name, "short_name": ent.short_name},
|
||||
"period": period,
|
||||
"overall": current["overall"],
|
||||
"level": current["level"],
|
||||
"level_type": current["level_type"],
|
||||
"diagnosis": current["diagnosis"],
|
||||
"dimensions": current["dimensions"],
|
||||
"comparison": comparison,
|
||||
"trend": trend,
|
||||
"kpi_available": current["kpi_available"],
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"""知识摘要 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)}")
|
||||
|
||||
|
||||
# ── CMA P1+P2 Round 3: 职业道德自测题 ──
|
||||
|
||||
ETHICS_QUIZ_DATA = [
|
||||
{
|
||||
"id": 1,
|
||||
"scenario": '你发现公司财务数据异常(现金比率仅0.6%),但总经理说"先别声张,等融资款到账再说",你应该:',
|
||||
"options": [
|
||||
{"id": "a", "text": "听从老板,暂时隐瞒", "correct": False, "feedback": "违反IMA诚信准则。财务人员有义务如实记录和报告"},
|
||||
{"id": "b", "text": "如实记录异常并向上级或审计委员会报告", "correct": True, "feedback": "诚信+客观准则。IMA要求财务人员如实反映财务状况"},
|
||||
{"id": "c", "text": "立即辞职,一走了之", "correct": False, "feedback": "可行但非最优解。应先通过正规渠道报告问题"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"scenario": "竞争对手公司的高管在行业会议上向你打探你们公司的成本数据,你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "告知对方,反正行业数据都差不多", "correct": False, "feedback": "违反保密准则。成本数据属于公司机密"},
|
||||
{"id": "b", "text": "断然拒绝,并告知这是公司机密", "correct": True, "feedback": "保密准则。财务人员必须保护公司机密信息"},
|
||||
{"id": "c", "text": "用编造的假数据糊弄对方", "correct": False, "feedback": "违反诚信准则。即使对竞争对手也不应提供虚假信息"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"scenario": "你被要求分析一项复杂的金融衍生品对公司的影响,但你没有相关专业知识经验,应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "凭感觉估算一个数值交差", "correct": False, "feedback": "违反胜任准则。不能在自己不擅长的领域出具专业意见"},
|
||||
{"id": "b", "text": "如实告知自己缺乏经验,建议聘请外部专家", "correct": True, "feedback": "胜任准则。IMA要求持续学习并在能力范围内执业"},
|
||||
{"id": "c", "text": "花一个晚上学习后立即出报告", "correct": False, "feedback": "短期内突击学习难以达到专业标准,建议请专家"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"scenario": "供应商送你价值5000元的高档礼品,并暗示希望在招标中获得倾斜,你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "收下礼物,在招标中适当照顾", "correct": False, "feedback": "违反客观准则。礼品影响职业判断的独立性"},
|
||||
{"id": "b", "text": "拒收礼品并报告公司合规部门", "correct": True, "feedback": "客观准则。财务人员必须保持独立性和客观判断"},
|
||||
{"id": "c", "text": "收下但不做任何特殊照顾", "correct": False, "feedback": "即使不特殊照顾,收礼本身已违反公司廉洁规定"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"scenario": '销售总监要求你将下季度已签约但未发货的订单提前确认为本季度收入,以"完成业绩指标",你应该:',
|
||||
"options": [
|
||||
{"id": "a", "text": "照做,反正迟早会确认", "correct": False, "feedback": "违反诚信准则。收入确认必须符合会计准则的确认条件"},
|
||||
{"id": "b", "text": "拒绝并解释收入确认应遵循权责发生制", "correct": True, "feedback": "诚信+客观准则。IMA要求如实反映交易实质"},
|
||||
{"id": "c", "text": "折中处理,先确认一半", "correct": False, "feedback": "部分确认依然违反会计准则和IMA诚信准则"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"scenario": "你的亲戚是股票投资者,向你打听公司即将发布的季度盈利数据(尚未公开),你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "暗示性地透露一点,让亲戚自己去判断", "correct": False, "feedback": "违反保密准则。内幕信息在任何情况下都不能泄露"},
|
||||
{"id": "b", "text": "明确拒绝并告知这是未公开的内部信息", "correct": True, "feedback": "保密准则。IMA要求保护信息机密性,禁止内幕交易"},
|
||||
{"id": "c", "text": "告知所有数据需等正式发布后才能讨论", "correct": False, "feedback": "虽然拒绝了但未明确说明信息性质,应更直接拒绝"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"scenario": "你在编制税务筹划方案时,发现最新的税法已经更新,但你还没参加过相关培训,应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "按老法规继续编制,客户不会发现", "correct": False, "feedback": "违反胜任准则。必须掌握最新知识才能提供专业服务"},
|
||||
{"id": "b", "text": "先学习新税法,再基于最新法规编制方案", "correct": True, "feedback": "胜任准则。IMA要求持续提升专业能力,保持知识更新"},
|
||||
{"id": "c", "text": "请客户另请高明", "correct": False, "feedback": "回避问题不可取。应先通过学习解决问题"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"scenario": "月度经营分析会上,CEO要求你只汇报好的指标,不提及存在的风险问题,你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "听从CEO安排,只报喜不报忧", "correct": False, "feedback": "违反客观准则。选择性披露误导决策"},
|
||||
{"id": "b", "text": "完整披露所有指标,包括存在风险和问题", "correct": True, "feedback": "客观+诚信准则。IMA要求全面、公正地呈现信息"},
|
||||
{"id": "c", "text": "在会上只口头上提一下风险但不出现在书面报告里", "correct": False, "feedback": "口头提及无记录,核心问题仍需书面报告"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"scenario": "为达成KPI目标获得年终奖,部门经理要求你修改部分实际运营数据,你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "修改数据,反正金额不大", "correct": False, "feedback": "违反诚信准则。任何金额的数据造假都不可接受"},
|
||||
{"id": "b", "text": "坚决拒绝并向上级或审计委员会举报", "correct": True, "feedback": "诚信准则。诚实是财务人员的首要职业道德要求"},
|
||||
{"id": "c", "text": "假装答应但不执行", "correct": False, "feedback": "不明确的回应可能导致后续施压,应明确拒绝"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"scenario": "你准备离职去竞争对手公司,离职前应该如何处理手头的公司财务数据文件?",
|
||||
"options": [
|
||||
{"id": "a", "text": "拷贝一份带走,作为新公司的参考", "correct": False, "feedback": "违反保密准则。离职不得带走公司机密数据"},
|
||||
{"id": "b", "text": "全部归还公司,并确认不保留任何副本", "correct": True, "feedback": "保密准则。离职后仍须承担保密义务"},
|
||||
{"id": "c", "text": "只带走非敏感性的一般统计资料", "correct": False, "feedback": "任何公司未公开数据均不得外传"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/ethics-quiz")
|
||||
def get_ethics_quiz():
|
||||
"""返回10道IMA职业道德情景自测题"""
|
||||
return ETHICS_QUIZ_DATA
|
||||
@@ -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,428 @@
|
||||
"""KPI因果链建模 — 任务2
|
||||
KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
||||
from app.services.causality_verification import (
|
||||
ALL_STATUSES,
|
||||
ALL_SOURCE_TYPES,
|
||||
STATUS_PENDING,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kpi-causality")
|
||||
|
||||
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), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)"""
|
||||
edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
|
||||
node_ids = set()
|
||||
edge_list = []
|
||||
for e in edges:
|
||||
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), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在") # 跨企业不暴露存在性
|
||||
|
||||
# 上游(指向当前KPI的因果)
|
||||
upstream = db.query(KPICausality).filter(
|
||||
KPICausality.target_kpi_id == kpi_id,
|
||||
KPICausality.entity_id == entity_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,
|
||||
"source_type": c.source_type, "verify_status": c.verify_status,
|
||||
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
|
||||
"verified_by": c.verified_by,
|
||||
})
|
||||
|
||||
# 下游(当前KPI指向的因果)
|
||||
downstream = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == kpi_id,
|
||||
KPICausality.entity_id == entity_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,
|
||||
"source_type": c.source_type, "verify_status": c.verify_status,
|
||||
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
|
||||
"verified_by": c.verified_by,
|
||||
})
|
||||
|
||||
return {
|
||||
"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), entity_id: int = Depends(get_entity_id)):
|
||||
"""模拟推演: 修改一个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不存在")
|
||||
if source_kpi.entity_id != entity_id:
|
||||
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,
|
||||
KPICausality.entity_id == entity_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,
|
||||
verify_status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选)"""
|
||||
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
|
||||
if source_kpi_id:
|
||||
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
|
||||
if target_kpi_id:
|
||||
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
|
||||
if verify_status:
|
||||
query = query.filter(KPICausality.verify_status == verify_status)
|
||||
items = query.order_by(KPICausality.id).all()
|
||||
|
||||
result = []
|
||||
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("/verify-status")
|
||||
def get_verify_status(
|
||||
verify_status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""验证状态总览 — 按状态统计 + 链列表(2026-08-27 三层验证机制)
|
||||
|
||||
可选 ?verify_status=pending/data_verified/human_verified/disputed 筛选
|
||||
"""
|
||||
if verify_status and verify_status not in ALL_STATUSES:
|
||||
raise HTTPException(400, f"verify_status 必须为 {'/'.join(ALL_STATUSES)}")
|
||||
|
||||
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
|
||||
if verify_status:
|
||||
query = query.filter(KPICausality.verify_status == verify_status)
|
||||
items = query.order_by(KPICausality.id).all()
|
||||
|
||||
by_status = {s: 0 for s in ALL_STATUSES}
|
||||
data = []
|
||||
for c in items:
|
||||
by_status[c.verify_status] = by_status.get(c.verify_status, 0) + 1
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
d["source_kpi_code"] = src.kpi_code if src else None
|
||||
d["source_kpi_name"] = src.kpi_name if src else None
|
||||
d["target_kpi_code"] = tgt.kpi_code if tgt else None
|
||||
d["target_kpi_name"] = tgt.kpi_name if tgt else None
|
||||
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
|
||||
data.append(d)
|
||||
|
||||
return {
|
||||
"summary": {"total": len(items), "by_status": by_status},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{causality_id}")
|
||||
def get_causality(causality_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
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_type标记来源, 2026-08-27)"""
|
||||
source_id = data.get("source_kpi_id")
|
||||
target_id = data.get("target_kpi_id")
|
||||
if not source_id or not target_id:
|
||||
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不存在")
|
||||
if src.entity_id != tgt.entity_id:
|
||||
raise HTTPException(400, "源KPI和目标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}")
|
||||
|
||||
source_type = data.get("source_type", "manual")
|
||||
if source_type not in ALL_SOURCE_TYPES:
|
||||
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
|
||||
|
||||
c = KPICausality(
|
||||
entity_id=src.entity_id,
|
||||
source_kpi_id=source_id,
|
||||
target_kpi_id=target_id,
|
||||
strength=data.get("strength", 0.5),
|
||||
lag_months=data.get("lag_months", 1),
|
||||
formula=data.get("formula"),
|
||||
direction=data.get("direction", "positive"),
|
||||
source_type=source_type,
|
||||
verify_status=STATUS_PENDING,
|
||||
)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
db.add(OperationLog(action="create", target_type="kpi_causality",
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})"))
|
||||
db.commit()
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@router.put("/{causality_id}")
|
||||
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
for field in ("strength", "lag_months", "formula", "direction", "source_type"):
|
||||
if field in data:
|
||||
if field == "source_type" and data[field] not in ALL_SOURCE_TYPES:
|
||||
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
|
||||
setattr(c, field, data[field])
|
||||
# 修改链定义后,验证状态回到待检(定义变了旧结论失效)
|
||||
if any(f in data for f in ("strength", "lag_months", "formula", "direction")):
|
||||
c.verify_status = STATUS_PENDING
|
||||
c.verified_at = None
|
||||
c.verified_by = None
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@router.put("/{causality_id}/verify")
|
||||
def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""人工确认(战略回顾会核对打标)— 2026-08-27 三层验证机制
|
||||
|
||||
Body: { verify_status: "human_verified"|"disputed", verified_by?: "任富海" }
|
||||
默认打标 human_verified(人工最终确认)。
|
||||
"""
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
|
||||
target_status = data.get("verify_status", "human_verified")
|
||||
if target_status not in ("human_verified", "disputed"):
|
||||
raise HTTPException(400, "verify_status 必须为 human_verified 或 disputed")
|
||||
verified_by = data.get("verified_by") or user.name or user.username
|
||||
|
||||
c.verify_status = target_status
|
||||
c.verified_at = datetime.now()
|
||||
c.verified_by = str(verified_by)[:50]
|
||||
db.add(OperationLog(action="verify", target_type="kpi_causality",
|
||||
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
d = _to_dict(c)
|
||||
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
|
||||
return d
|
||||
|
||||
|
||||
@router.delete("/{causality_id}")
|
||||
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if c:
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
db.delete(c)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""KPI数据治理4条规则:入库必检 + 元数据必填 + 编码规范 + 战略分级
|
||||
|
||||
规则1: 入库必检 — dimension/target_value/unit 必填(创建/更新强制拦截)
|
||||
规则2: 元数据必填 — formula/data_source/data_owner 不能为空或占位符(待补充/待指定/-)
|
||||
规则3: 编码规范 — kpi_code 必须以 F_/C_/P_/L_ 前缀开头且与 dimension 一致;禁止跨层同名
|
||||
规则4: 战略/运营分级 — kpi_level 必须是 strategic/operational
|
||||
|
||||
API:
|
||||
- POST /api/cma/kpi/validate 校验单个KPI数据 → {valid, errors}
|
||||
- GET /api/cma/kpi/governance/audit 全量校验 → 按4条规则分组的不合规清单
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/kpi",
|
||||
tags=["KPI数据治理"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# 维度 → 编码前缀
|
||||
DIM_PREFIX = {"finance": "F", "customer": "C", "process": "P", "learning": "L"}
|
||||
# 历史遗留兼容前缀: EXT_ = 科目余额表导入的财务科目KPI(仅限 finance 维度)
|
||||
LEGACY_PREFIX_DIM = {"EXT": "finance", "FB": "process"}
|
||||
# 品牌级业务KPI前缀(跨维度合法,2026-08-21 治理审计适配: BH_ = 博海业务KPI系列)
|
||||
BRAND_PREFIXES = ("BH",)
|
||||
VALID_LEVELS = ("strategic", "operational")
|
||||
# 视为"未完善"的占位符值
|
||||
PLACEHOLDERS = ("待补充", "待指定", "待完善", "待定", "暂无", "TBD", "tbd", "-", "--", "N/A", "n/a")
|
||||
|
||||
# 规则2必填元数据字段
|
||||
META_FIELDS = [
|
||||
("formula", "计算公式"),
|
||||
("data_source", "数据来源"),
|
||||
("data_owner", "数据责任人"),
|
||||
("kpi_name", "KPI名称"),
|
||||
]
|
||||
|
||||
|
||||
def _clean_str(val) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, str):
|
||||
return val.strip()
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def _is_placeholder(val) -> bool:
|
||||
"""空值或占位符(待补充/待指定/- 等)视为未完善"""
|
||||
s = _clean_str(val)
|
||||
if not s:
|
||||
return True
|
||||
return s in PLACEHOLDERS
|
||||
|
||||
|
||||
def validate_kpi_payload(
|
||||
data: dict,
|
||||
db: Session = None,
|
||||
current_kpi_id: Optional[int] = None,
|
||||
is_update: bool = False,
|
||||
) -> List[dict]:
|
||||
"""校验单个KPI数据(4条规则)。
|
||||
|
||||
- data: 提交的KPI字段字典(创建或更新的载荷)
|
||||
- db: SQLAlchemy Session(用于跨层同名/编码唯一性检查,可为None)
|
||||
- current_kpi_id: 更新时传KPI自身id(避免自检误报)
|
||||
- is_update: 更新模式 — 仅校验载荷中显式出现的字段
|
||||
|
||||
返回 [{rule: int, field: str, message: str}, ...],空列表=合规。
|
||||
"""
|
||||
issues: List[dict] = []
|
||||
|
||||
def add(rule: int, field: str, message: str):
|
||||
issues.append({"rule": rule, "field": field, "message": message})
|
||||
|
||||
code = _clean_str(data.get("kpi_code"))
|
||||
dimension = _clean_str(data.get("dimension"))
|
||||
has_code = bool(code)
|
||||
|
||||
# ── 规则1: 入库必检 dimension/target_value/unit ──
|
||||
if not is_update or "dimension" in data:
|
||||
if not dimension:
|
||||
add(1, "dimension", "缺少dimension(所属维度: finance/customer/process/learning)")
|
||||
if not is_update or "target_value" in data:
|
||||
tv = data.get("target_value")
|
||||
if tv is None or (isinstance(tv, str) and _clean_str(tv) == ""):
|
||||
add(1, "target_value", "缺少target_value(目标值)")
|
||||
if not is_update or "unit" in data:
|
||||
if _is_placeholder(data.get("unit")):
|
||||
add(1, "unit", "缺少unit(单位)")
|
||||
|
||||
# ── 规则2: 元数据必填(不能为空或占位符)──
|
||||
for field, label in META_FIELDS:
|
||||
if not is_update or field in data:
|
||||
if _is_placeholder(data.get(field)):
|
||||
add(2, field, f"元数据未完善: {label}({field})不能为空或占位符(待补充/待指定/-)")
|
||||
|
||||
# ── 规则3: 编码规范 ──
|
||||
if not is_update or "kpi_code" in data:
|
||||
if not has_code:
|
||||
add(3, "kpi_code", "缺少kpi_code(KPI编码)")
|
||||
else:
|
||||
prefix = code.split("_")[0] if "_" in code else code
|
||||
if prefix not in ("F", "C", "P", "L"):
|
||||
# 兼容历史遗留 EXT_ 前缀(科目余额表导入的财务科目KPI,仅限finance维度)
|
||||
# 兼容 FB_ 前缀(财务Bot KPI,仅限process维度)与 BH_ 品牌前缀(跨维度合法)
|
||||
if not (LEGACY_PREFIX_DIM.get(prefix) and dimension == LEGACY_PREFIX_DIM[prefix]) \
|
||||
and prefix not in BRAND_PREFIXES:
|
||||
add(3, "kpi_code", f"编码前缀不符: {code} 应以F_/C_/P_/L_开头")
|
||||
elif dimension and DIM_PREFIX.get(dimension) and prefix != DIM_PREFIX[dimension]:
|
||||
expected = DIM_PREFIX[dimension]
|
||||
add(3, "kpi_code",
|
||||
f"编码前缀与维度不符: {code} 前缀{prefix}_ 与维度{dimension}(应为{expected}_)不一致")
|
||||
|
||||
# 禁止跨层同名: 同一kpi_code不能用于不同dimension
|
||||
if has_code and db is not None:
|
||||
dup = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if dup and dup.id != current_kpi_id:
|
||||
if dimension and dup.dimension and dup.dimension != dimension:
|
||||
add(3, "kpi_code",
|
||||
f"跨层同名: {code} 已用于维度{dup.dimension},不能用于维度{dimension}")
|
||||
elif not dimension:
|
||||
add(3, "kpi_code", f"编码已存在: {code} 已注册(维度{dup.dimension}),不能重复使用")
|
||||
|
||||
# ── 规则4: 战略/运营分级 ──
|
||||
if not is_update or "kpi_level" in data:
|
||||
lv = data.get("kpi_level")
|
||||
if lv is not None and lv not in VALID_LEVELS:
|
||||
add(4, "kpi_level", f"kpi_level必须是strategic或operational,当前值: {lv}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def kpi_issues_message(issues: List[dict]) -> List[str]:
|
||||
"""issue dict列表 → 纯文本错误列表"""
|
||||
return [i["message"] for i in issues]
|
||||
|
||||
|
||||
@router.post("/validate")
|
||||
def validate_kpi(
|
||||
kpi_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""校验单个KPI数据(不入库)。
|
||||
|
||||
请求体: KPI字段字典,可选 kpi_id 标识正在编辑的KPI(避免跨层同名误报)。
|
||||
返回: {"valid": bool, "errors": [str], "details": [{rule, field, message}]}
|
||||
"""
|
||||
kpi_id = kpi_data.get("kpi_id") if isinstance(kpi_data.get("kpi_id"), int) else None
|
||||
issues = validate_kpi_payload(kpi_data, db=db, current_kpi_id=kpi_id)
|
||||
return {
|
||||
"valid": len(issues) == 0,
|
||||
"errors": kpi_issues_message(issues),
|
||||
"details": issues,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/governance/audit")
|
||||
def governance_audit(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""全量校验所有活跃KPI,输出按4条规则分组的不合规清单。"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
|
||||
non_compliant = []
|
||||
by_rule: dict = {1: [], 2: [], 3: [], 4: []}
|
||||
for k in kpis:
|
||||
payload = {c.name: getattr(k, c.name) for c in k.__table__.columns}
|
||||
issues = validate_kpi_payload(payload, db=db, current_kpi_id=k.id)
|
||||
if issues:
|
||||
entry = {
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"kpi_level": k.kpi_level,
|
||||
"issues": issues,
|
||||
}
|
||||
non_compliant.append(entry)
|
||||
for i in issues:
|
||||
by_rule.setdefault(i["rule"], []).append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"field": i["field"],
|
||||
"message": i["message"],
|
||||
})
|
||||
|
||||
rule_counts = {str(r): len(items) for r, items in by_rule.items()}
|
||||
return {
|
||||
"total": len(kpis),
|
||||
"compliant": len(kpis) - len(non_compliant),
|
||||
"non_compliant_count": len(non_compliant),
|
||||
"rule_counts": rule_counts,
|
||||
"by_rule": {str(r): items for r, items in by_rule.items()},
|
||||
"non_compliant": non_compliant,
|
||||
}
|
||||
+700
-14
@@ -7,8 +7,10 @@ from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
|
||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||
|
||||
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
@@ -26,6 +28,8 @@ def list_kpis(
|
||||
keyword: Optional[str] = None,
|
||||
epic: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
kpi_level: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
@@ -35,16 +39,25 @@ 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))
|
||||
if entity_id is not None:
|
||||
query = query.filter(KPIDefinition.entity_id == entity_id)
|
||||
if kpi_level:
|
||||
query = query.filter(KPIDefinition.kpi_level == kpi_level)
|
||||
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]}
|
||||
result = {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
|
||||
if entity_id is not None:
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if ent:
|
||||
result["entity"] = {"id": ent.id, "name": ent.name, "short_name": ent.short_name}
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
@@ -89,20 +102,631 @@ def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depen
|
||||
return {"tree": tree, "total": sum(dim_counts.values())}
|
||||
|
||||
|
||||
@router.get("/{kpi_id}")
|
||||
def get_kpi(kpi_id: int, db: Session = Depends(get_db)):
|
||||
# ============================================================
|
||||
# KPI-5: 五档评分引擎(静态路由必须在动态/{kpi_id}之前)
|
||||
# ============================================================
|
||||
|
||||
REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO',
|
||||
'F_COST_RATIO', 'F_AR_DAYS', 'F_REBATE_RATE',
|
||||
'F_FACTORY_REBATE_RATE', 'F_COST_CONTROL_RATE', 'F_INV_DAYS']
|
||||
|
||||
|
||||
def _calc_five_tier_score(current_value, target_value, is_reverse=False):
|
||||
"""五档评分:1-5分(支持正反向指标)"""
|
||||
if current_value is None or target_value is None or target_value == 0:
|
||||
return None, "info"
|
||||
ratio = current_value / target_value
|
||||
if is_reverse:
|
||||
# 反向指标:实际值越低越好
|
||||
if ratio <= 0.5:
|
||||
return 5, "success" # 远低于目标→卓越
|
||||
elif ratio <= 0.8:
|
||||
return 4, "success" # 低于目标→达标
|
||||
elif ratio <= 1.0:
|
||||
return 3, "warning" # 接近目标→预警
|
||||
elif ratio <= 1.2:
|
||||
return 2, "danger" # 超过目标→危险
|
||||
else:
|
||||
return 1, "danger" # 远超目标→失效
|
||||
else:
|
||||
if ratio >= 1.2:
|
||||
return 5, "success" # 卓越
|
||||
elif ratio >= 1.0:
|
||||
return 4, "success" # 达标
|
||||
elif ratio >= 0.8:
|
||||
return 3, "warning" # 预警
|
||||
elif ratio >= 0.5:
|
||||
return 2, "danger" # 危险
|
||||
else:
|
||||
return 1, "danger" # 失效
|
||||
|
||||
|
||||
@router.get("/score")
|
||||
def get_kpi_score(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
period: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""五档评分引擎 - 返回各KPI评分和BSC四层汇总
|
||||
评分: 5卓越(≥1.2×目标) 4达标(≥目标) 3预警(≥0.8×目标) 2危险(≥0.5×目标) 1失效(<0.5×目标)
|
||||
"""
|
||||
# 获取该企业所有活跃KPI
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
if not kpis:
|
||||
return {"entity_id": entity_id, "kpis": [], "layers": {}, "overall": None}
|
||||
|
||||
# 获取企业信息
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
entity_info = {"id": ent.id, "name": ent.name, "short_name": ent.short_name} if ent else {"id": entity_id}
|
||||
|
||||
# 单个KPI评分
|
||||
kpi_scores = []
|
||||
for k in kpis:
|
||||
# 取最新实际值
|
||||
val_query = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period:
|
||||
val_query = val_query.filter(KPIValue.period == period)
|
||||
latest_val = val_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
current_val = latest_val.actual_value if latest_val else None
|
||||
score, status = _calc_five_tier_score(current_val, k.target_value, is_reverse=(k.kpi_code in REVERSE_INDICATORS))
|
||||
|
||||
kpi_scores.append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"target_value": k.target_value,
|
||||
"current_value": current_val,
|
||||
"score": score,
|
||||
"status": status,
|
||||
"unit": k.unit,
|
||||
"weight": 10,
|
||||
"period": latest_val.period if latest_val else None,
|
||||
})
|
||||
|
||||
# BSC四层汇总
|
||||
layer_map = {
|
||||
"finance": {"label": "财务", "order": 0},
|
||||
"customer": {"label": "客户", "order": 1},
|
||||
"process": {"label": "流程", "order": 2},
|
||||
"learning": {"label": "学习成长", "order": 3},
|
||||
}
|
||||
layers = {}
|
||||
total_weighted_score = 0
|
||||
total_weight = 0
|
||||
|
||||
for dim_key, dim_info in layer_map.items():
|
||||
layer_kpis = [s for s in kpi_scores if s["dimension"] == dim_key and s["score"] is not None]
|
||||
if not layer_kpis:
|
||||
layers[dim_key] = {"label": dim_info["label"], "score": None, "status": "info", "kpi_count": 0, "weighted_score": None}
|
||||
continue
|
||||
|
||||
w = sum(k["weight"] for k in layer_kpis)
|
||||
ws = sum(k["score"] * k["weight"] for k in layer_kpis)
|
||||
avg_score = ws / w if w > 0 else None
|
||||
avg_status = "success" if avg_score and avg_score >= 4 else ("warning" if avg_score and avg_score >= 3 else "danger") if avg_score else "info"
|
||||
|
||||
layers[dim_key] = {
|
||||
"label": dim_info["label"],
|
||||
"score": round(avg_score, 2) if avg_score else None,
|
||||
"status": avg_status,
|
||||
"kpi_count": len(layer_kpis),
|
||||
"weighted_score": round(avg_score, 2) if avg_score else None,
|
||||
}
|
||||
|
||||
if avg_score:
|
||||
total_weighted_score += avg_score * len(layer_kpis)
|
||||
total_weight += len(layer_kpis)
|
||||
|
||||
# 综合得分
|
||||
overall_score = round(total_weighted_score / total_weight, 2) if total_weight > 0 else None
|
||||
overall_status = "success" if overall_score and overall_score >= 4 else ("warning" if overall_score and overall_score >= 3 else "danger") if overall_score else "info"
|
||||
|
||||
return {
|
||||
"entity": entity_info,
|
||||
"kpis": kpi_scores,
|
||||
"layers": layers,
|
||||
"overall": {"score": overall_score, "status": overall_status},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# KPI-glossary: 知识资产化 — KPI字典实时加载(供ChatBI财务Bot调用)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/glossary")
|
||||
def get_kpi_glossary(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""KPI字典实时加载 — 返回所有KPI的定义、当前值、目标值、公式、维度、阈值
|
||||
|
||||
供ChatBI财务Bot在分析前调用,确保口径与系统一致。
|
||||
返回字段: kpi_code, kpi_name, current_value, target_value, formula, dimension, threshold
|
||||
"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
result = []
|
||||
for k in kpis:
|
||||
# 获取最新实际值
|
||||
latest_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
current_value = latest_val.actual_value if latest_val else None
|
||||
latest_period = latest_val.period if latest_val else None
|
||||
|
||||
# 组装阈值描述
|
||||
threshold = None
|
||||
if k.threshold_green or k.threshold_yellow or k.threshold_red:
|
||||
parts = []
|
||||
if k.threshold_green:
|
||||
parts.append(f"绿灯:{k.threshold_green}")
|
||||
if k.threshold_yellow:
|
||||
parts.append(f"黄灯:{k.threshold_yellow}")
|
||||
if k.threshold_red:
|
||||
parts.append(f"红灯:{k.threshold_red}")
|
||||
threshold = " | ".join(parts)
|
||||
|
||||
result.append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"formula": k.formula,
|
||||
"formula_desc": k.formula_desc,
|
||||
"unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"current_value": current_value,
|
||||
"latest_period": latest_period,
|
||||
"threshold": threshold,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
"responsible_user": k.responsible_user,
|
||||
"data_source": k.data_source,
|
||||
"data_owner": k.data_owner,
|
||||
"frequency": k.frequency,
|
||||
"status": k.status,
|
||||
})
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"total": len(result),
|
||||
"glossary": result,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# KPI-6: KPI三级分解树
|
||||
# ============================================================
|
||||
|
||||
@router.get("/hierarchy")
|
||||
def get_kpi_hierarchy(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
kpi_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""KPI三级分解树:公司→部门→个人"""
|
||||
query = db.query(KPIHierarchy).filter(KPIHierarchy.entity_id == entity_id)
|
||||
if kpi_id is not None:
|
||||
query = query.filter(
|
||||
(KPIHierarchy.parent_kpi_id == kpi_id) | (KPIHierarchy.child_kpi_id == kpi_id)
|
||||
)
|
||||
relations = query.order_by(KPIHierarchy.level).all()
|
||||
|
||||
if not relations:
|
||||
# 无层级数据,返回公司级KPI作为根节点
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).limit(20).all()
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"tree": [{"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "level": 1, "children": []} for k in kpis],
|
||||
"total": len(kpis),
|
||||
}
|
||||
|
||||
# 构建树
|
||||
kpi_ids = set()
|
||||
for r in relations:
|
||||
kpi_ids.add(r.parent_kpi_id)
|
||||
kpi_ids.add(r.child_kpi_id)
|
||||
|
||||
kpi_map = {}
|
||||
for kid in kpi_ids:
|
||||
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
|
||||
if k:
|
||||
kpi_map[kid] = {"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "level": None, "children": []}
|
||||
|
||||
# 分配层级
|
||||
for r in relations:
|
||||
if r.parent_kpi_id in kpi_map:
|
||||
kpi_map[r.parent_kpi_id]["level"] = 1 # 公司级
|
||||
if r.child_kpi_id in kpi_map:
|
||||
current_level = kpi_map[r.child_kpi_id].get("level")
|
||||
new_level = r.level or 2
|
||||
if current_level is None or current_level > new_level:
|
||||
kpi_map[r.child_kpi_id]["level"] = new_level
|
||||
|
||||
# 构造父子关系
|
||||
tree = []
|
||||
added = set()
|
||||
for r in relations:
|
||||
parent = kpi_map.get(r.parent_kpi_id)
|
||||
child = kpi_map.get(r.child_kpi_id)
|
||||
if parent and child:
|
||||
child_node = dict(child)
|
||||
child_node["weight"] = r.weight
|
||||
child_node["child_name"] = r.child_name
|
||||
# 避免重复添加
|
||||
child_key = r.child_kpi_id
|
||||
existing_child = next(
|
||||
(c for c in parent["children"] if c["id"] == child_key), None
|
||||
)
|
||||
if not existing_child:
|
||||
parent["children"].append(child_node)
|
||||
|
||||
# 收集顶级节点(有子节点且未被引用的parent)
|
||||
all_child_ids = {r.child_kpi_id for r in relations}
|
||||
for r in relations:
|
||||
pid = r.parent_kpi_id
|
||||
if pid not in all_child_ids or pid == (kpi_id if kpi_id else -1):
|
||||
if pid not in added and pid in kpi_map:
|
||||
tree.append(kpi_map[pid])
|
||||
added.add(pid)
|
||||
|
||||
# 如果kpi_id指定,返回该节点为根的子树
|
||||
if kpi_id is not None and kpi_id in kpi_map:
|
||||
root = kpi_map[kpi_id]
|
||||
return {"entity_id": entity_id, "tree": [root], "total": len(tree)}
|
||||
|
||||
# 否则按level排序
|
||||
tree.sort(key=lambda n: (n.get("level") or 99, n["kpi_code"]))
|
||||
|
||||
return {"entity_id": entity_id, "tree": tree, "total": len(tree)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# KPI-8: KPI因果链追踪
|
||||
# ============================================================
|
||||
|
||||
@router.get("/{kpi_id}/causality-chain")
|
||||
def get_kpi_causality_chain(
|
||||
kpi_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""KPI因果链追踪 — 返回单个KPI的上下游因果链"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
return kpi_to_dict(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,
|
||||
"dimension": src.dimension,
|
||||
"layer": src.dimension,
|
||||
"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,
|
||||
"dimension": tgt.dimension,
|
||||
"layer": tgt.dimension,
|
||||
"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,
|
||||
"layer": kpi.dimension,
|
||||
},
|
||||
"drives": downstream_list,
|
||||
"driven_by": upstream_list,
|
||||
"total_upstream": len(upstream_list),
|
||||
"total_downstream": len(downstream_list),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 动态路由(必须在静态路由之后)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{kpi_id}/values")
|
||||
def create_kpi_value(
|
||||
kpi_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""录入KPI单值(人工数据录入,用于客户/流程/学习层台账数据)
|
||||
Body: {period: '2026-08', actual_value: 85}
|
||||
"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
period = data.get("period")
|
||||
actual_value = data.get("actual_value")
|
||||
if not period or actual_value is None:
|
||||
raise HTTPException(400, "缺少必要参数: period, actual_value")
|
||||
|
||||
# 同一期间重复录入 → 更新
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.source_type == "manual",
|
||||
).first()
|
||||
if existing:
|
||||
existing.actual_value = float(actual_value)
|
||||
existing.data_status = "verified"
|
||||
existing.remark = f"人工录入(更新) by {current_user.username}"
|
||||
db.commit()
|
||||
return {"message": "已更新", "id": existing.id}
|
||||
|
||||
new_val = KPIValue(
|
||||
kpi_id=kpi_id,
|
||||
entity_id=entity_id,
|
||||
period=period,
|
||||
actual_value=float(actual_value),
|
||||
source_type="manual",
|
||||
source_batch=f"manual-{current_user.username}-{datetime.now().strftime('%Y%m%d')}",
|
||||
data_status="verified",
|
||||
remark=f"人工录入 by {current_user.username}",
|
||||
)
|
||||
db.add(new_val)
|
||||
db.commit()
|
||||
return {"message": "已录入", "id": new_val.id, "period": period, "actual_value": float(actual_value)}
|
||||
|
||||
|
||||
@router.get("/{kpi_id}/values")
|
||||
def list_kpi_values(
|
||||
kpi_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""查询KPI实际值列表(含source_type标记,供归集标签页展示)"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all()
|
||||
return {
|
||||
"data": [{
|
||||
"id": v.id,
|
||||
"period": v.period,
|
||||
"actual_value": v.actual_value,
|
||||
"source_type": v.source_type or "manual",
|
||||
"source_batch": v.source_batch or "",
|
||||
"data_status": v.data_status,
|
||||
"remark": v.remark or "",
|
||||
} for v in vals]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{kpi_id}")
|
||||
def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 账套隔离: 禁止跨企业读取 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
result = kpi_to_dict(kpi)
|
||||
# 附带历史数据(前端KPI详情"历史数据"Tab依赖)
|
||||
# 2026-08-26修复: 原实现只返回定义信息,values缺失导致历史数据Tab空白
|
||||
# 2026-08-26增强: values对齐KPI元数据(目标值/偏差/红黄绿判定/单位)
|
||||
vals = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.asc()).all()
|
||||
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
|
||||
target = kpi.target_value
|
||||
result["values"] = []
|
||||
for v in vals:
|
||||
# 判定红黄绿(对齐工作台语义:正向≥0.9绿/≥0.7黄/否则红;反向≤目标绿/≤1.1倍黄/否则红)
|
||||
level = "info"
|
||||
score = None
|
||||
if target and v.actual_value is not None:
|
||||
if is_reverse:
|
||||
if v.actual_value <= target:
|
||||
level = "green"
|
||||
elif v.actual_value <= target * 1.1:
|
||||
level = "yellow"
|
||||
else:
|
||||
level = "red"
|
||||
else:
|
||||
ratio = v.actual_value / target
|
||||
if ratio >= 0.9:
|
||||
level = "green"
|
||||
elif ratio >= 0.7:
|
||||
level = "yellow"
|
||||
else:
|
||||
level = "red"
|
||||
score, _ = _calc_five_tier_score(v.actual_value, target, is_reverse=is_reverse)
|
||||
# 偏差率(相对目标)
|
||||
deviation = None
|
||||
if target and target != 0 and v.actual_value is not None:
|
||||
deviation = round((v.actual_value - target) / target * 100, 1)
|
||||
result["values"].append({
|
||||
"id": v.id,
|
||||
"period": v.period,
|
||||
"actual_value": v.actual_value,
|
||||
"target_value": target,
|
||||
"unit": kpi.unit or "",
|
||||
"deviation_pct": deviation,
|
||||
"level": level,
|
||||
"score": score,
|
||||
"source_type": v.source_type,
|
||||
"data_status": v.data_status,
|
||||
"source_batch": v.source_batch,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] = None, is_update: bool = False):
|
||||
"""数据治理4条规则校验(入库必检+元数据+编码规范+战略分级),返回错误信息列表"""
|
||||
issues = validate_kpi_payload(data, db=db, current_kpi_id=current_kpi_id, is_update=is_update)
|
||||
return kpi_issues_message(issues)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# KPI多粒度目标:指标类型推断 + 周期目标派生(docs/kpi-design-rule.md 落地)
|
||||
# 规则:累计型 月×3=季、月×12=年(季×4=年);比率型 季/年沿用基准(可手调)
|
||||
# 派生为"虚拟展示值":DB只存用户手填真值,API返回时补派生值+derived标记
|
||||
# ════════════════════════════════════════════════════════════
|
||||
RATIO_NAME_HINTS = ['率', '比', '满意度', '周转', '时长', '周期', '天数', '指数', 'NPS', 'LTV', 'CAC',
|
||||
'份额', '集中度', '响应', '完成', '达成', '人均', '单价', '净推荐', '覆盖', '保留',
|
||||
'复购', '转介绍', '投诉', '合规', '认证', '掌握', '胜任', '认知', '采纳', '引用',
|
||||
'复用', '一致性', '准确', '间隙', '时效', '及时']
|
||||
ACCUM_NAME_HINTS = ['营收', '收入', '利润', '净利', '销售', '客户数', '新客', '新增', '产量', '销量',
|
||||
'金额', '现金流', '回款', '毛利额', '产值', '储备', '数量', '篇数', '报告产出',
|
||||
'提案', '发现数', '知识沉淀', '招待费']
|
||||
RATIO_UNIT_HINTS = ['%', '倍', '天', '分', '小时', '分钟']
|
||||
ACCUM_UNIT_HINTS = ['万元', '元', '个', '件', '人', '篇', '份', '万']
|
||||
|
||||
|
||||
def infer_calc_type(kpi_code: str = "", kpi_name: str = "", unit: str = "") -> str:
|
||||
"""推断指标类型: accumulate累计(可乘) / ratio比率(不可乘)。名称关键词优先于单位"""
|
||||
n = (kpi_name or "") + " " + (kpi_code or "")
|
||||
u = unit or ""
|
||||
if any(k in n for k in RATIO_NAME_HINTS):
|
||||
return "ratio"
|
||||
if any(k in n for k in ACCUM_NAME_HINTS):
|
||||
return "accumulate"
|
||||
if u in RATIO_UNIT_HINTS or u.startswith("小时"):
|
||||
return "ratio"
|
||||
if u in ACCUM_UNIT_HINTS:
|
||||
return "accumulate"
|
||||
return "ratio" # 兜底比率(率值不能乘,更安全)
|
||||
|
||||
|
||||
def derive_cycle_targets(kpi) -> dict:
|
||||
"""按指标类型派生月/季/年目标(虚拟值,不落库)。
|
||||
返回: {"derived": {monthly/quarterly/yearly: 显示值}, "flags": {monthly/quarterly/yearly: 是否派生}}
|
||||
"""
|
||||
calc_type = (getattr(kpi, "target_calc_type", None) or infer_calc_type(
|
||||
kpi.kpi_code or "", kpi.kpi_name or "", kpi.unit or "")).lower()
|
||||
m = kpi.target_monthly
|
||||
q = kpi.target_quarterly
|
||||
y = kpi.target_yearly
|
||||
freq = (kpi.frequency or "monthly").lower()
|
||||
|
||||
# 基准值(考核周期优先,回退 target_value)
|
||||
base = None
|
||||
if freq == "yearly":
|
||||
base = y
|
||||
elif freq in ("quarterly", "half_year"):
|
||||
base = q
|
||||
elif freq in ("monthly", "weekly"):
|
||||
base = m
|
||||
if base is None:
|
||||
base = kpi.target_value
|
||||
# 无基准值则不派生
|
||||
if base is None:
|
||||
return {"derived": {"monthly": m, "quarterly": q, "yearly": y},
|
||||
"flags": {"monthly": False, "quarterly": False, "yearly": False}}
|
||||
|
||||
dm, dq, dy = m, q, y
|
||||
fm, fq, fy = False, False, False
|
||||
if calc_type == "accumulate":
|
||||
# 锚点月值:手填月目标优先;月基准且手填月空时用 target_value 回退
|
||||
anchor_m = dm
|
||||
if anchor_m is None and base is not None and freq in ("monthly", "weekly"):
|
||||
anchor_m = base
|
||||
if anchor_m is not None:
|
||||
if dm is None:
|
||||
dm = anchor_m # target_value 回退显示为月基准
|
||||
if dq is None:
|
||||
dq, fq = anchor_m * 3, True
|
||||
if dy is None:
|
||||
dy, fy = anchor_m * 12, True
|
||||
elif dq is not None:
|
||||
# 季基准(累计型):年=季×4;月不反推(避免小数噪声)
|
||||
if dy is None:
|
||||
dy, fy = dq * 4, True
|
||||
else: # ratio:季/年沿用基准,不乘
|
||||
if dq is None:
|
||||
dq, fq = base, True
|
||||
if dy is None:
|
||||
dy, fy = base, True
|
||||
return {"derived": {"monthly": dm, "quarterly": dq, "yearly": dy},
|
||||
"flags": {"monthly": fm, "quarterly": fq, "yearly": fy}}
|
||||
|
||||
|
||||
def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict:
|
||||
"""create/update 前:未显式传 target_calc_type 时按名称/单位推断。
|
||||
infer_missing=False(update场景):仅当用户显式传了空值时推断,未传则保留DB原值"""
|
||||
if "target_calc_type" in data:
|
||||
if not data.get("target_calc_type"):
|
||||
data["target_calc_type"] = infer_calc_type(
|
||||
data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", ""))
|
||||
elif infer_missing:
|
||||
data["target_calc_type"] = infer_calc_type(
|
||||
data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", ""))
|
||||
return data
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
# 检查编码唯一性
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", "")).first()
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", ""), KPIDefinition.entity_id == entity_id).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在")
|
||||
# 数据治理校验(规则1强制拦截)
|
||||
errs = _validate_kpi_data(data, db=db, is_update=False)
|
||||
if errs:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a)
|
||||
data = apply_calc_type_inference(data)
|
||||
kpi = KPIDefinition(**data)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
@@ -112,10 +736,19 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
|
||||
|
||||
@router.put("/{kpi_id}")
|
||||
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 账套隔离: 禁止跨企业修改 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 数据治理校验(更新时只检查传了但为空的字段)
|
||||
errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True)
|
||||
if errs:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
data.pop("entity_id", None) # 禁止通过update改企业归属
|
||||
data = apply_calc_type_inference(data, infer_missing=False)
|
||||
for k, v in data.items():
|
||||
if hasattr(kpi, k) and v is not None:
|
||||
setattr(kpi, k, v)
|
||||
@@ -125,16 +758,69 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
|
||||
|
||||
|
||||
@router.delete("/{kpi_id}")
|
||||
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if kpi:
|
||||
# 账套隔离: 禁止跨企业删除 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
kpi.status = "disabled"
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.put("/{kpi_id}/restore")
|
||||
def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if kpi:
|
||||
# 账套隔离: 禁止跨企业恢复 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
kpi.status = "active"
|
||||
db.commit()
|
||||
return {"message": "已恢复"}
|
||||
|
||||
|
||||
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}
|
||||
# 多粒度目标派生:月/季/年显示值 + derived标记(虚拟,不落库)
|
||||
try:
|
||||
der = derive_cycle_targets(k)
|
||||
d["derived_targets"] = der["derived"]
|
||||
d["derived_flags"] = der["flags"]
|
||||
except Exception:
|
||||
d["derived_targets"] = {"monthly": k.target_monthly, "quarterly": k.target_quarterly, "yearly": k.target_yearly}
|
||||
d["derived_flags"] = {"monthly": False, "quarterly": False, "yearly": False}
|
||||
# 附加战略地图信息
|
||||
if k.map_id:
|
||||
from app.database import get_session_local
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""AI获客 - 客户咨询接口"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import json, os, datetime, urllib.request
|
||||
|
||||
router = APIRouter(prefix="/api/cma/lead", tags=["AI获客"])
|
||||
|
||||
class LeadRequest(BaseModel):
|
||||
name: str
|
||||
phone: str
|
||||
requirement: str = ""
|
||||
source: str = "sxbh.ltd"
|
||||
|
||||
def notify_wecom(name, phone, requirement, source):
|
||||
"""推送到企微全员群"""
|
||||
msg = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": f"## 🔔 新客户咨询\n**姓名**: {name}\n**电话**: {phone}\n**需求**: {requirement}\n**来源**: {source}\n**时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
}
|
||||
}
|
||||
try:
|
||||
data = json.dumps(msg).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8800/send",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except:
|
||||
pass # 通知失败不影响主流程
|
||||
|
||||
@router.post("")
|
||||
def create_lead(data: LeadRequest):
|
||||
"""接收客户咨询并保存"""
|
||||
record = data.model_dump()
|
||||
record["timestamp"] = datetime.datetime.now().isoformat()
|
||||
record["status"] = "new"
|
||||
|
||||
log_dir = "/root/leads"
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
with open(f"{log_dir}/leads.json", "a") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
# 异步推送企微通知
|
||||
notify_wecom(data.name, data.phone, data.requirement, data.source)
|
||||
|
||||
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
|
||||
+543
-52
@@ -2,9 +2,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import StrategicMap, OperationLog
|
||||
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
||||
@@ -15,46 +17,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"]},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -62,53 +62,112 @@ STRATEGIC_MAP_TEMPLATE = [
|
||||
# ── CRUD ────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
def list_maps(db: Session = Depends(get_db)):
|
||||
maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all()
|
||||
return {"data": [m_to_dict(m) for m in maps]}
|
||||
def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 账套隔离: 仅当前企业地图 (2026-08-25)
|
||||
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
|
||||
return {"data": [m_to_dict(m, db) for m in maps]}
|
||||
|
||||
@router.post("")
|
||||
def create_map(data: dict, db: Session = Depends(get_db)):
|
||||
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
|
||||
if "dimensions" not in data or data.get("dimensions") is None:
|
||||
data = {**data, "dimensions": [
|
||||
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", "objectives": []},
|
||||
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": []},
|
||||
{"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A", "objectives": []},
|
||||
{"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C", "objectives": []},
|
||||
]}
|
||||
if "canvas_data" not in data or data.get("canvas_data") is None:
|
||||
data = {**data, "canvas_data": {"connections": []}}
|
||||
data["entity_id"] = entity_id # 账套隔离: 强制token企业
|
||||
m = StrategicMap(**data)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m_to_dict(m)
|
||||
_sync_map_objectives(m, db)
|
||||
return m_to_dict(m, db)
|
||||
|
||||
|
||||
@router.post("/create-with-template")
|
||||
def create_map_with_template(data: dict, db: Session = Depends(get_db)):
|
||||
"""一键创建带四维度模板的战略地图"""
|
||||
def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""一键创建带四维度模板的战略地图(账套隔离: 强制token企业)"""
|
||||
m = StrategicMap(
|
||||
title=data.get("title", "新建战略地图"),
|
||||
version=data.get("version", "v1.0"),
|
||||
status="draft",
|
||||
entity_id=entity_id,
|
||||
dimensions=STRATEGIC_MAP_TEMPLATE,
|
||||
canvas_data={"connections": []},
|
||||
)
|
||||
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}")
|
||||
def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
# 账套隔离: 禁止跨企业修改
|
||||
if m.entity_id != entity_id:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
old_status = m.status
|
||||
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
|
||||
# status 允许但仅限合法值域(draft/published), 防止任意发布
|
||||
ALLOWED_MAP_FIELDS = {"title", "version", "dimensions", "canvas_data", "description", "status"}
|
||||
for k, v in data.items():
|
||||
if hasattr(m, k) and v is not None:
|
||||
if k in ALLOWED_MAP_FIELDS and hasattr(m, k) and v is not None:
|
||||
if k == "status" and v not in ("draft", "published"):
|
||||
continue
|
||||
setattr(m, k, v)
|
||||
|
||||
db.commit()
|
||||
# 同步目标到map_objectives表
|
||||
_sync_map_objectives(m, db)
|
||||
# KR完整修复(2026-08-27): 同步 objectives+krs 表
|
||||
_sync_objectives_krs(m, db)
|
||||
|
||||
# ├─ 版本管理: draft → published 时自动创建快照
|
||||
if old_status == "draft" and m.status == "published":
|
||||
_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), entity_id: int = Depends(get_entity_id)):
|
||||
"""删除战略地图(账套隔离: 禁止跨企业删除)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
if m.entity_id != entity_id:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
db.delete(m)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.post("/batch-delete")
|
||||
def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""批量删除战略地图(账套隔离: 仅本企业地图)"""
|
||||
ids = data.get("ids", [])
|
||||
if not ids:
|
||||
raise HTTPException(400, "请选择要删除的地图")
|
||||
deleted = 0
|
||||
for mid in ids:
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == mid, StrategicMap.entity_id == entity_id).first()
|
||||
if m:
|
||||
db.delete(m)
|
||||
deleted += 1
|
||||
db.commit()
|
||||
return {"message": f"已删除 {deleted} 个地图", "deleted": deleted}
|
||||
|
||||
|
||||
# ── 连线管理 ─────────────────────────────────
|
||||
@@ -127,9 +186,9 @@ def _get_connections(m: StrategicMap) -> list:
|
||||
|
||||
|
||||
@router.post("/{map_id}/connections")
|
||||
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
@@ -143,11 +202,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)
|
||||
|
||||
@@ -156,16 +213,22 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
if c.get("from") == from_id and c.get("to") == to_id:
|
||||
raise HTTPException(400, "已存在相同的连线")
|
||||
|
||||
conns.append({"from": from_id, "to": to_id, "style": "solid"})
|
||||
conns.append({
|
||||
"from": from_id,
|
||||
"to": to_id,
|
||||
"style": "solid",
|
||||
"effect": data.get("effect", "positive"),
|
||||
"label": data.get("label", ""),
|
||||
})
|
||||
m.canvas_data["connections"] = conns
|
||||
db.commit()
|
||||
return {"connections": conns}
|
||||
|
||||
|
||||
@router.delete("/{map_id}/connections")
|
||||
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""根据 from/to 删除连线"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
@@ -233,24 +296,324 @@ 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)
|
||||
dims = dims or []
|
||||
for dim in dims:
|
||||
for i, obj in enumerate(dim.get("objectives", [])):
|
||||
mo = MapObjective(
|
||||
map_id=m.id,
|
||||
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
|
||||
dimension_key=dim.get("key", ""),
|
||||
name=obj.get("name", ""),
|
||||
description=obj.get("description", ""),
|
||||
icon=obj.get("icon", "target"),
|
||||
kpis=obj.get("kpis", []),
|
||||
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)
|
||||
dims = dims or []
|
||||
# 按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:
|
||||
merged = []
|
||||
for i, o in enumerate(grouped[key]):
|
||||
# 保留原有JSON中的额外字段(targetValue/krs等)
|
||||
existing = dim["objectives"][i] if i < len(dim["objectives"]) else {}
|
||||
merged.append({
|
||||
"name": o.name,
|
||||
"description": o.description or existing.get("description", ""),
|
||||
"icon": o.icon or existing.get("icon", "target"),
|
||||
"kpis": o.kpis or existing.get("kpis", []),
|
||||
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")},
|
||||
})
|
||||
dim["objectives"] = merged
|
||||
m.dimensions = dims
|
||||
|
||||
|
||||
# ── KR完整修复(2026-08-27): 地图节点 → objectives + krs 表同步 ──
|
||||
|
||||
KR_OPERATORS = (">=", "<=", ">", "<", "=")
|
||||
|
||||
|
||||
def _parse_kr_operator_target(raw_target, operator=None):
|
||||
"""解析KR目标值字符串:
|
||||
旧格式 '≥20%'/'<=18' → ('>=', 20.0)
|
||||
新格式 operator='>=' + target=20 → ('>=', 20.0)
|
||||
"""
|
||||
if operator:
|
||||
op = str(operator).strip()
|
||||
if op == "≥":
|
||||
op = ">="
|
||||
elif op == "≤":
|
||||
op = "<="
|
||||
if op not in KR_OPERATORS:
|
||||
op = ">="
|
||||
else:
|
||||
op = ">="
|
||||
if raw_target is None:
|
||||
return op, None
|
||||
if isinstance(raw_target, (int, float)):
|
||||
return op, float(raw_target)
|
||||
s = str(raw_target).strip()
|
||||
if s == "":
|
||||
return op, None
|
||||
# 前缀符号
|
||||
for cand in KR_OPERATORS:
|
||||
if s.startswith(cand):
|
||||
op = cand
|
||||
s = s[len(cand):]
|
||||
break
|
||||
if s.startswith("≥"):
|
||||
op = ">="
|
||||
s = s[1:]
|
||||
elif s.startswith("≤"):
|
||||
op = "<="
|
||||
s = s[1:]
|
||||
# 去掉单位(%、天、小时等非数字后缀)
|
||||
num = s
|
||||
while num and not (num[0].isdigit() or num[0] in ".+-"):
|
||||
num = num[1:]
|
||||
import re
|
||||
m = re.match(r"[-+]?\d*\.?\d+", num)
|
||||
if not m:
|
||||
return op, None
|
||||
return op, float(m.group(0))
|
||||
|
||||
|
||||
def _sync_objectives_krs(m, db):
|
||||
"""保存地图时:将dimensions JSON中带krs的目标节点同步到objectives表+krs表。
|
||||
- 每个带krs的目标节点 → 确保objectives表有记录(按entity_id+name匹配,无则创建)
|
||||
- krs数组 → krs表 upsert(按title匹配;删除本目标下JSON中不存在的krs)
|
||||
- 回写 objective_id / kr_id 到节点JSON,方便前端下次直接使用
|
||||
"""
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except Exception:
|
||||
return
|
||||
dims = dims or []
|
||||
entity_id = m.entity_id if m.entity_id is not None else 1
|
||||
for dim in dims:
|
||||
dim_key = dim.get("key", "")
|
||||
for obj in dim.get("objectives", []):
|
||||
krs = obj.get("krs") or []
|
||||
if not krs:
|
||||
continue
|
||||
name = (obj.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
# 1. 确保 Objective 记录存在
|
||||
objective = db.query(Objective).filter(
|
||||
Objective.entity_id == entity_id,
|
||||
Objective.title == name,
|
||||
).first()
|
||||
if not objective:
|
||||
objective = Objective(
|
||||
entity_id=entity_id,
|
||||
title=name,
|
||||
description=obj.get("description") or "",
|
||||
dimension=dim_key,
|
||||
quarter=_map_quarter(m),
|
||||
owner=obj.get("owner") or "",
|
||||
status="active",
|
||||
progress=0,
|
||||
)
|
||||
db.add(objective)
|
||||
db.flush() # 拿到 id
|
||||
obj["_objective_id"] = objective.id
|
||||
# 2. upsert krs 表
|
||||
existing_krs = {kr.title: kr for kr in db.query(KR).filter(KR.objective_id == objective.id).all()}
|
||||
keep_ids = set()
|
||||
for i, kr_item in enumerate(krs):
|
||||
if not isinstance(kr_item, dict):
|
||||
continue
|
||||
title = (kr_item.get("title") or kr_item.get("name") or "").strip()
|
||||
if not title:
|
||||
# 跳过空KR(前端允许空白行,不入库)
|
||||
continue
|
||||
op, tgt = _parse_kr_operator_target(
|
||||
kr_item.get("target_value"), kr_item.get("operator"))
|
||||
kpi_id = kr_item.get("metric_kpi_id")
|
||||
if not kpi_id and kr_item.get("kpi_code"):
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code == kr_item["kpi_code"],
|
||||
).first()
|
||||
kpi_id = kpi.id if kpi else None
|
||||
# 关联KPI时自动继承方向(KPI阈值如 '>=800')
|
||||
if kpi_id and not kr_item.get("operator"):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if kpi and kpi.threshold_green:
|
||||
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
|
||||
op, tgt = op2, (tgt2 if tgt is None else tgt)
|
||||
kr = existing_krs.get(title)
|
||||
if kr is None:
|
||||
kr = KR(
|
||||
entity_id=entity_id,
|
||||
objective_id=objective.id,
|
||||
title=title,
|
||||
)
|
||||
db.add(kr)
|
||||
kr.metric_kpi_id = kpi_id
|
||||
kr.operator = op
|
||||
if kr_item.get("tolerance") is not None:
|
||||
kr.tolerance = kr_item.get("tolerance")
|
||||
if kr_item.get("weight") is not None:
|
||||
kr.weight = kr_item.get("weight")
|
||||
if tgt is not None:
|
||||
kr.target_value = tgt
|
||||
if kr_item.get("current_value") is not None:
|
||||
kr.current_value = kr_item.get("current_value")
|
||||
if kr_item.get("due_date"):
|
||||
kr.due_date = _parse_kr_date(kr_item.get("due_date"))
|
||||
kr.sort_order = i + 1
|
||||
keep_ids.add(kr.id)
|
||||
kr_item["_kr_id"] = kr.id
|
||||
# 3. 删除JSON中不存在的krs(空标题的除外——它从未入库)
|
||||
for title, kr in existing_krs.items():
|
||||
if kr.id not in keep_ids:
|
||||
db.delete(kr)
|
||||
db.commit()
|
||||
# 回写dimensions(含_objective_id/_kr_id)
|
||||
m.dimensions = dims
|
||||
db.commit()
|
||||
|
||||
|
||||
def _map_quarter(m) -> str:
|
||||
"""从地图标题提取季度标识(如 2026Q3),无则空字符串"""
|
||||
title = m.title or ""
|
||||
import re
|
||||
m_q = re.search(r"(20\d{2})\s*[Qq](\d)", title)
|
||||
if m_q:
|
||||
return f"{m_q.group(1)}Q{m_q.group(2)}"
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_kr_date(v):
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
try:
|
||||
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ── 战略回顾会 聚合接口 ──────────────────────
|
||||
|
||||
|
||||
@router.get("/{map_id}/krs")
|
||||
def get_map_krs(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""战略回顾会 KR进度: 从krs表读取地图各目标的KR (KR修复 2026-08-27)
|
||||
|
||||
地图dimensions JSON中每个带krs的目标节点已同步到objectives+krs表,
|
||||
此处按 节点._objective_id(或 entity+name 回查)取KR。
|
||||
"""
|
||||
from app.models import KPIDefinition, KR, Objective
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except Exception:
|
||||
dims = []
|
||||
dims = dims or []
|
||||
|
||||
obj_ids = []
|
||||
for dim in dims:
|
||||
for obj in dim.get("objectives", []):
|
||||
oid = obj.get("_objective_id")
|
||||
if oid:
|
||||
obj_ids.append(int(oid))
|
||||
continue
|
||||
# 兼容无回写字段的旧节点: 按 entity+name 回查
|
||||
name = (obj.get("name") or "").strip()
|
||||
if name:
|
||||
o = db.query(Objective).filter(
|
||||
Objective.entity_id == entity_id,
|
||||
Objective.title == name,
|
||||
).first()
|
||||
if o:
|
||||
obj_ids.append(o.id)
|
||||
|
||||
result = []
|
||||
if obj_ids:
|
||||
krs = db.query(KR).filter(
|
||||
KR.entity_id == entity_id,
|
||||
KR.objective_id.in_(obj_ids),
|
||||
).order_by(KR.objective_id, KR.sort_order, KR.id).all()
|
||||
for kr in krs:
|
||||
mkpi = None
|
||||
if kr.metric_kpi_id:
|
||||
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
|
||||
result.append({
|
||||
"id": kr.id,
|
||||
"objective_id": kr.objective_id,
|
||||
"title": kr.title,
|
||||
"metric_kpi_id": kr.metric_kpi_id,
|
||||
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||
"operator": _parse_kr_operator_target(kr.operator)[0] if hasattr(kr, "operator") and kr.operator else ">=",
|
||||
"weight": float(kr.weight) if kr.weight is not None else None,
|
||||
"target_value": float(kr.target_value) if kr.target_value is not None else None,
|
||||
"current_value": float(kr.current_value) if kr.current_value is not None else None,
|
||||
"progress": kr.progress or 0,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||||
})
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@router.get("/{map_id}/review")
|
||||
def get_map_review(map_id: int, db: Session = Depends(get_db)):
|
||||
"""战略回顾会:返回目标状态、KPI值、改善行动"""
|
||||
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""战略回顾会:返回目标状态、KPI值、改善行动
|
||||
|
||||
level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI;
|
||||
不传则返回全部KPI(向后兼容)。
|
||||
"""
|
||||
from app.models import KPIDefinition, KPIValue, ActionPlan
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
dims = json.loads(dims)
|
||||
dims = dims or [] # NULL容错
|
||||
|
||||
# 收集所有KPI code
|
||||
all_kpi_codes = set()
|
||||
@@ -259,9 +622,10 @@ def get_map_review(map_id: int, db: Session = Depends(get_db)):
|
||||
for code in obj.get("kpis", []):
|
||||
all_kpi_codes.add(code)
|
||||
|
||||
# 查询KPI定义
|
||||
# 查询KPI定义(账套隔离: 同编码跨企业会混入, OpenCode审查#10)
|
||||
kpi_defs = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False
|
||||
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all() if all_kpi_codes else []
|
||||
kpi_map = {k.kpi_code: k for k in kpi_defs}
|
||||
|
||||
@@ -322,16 +686,29 @@ def get_map_review(map_id: int, db: Session = Depends(get_db)):
|
||||
kpi_def = kpi_map.get(code)
|
||||
if not kpi_def:
|
||||
continue
|
||||
# 规则4: 战略地图默认只显示strategic级KPI(level过滤)
|
||||
if level and kpi_def.kpi_level != level:
|
||||
continue
|
||||
lv = latest_values.get(kpi_def.id, {})
|
||||
actual = lv.get("actual_value")
|
||||
target = kpi_def.target_value
|
||||
# 判断红黄绿灯
|
||||
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
|
||||
target = kpi_def.target_monthly or kpi_def.target_value
|
||||
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
||||
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
|
||||
level = "gray"
|
||||
if actual is not None and target:
|
||||
ratio = actual / target
|
||||
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
|
||||
lower_better = any(k in name for k in
|
||||
("费用率", "成本率", "损耗", "逾期", "投诉", "流失", "负债率", "返修", "缺勤"))
|
||||
if lower_better:
|
||||
ratio = target / actual if actual else 0 # 实际越低越好: 目标/实际
|
||||
else:
|
||||
ratio = actual / target
|
||||
if ratio < 0:
|
||||
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
|
||||
if ratio >= 0.9:
|
||||
level = "green"
|
||||
elif ratio >= 0.7:
|
||||
elif ratio >= 0.6:
|
||||
level = "yellow"
|
||||
else:
|
||||
level = "red"
|
||||
@@ -359,6 +736,13 @@ def get_map_review(map_id: int, db: Session = Depends(get_db)):
|
||||
"kpis": obj_kpis,
|
||||
"level": worst_level,
|
||||
"has_data": len(obj_kpis) > 0,
|
||||
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
|
||||
"action_plan_summary": {
|
||||
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
|
||||
"pending": len([p for p in action_plans_data
|
||||
if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]
|
||||
and p["status"] not in ("done", "completed")]),
|
||||
} if obj_kpis else {"total": 0, "pending": 0},
|
||||
}
|
||||
objectives.append(obj_item)
|
||||
|
||||
@@ -401,3 +785,110 @@ def get_map_review(map_id: int, db: Session = Depends(get_db)):
|
||||
"action_plans": action_plans_data,
|
||||
}
|
||||
|
||||
|
||||
# ── 复盘记录持久化(大PDCA Act闭环 2026-08-27)────────────────
|
||||
def review_to_dict(r: ReviewRecord) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"entity_id": r.entity_id,
|
||||
"map_id": r.map_id,
|
||||
"review_type": r.review_type,
|
||||
"review_date": r.review_date.isoformat() if r.review_date else None,
|
||||
"summary": r.summary,
|
||||
"adjustments": r.adjustments,
|
||||
"next_priorities": r.next_priorities,
|
||||
"created_by": r.created_by,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _parse_review_date(raw) -> Optional[date]:
|
||||
"""兼容 'YYYY-MM-DD' / datetime / date 输入"""
|
||||
if raw is None:
|
||||
return datetime.now().date()
|
||||
if isinstance(raw, datetime):
|
||||
return raw.date()
|
||||
if isinstance(raw, date):
|
||||
return raw
|
||||
try:
|
||||
return datetime.strptime(str(raw)[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return datetime.now().date()
|
||||
|
||||
|
||||
def _get_map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id
|
||||
).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
return m
|
||||
|
||||
|
||||
@router.post("/{map_id}/review-records")
|
||||
def create_review_record(
|
||||
map_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""保存复盘结论(战略回顾会/运营复盘会结束时落库)"""
|
||||
_get_map_or_404(db, map_id, entity_id)
|
||||
summary = (data.get("summary") or "").strip()
|
||||
if not summary:
|
||||
raise HTTPException(400, "复盘结论(summary)不能为空")
|
||||
|
||||
r = ReviewRecord(
|
||||
entity_id=entity_id, # 多租户隔离: 强制token企业
|
||||
map_id=map_id,
|
||||
review_type=data.get("review_type", "quarterly"),
|
||||
review_date=_parse_review_date(data.get("review_date")),
|
||||
summary=summary,
|
||||
adjustments=data.get("adjustments") or [],
|
||||
next_priorities=data.get("next_priorities") or [],
|
||||
created_by=current_user.id if current_user else None,
|
||||
)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return review_to_dict(r)
|
||||
|
||||
|
||||
@router.get("/{map_id}/review-records")
|
||||
def list_review_records(
|
||||
map_id: int,
|
||||
review_type: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""历史复盘记录列表(按复盘日期倒序)"""
|
||||
_get_map_or_404(db, map_id, entity_id)
|
||||
q = db.query(ReviewRecord).filter(
|
||||
ReviewRecord.map_id == map_id,
|
||||
ReviewRecord.entity_id == entity_id,
|
||||
)
|
||||
if review_type:
|
||||
q = q.filter(ReviewRecord.review_type == review_type)
|
||||
records = q.order_by(ReviewRecord.review_date.desc(), ReviewRecord.id.desc()).all()
|
||||
return {"data": [review_to_dict(r) for r in records]}
|
||||
|
||||
|
||||
@router.get("/{map_id}/review-records/{record_id}")
|
||||
def get_review_record(
|
||||
map_id: int,
|
||||
record_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""单条复盘记录详情"""
|
||||
_get_map_or_404(db, map_id, entity_id)
|
||||
r = db.query(ReviewRecord).filter(
|
||||
ReviewRecord.id == record_id,
|
||||
ReviewRecord.map_id == map_id,
|
||||
ReviewRecord.entity_id == entity_id,
|
||||
).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "复盘记录不存在")
|
||||
return review_to_dict(r)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import MapObjective, StrategicMap, KPIDefinition
|
||||
|
||||
@@ -11,8 +12,11 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"],
|
||||
|
||||
|
||||
@router.get("/{map_id}/objectives")
|
||||
def list_objectives(map_id: int, db: Session = Depends(get_db)):
|
||||
"""获取某地图下的所有目标"""
|
||||
def list_objectives(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取某地图下的所有目标(账套隔离: 跨企业返回空, OpenCode执行层发现)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
return {"data": []}
|
||||
objs = db.query(MapObjective).filter(
|
||||
MapObjective.map_id == map_id
|
||||
).order_by(MapObjective.sort_order).all()
|
||||
@@ -20,9 +24,9 @@ def list_objectives(map_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{map_id}/objectives")
|
||||
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""新增目标"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = MapObjective(
|
||||
@@ -40,23 +44,30 @@ def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{map_id}/objectives/{obj_id}")
|
||||
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""修改目标"""
|
||||
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""修改目标(账套隔离 + 字段白名单, OpenCode审查#8)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = db.query(MapObjective).filter(
|
||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||
).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
ALLOWED_OBJ_FIELDS = {"name", "description", "icon", "sort_order", "kpis", "dimension_key"}
|
||||
for k, v in data.items():
|
||||
if hasattr(obj, k) and v is not None:
|
||||
if k in ALLOWED_OBJ_FIELDS and hasattr(obj, k) and v is not None:
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
return _obj_to_dict(obj)
|
||||
|
||||
|
||||
@router.delete("/{map_id}/objectives/{obj_id}")
|
||||
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
|
||||
"""删除目标"""
|
||||
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""删除目标(账套隔离)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = db.query(MapObjective).filter(
|
||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||
).first()
|
||||
@@ -68,8 +79,11 @@ def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{map_id}/objectives/sort")
|
||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""批量排序: {"ids": [3, 1, 2]}"""
|
||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""批量排序: {"ids": [3, 1, 2]}(账套隔离)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
ids = data.get("ids", [])
|
||||
for idx, obj_id in enumerate(ids):
|
||||
db.query(MapObjective).filter(
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
|
||||
KR完整修复(2026-08-27): KR读取从ActionPlan改为krs表 + 方向符号operator + 权重
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta, date as date_type
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition
|
||||
|
||||
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ── KR 工具函数 ─────────────────────────────────────────
|
||||
|
||||
VALID_OPERATORS = (">=", "<=", ">", "<", "=")
|
||||
|
||||
|
||||
def _num(v):
|
||||
"""Decimal → float 便于 JSON 序列化"""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _parse_operator(raw):
|
||||
"""解析方向符号: 支持 >=/<=/>/</=,非法默认 >="""
|
||||
if not raw:
|
||||
return ">="
|
||||
op = str(raw).strip()
|
||||
if op in VALID_OPERATORS:
|
||||
return op
|
||||
# 兼容中文符号 ≥ ≤
|
||||
if op in ("≥",):
|
||||
return ">="
|
||||
if op in ("≤",):
|
||||
return "<="
|
||||
return ">="
|
||||
|
||||
|
||||
def _eval_kr(kr: KR) -> bool:
|
||||
"""达成判断: 实际值满足 operator → True"""
|
||||
if kr.current_value is None or kr.target_value is None:
|
||||
return False
|
||||
cur = float(kr.current_value)
|
||||
tgt = float(kr.target_value)
|
||||
op = _parse_operator(kr.operator)
|
||||
if op == ">=":
|
||||
ok = cur >= tgt
|
||||
elif op == "<=":
|
||||
ok = cur <= tgt
|
||||
elif op == ">":
|
||||
ok = cur > tgt
|
||||
elif op == "<":
|
||||
ok = cur < tgt
|
||||
elif op == "=":
|
||||
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
|
||||
ok = abs(cur - tgt) <= tol
|
||||
else:
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def _calc_progress(kr: KR) -> int:
|
||||
"""方向感知进度计算:
|
||||
>=/>: current/target*100
|
||||
<=/<: target/current*100
|
||||
= : 容差内100
|
||||
"""
|
||||
if kr.current_value is None or kr.target_value is None:
|
||||
return kr.progress or 0
|
||||
cur = float(kr.current_value)
|
||||
tgt = float(kr.target_value)
|
||||
if tgt == 0:
|
||||
return 100 if cur == 0 else (100 if cur > 0 else 0)
|
||||
op = _parse_operator(kr.operator)
|
||||
if op in (">=", ">"):
|
||||
pct = cur / tgt * 100
|
||||
elif op in ("<=", "<"):
|
||||
# 值越小进度越高(如费用率<=18%)
|
||||
if cur <= tgt:
|
||||
pct = 100.0
|
||||
else:
|
||||
pct = max(0.0, tgt / cur * 100)
|
||||
elif op == "=":
|
||||
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
|
||||
if abs(cur - tgt) <= tol:
|
||||
pct = 100.0
|
||||
else:
|
||||
pct = max(0.0, 100.0 - abs(cur - tgt) / (abs(tgt) or 1) * 100)
|
||||
else:
|
||||
pct = cur / tgt * 100
|
||||
return max(0, min(100, round(pct)))
|
||||
|
||||
|
||||
def _refresh_kr_state(kr: KR):
|
||||
"""按当前值刷新 progress + status(方向感知)"""
|
||||
if kr.current_value is not None and kr.target_value is not None:
|
||||
kr.progress = _calc_progress(kr)
|
||||
if _eval_kr(kr):
|
||||
kr.status = "achieved"
|
||||
elif kr.progress > 0:
|
||||
kr.status = "in_progress"
|
||||
return kr
|
||||
|
||||
|
||||
def _inherit_kpi_direction(db: Session, kpi_id, operator, target_value):
|
||||
"""关联KPI时自动继承方向: 从KPI的threshold_green解析(如 '>=800' / '<=18')"""
|
||||
if not kpi_id:
|
||||
return operator, target_value
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
return operator, target_value
|
||||
threshold = (kpi.threshold_green or "").strip()
|
||||
if not threshold:
|
||||
return operator, target_value
|
||||
op = operator
|
||||
tgt = target_value
|
||||
for cand in VALID_OPERATORS:
|
||||
if threshold.startswith(cand):
|
||||
op = cand
|
||||
try:
|
||||
tgt = float(threshold[len(cand):].strip())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
else:
|
||||
# 无符号时尝试解析纯数字
|
||||
try:
|
||||
tgt = float(threshold)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return op, tgt
|
||||
|
||||
|
||||
def _kr_serialize(kr: KR, db: Session = None) -> dict:
|
||||
"""KR → dict(含operator/weight/tolerance)"""
|
||||
mkpi = None
|
||||
if db and kr.metric_kpi_id:
|
||||
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
|
||||
return {
|
||||
"id": kr.id,
|
||||
"objective_id": kr.objective_id,
|
||||
"title": kr.title,
|
||||
"metric_kpi_id": kr.metric_kpi_id,
|
||||
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||
"operator": _parse_operator(kr.operator),
|
||||
"tolerance": _num(kr.tolerance),
|
||||
"weight": _num(kr.weight),
|
||||
"target_value": _num(kr.target_value),
|
||||
"current_value": _num(kr.current_value),
|
||||
"progress": kr.progress,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||||
"monthly_milestones": kr.monthly_milestones or [],
|
||||
}
|
||||
|
||||
|
||||
def _get_objective_or_404(db: Session, obj_id: int, entity_id: int) -> Objective:
|
||||
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
return obj
|
||||
|
||||
|
||||
# ── 目标列表/详情 ─────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
def list_objectives(
|
||||
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
||||
dimension: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""列出OKR目标(账套隔离: 按token企业) — KR读取自krs表(2026-08-27)"""
|
||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
if dimension:
|
||||
q = q.filter(Objective.dimension == dimension)
|
||||
if status:
|
||||
q = q.filter(Objective.status == status)
|
||||
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
||||
results = []
|
||||
for o in objs:
|
||||
krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all()
|
||||
kr_summary = [
|
||||
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress,
|
||||
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight),
|
||||
"metric_kpi_id": kr.metric_kpi_id}
|
||||
for kr in krs
|
||||
]
|
||||
results.append({
|
||||
"id": o.id, "title": o.title, "description": o.description,
|
||||
"dimension": o.dimension, "quarter": o.quarter,
|
||||
"owner": o.owner, "status": o.status, "progress": o.progress,
|
||||
"confidence": o.confidence,
|
||||
"key_results": kr_summary,
|
||||
"kr_count": len(krs),
|
||||
"kr_completed": sum(1 for kr in krs if kr.status in ("completed", "achieved")),
|
||||
"created_at": o.created_at.isoformat() if o.created_at else None,
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_objective(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
|
||||
title = data.get("title") or ""
|
||||
quarter = data.get("quarter") or ""
|
||||
description = data.get("description")
|
||||
dimension = data.get("dimension")
|
||||
owner = data.get("owner")
|
||||
if not title or not quarter:
|
||||
raise HTTPException(422, "缺少必填字段: title, quarter")
|
||||
obj = Objective(title=title, quarter=quarter, description=description,
|
||||
dimension=dimension, owner=owner, entity_id=entity_id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return {"ok": True, "id": obj.id, "title": obj.title}
|
||||
|
||||
|
||||
@router.get("/{obj_id}")
|
||||
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取单个OKR详情(账套隔离: 跨企业404) — KR读取自krs表(2026-08-27)"""
|
||||
obj = _get_objective_or_404(db, obj_id, entity_id)
|
||||
krs = db.query(KR).filter(KR.objective_id == obj_id).order_by(KR.id).all()
|
||||
return {
|
||||
"objective": {
|
||||
"id": obj.id, "title": obj.title, "description": obj.description,
|
||||
"dimension": obj.dimension, "quarter": obj.quarter,
|
||||
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
|
||||
"confidence": obj.confidence,
|
||||
},
|
||||
"key_results": [_kr_serialize(kr, db) for kr in krs],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{obj_id}")
|
||||
def update_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""更新OKR进度(通过krs表KR自动计算加权进度)"""
|
||||
obj = _get_objective_or_404(db, obj_id, entity_id)
|
||||
krs = db.query(KR).filter(KR.objective_id == obj_id).all()
|
||||
if krs:
|
||||
weights = [float(kr.weight) if kr.weight else 1.0 for kr in krs]
|
||||
total_w = sum(weights) or len(krs)
|
||||
obj.progress = round(sum(kr.progress * w for kr, w in zip(krs, weights)) / total_w)
|
||||
db.commit()
|
||||
return {"ok": True, "id": obj_id, "progress": obj.progress}
|
||||
|
||||
|
||||
# ── KR CRUD ───────────────────────────────────────────
|
||||
|
||||
@router.post("/{objective_id}/krs")
|
||||
def create_kr(objective_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""新增KR(落krs表)— title/metric_kpi_id/operator/target_value/weight/progress/due_date
|
||||
关联KPI时自动继承方向(threshold_green)
|
||||
"""
|
||||
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||
title = data.get("title") or ""
|
||||
if not title:
|
||||
raise HTTPException(422, "缺少必填字段: title")
|
||||
operator, target_value = _inherit_kpi_direction(
|
||||
db, data.get("metric_kpi_id"), data.get("operator"), data.get("target_value"))
|
||||
if target_value is None and operator != "=":
|
||||
# 没有目标值也能建(后续补),但明确提示
|
||||
pass
|
||||
kr = KR(
|
||||
entity_id=entity_id,
|
||||
objective_id=objective_id,
|
||||
title=title,
|
||||
metric_kpi_id=data.get("metric_kpi_id") or None,
|
||||
operator=_parse_operator(operator),
|
||||
tolerance=data.get("tolerance"),
|
||||
weight=data.get("weight"),
|
||||
target_value=target_value,
|
||||
current_value=data.get("current_value"),
|
||||
progress=data.get("progress", 0),
|
||||
status=data.get("status", "pending"),
|
||||
due_date=_parse_date(data.get("due_date")),
|
||||
monthly_milestones=data.get("monthly_milestones") or [],
|
||||
)
|
||||
_refresh_kr_state(kr)
|
||||
db.add(kr)
|
||||
db.commit()
|
||||
db.refresh(kr)
|
||||
update_objective(objective_id, db, entity_id)
|
||||
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
|
||||
|
||||
|
||||
@router.put("/{objective_id}/krs/sync")
|
||||
def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""批量同步KR数组(前端保存整个KR列表时用):
|
||||
body: {"krs": [{id?, title, operator, target_value, weight, kpi_code?, ...}]}
|
||||
已存在的按id更新;无id且同名则更新;否则新增;不在列表中的删除
|
||||
注意: 必须声明在 /krs/{kr_id} 之前,避免 'sync' 被解析为 kr_id
|
||||
"""
|
||||
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||
incoming = data.get("krs", [])
|
||||
if not isinstance(incoming, list):
|
||||
raise HTTPException(400, "krs 必须是数组")
|
||||
existing = {kr.id: kr for kr in db.query(KR).filter(KR.objective_id == objective_id).all()}
|
||||
keep_ids = set()
|
||||
now_name = {}
|
||||
|
||||
for i, item in enumerate(incoming):
|
||||
title = (item.get("title") or item.get("name") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
kpi_id = item.get("metric_kpi_id") or item.get("kpi_id")
|
||||
# kpi_code → kpi_id 转换
|
||||
if not kpi_id and item.get("kpi_code"):
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == item["kpi_code"],
|
||||
KPIDefinition.entity_id == entity_id).first()
|
||||
kpi_id = kpi.id if kpi else None
|
||||
operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value"))
|
||||
|
||||
kr = None
|
||||
if item.get("id") and int(item["id"]) in existing:
|
||||
kr = existing[int(item["id"])]
|
||||
elif title in now_name:
|
||||
kr = now_name[title]
|
||||
else:
|
||||
# 按同名找已有
|
||||
for k in existing.values():
|
||||
if k.title == title:
|
||||
kr = k
|
||||
break
|
||||
if kr is None:
|
||||
kr = KR(entity_id=entity_id, objective_id=objective_id, title=title)
|
||||
db.add(kr)
|
||||
kr.title = title
|
||||
kr.metric_kpi_id = kpi_id
|
||||
kr.operator = _parse_operator(operator)
|
||||
if item.get("tolerance") is not None:
|
||||
kr.tolerance = item.get("tolerance")
|
||||
if item.get("weight") is not None:
|
||||
kr.weight = item.get("weight")
|
||||
if item.get("target_value") is not None:
|
||||
kr.target_value = item.get("target_value")
|
||||
elif target_value is not None:
|
||||
kr.target_value = target_value
|
||||
if item.get("current_value") is not None:
|
||||
kr.current_value = item.get("current_value")
|
||||
if item.get("due_date"):
|
||||
kr.due_date = _parse_date(item.get("due_date"))
|
||||
if item.get("monthly_milestones") is not None:
|
||||
kr.monthly_milestones = item.get("monthly_milestones")
|
||||
_refresh_kr_state(kr)
|
||||
keep_ids.add(kr.id)
|
||||
now_name[title] = kr
|
||||
# 删除不在列表中的
|
||||
for kid, kr in existing.items():
|
||||
if kid not in keep_ids:
|
||||
db.delete(kr)
|
||||
db.commit()
|
||||
update_objective(objective_id, db, entity_id)
|
||||
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
|
||||
return {"ok": True, "objective_id": objective_id, "krs": [_kr_serialize(kr, db) for kr in krs]}
|
||||
|
||||
|
||||
@router.put("/{objective_id}/krs/{kr_id}")
|
||||
def update_kr(objective_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""更新KR(含方向/权重/当前值,自动重算progress+status)"""
|
||||
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该目标")
|
||||
updatable = {"title", "metric_kpi_id", "operator", "tolerance", "weight",
|
||||
"target_value", "current_value", "progress", "status", "due_date",
|
||||
"monthly_milestones"}
|
||||
for k, v in data.items():
|
||||
if k in updatable and v is not None:
|
||||
setattr(kr, k, v)
|
||||
# 关联KPI变化 → 自动继承方向
|
||||
if "metric_kpi_id" in data or "operator" not in data or "target_value" not in data:
|
||||
if data.get("metric_kpi_id") and ("operator" not in data or "target_value" not in data):
|
||||
kr.operator, kr.target_value = _inherit_kpi_direction(
|
||||
db, data.get("metric_kpi_id", kr.metric_kpi_id),
|
||||
data.get("operator", kr.operator), data.get("target_value", kr.target_value))
|
||||
if data.get("due_date"):
|
||||
kr.due_date = _parse_date(data["due_date"])
|
||||
_refresh_kr_state(kr)
|
||||
db.commit()
|
||||
db.refresh(kr)
|
||||
update_objective(objective_id, db, entity_id)
|
||||
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
|
||||
|
||||
|
||||
@router.delete("/{objective_id}/krs/{kr_id}")
|
||||
def delete_kr(objective_id: int, kr_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""删除KR"""
|
||||
_get_objective_or_404(db, objective_id, entity_id)
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该目标")
|
||||
db.delete(kr)
|
||||
db.commit()
|
||||
update_objective(objective_id, db, entity_id)
|
||||
return {"ok": True, "deleted": kr_id}
|
||||
|
||||
|
||||
# ── KR里程碑(时间分解) ─────────────────────────────
|
||||
|
||||
@router.put("/{okr_id}/decomposition/milestones/{kr_id}")
|
||||
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
|
||||
_get_objective_or_404(db, okr_id, entity_id)
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||
milestones = data.get("milestones")
|
||||
if not isinstance(milestones, list):
|
||||
raise HTTPException(400, "milestones 必须是数组")
|
||||
normalized = []
|
||||
for ms in milestones:
|
||||
normalized.append({
|
||||
"month": ms.get("month", ""),
|
||||
"label": ms.get("label", ""),
|
||||
"status": ms.get("status", "pending"),
|
||||
"target": ms.get("target"),
|
||||
})
|
||||
kr.monthly_milestones = normalized
|
||||
if normalized:
|
||||
done = sum(1 for ms in normalized if ms.get("status") == "completed")
|
||||
kr.progress = round(done / len(normalized) * 100)
|
||||
if kr.progress >= 100:
|
||||
kr.status = "achieved"
|
||||
elif kr.status == "achieved" and kr.progress < 100:
|
||||
kr.status = "in_progress"
|
||||
db.commit()
|
||||
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
|
||||
|
||||
|
||||
@router.post("/{okr_id}/decomposition/milestones/generate")
|
||||
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
|
||||
_get_objective_or_404(db, okr_id, entity_id)
|
||||
kr_id = data.get("kr_id")
|
||||
if not kr_id:
|
||||
raise HTTPException(400, "缺少 kr_id")
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||
kr.monthly_milestones = _build_auto_milestones(kr)
|
||||
db.commit()
|
||||
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
|
||||
|
||||
|
||||
def _build_auto_milestones(kr: KR) -> list:
|
||||
"""按截止日期向前均分3个月度里程碑"""
|
||||
base = kr.due_date or datetime.now() + timedelta(days=90)
|
||||
if isinstance(base, str):
|
||||
try:
|
||||
base = datetime.strptime(base[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
base = datetime.now().date() + timedelta(days=90)
|
||||
if isinstance(base, datetime):
|
||||
base = base.date()
|
||||
base = base.replace(day=1)
|
||||
month_keys = []
|
||||
for i in range(3, 0, -1):
|
||||
y, m = base.year, base.month - i
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
month_keys.append(f"{y:04d}-{m:02d}")
|
||||
return [
|
||||
{
|
||||
"month": mk,
|
||||
"label": f"里程碑{i + 1}",
|
||||
"status": "pending",
|
||||
"target": None,
|
||||
}
|
||||
for i, mk in enumerate(month_keys)
|
||||
]
|
||||
|
||||
|
||||
def _parse_date(v):
|
||||
"""兼容 str/date/datetime → date"""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, date_type):
|
||||
return v
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
try:
|
||||
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{okr_id}/decomposition")
|
||||
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""获取OKR的时间分解视图数据 — KR读取自krs表(2026-08-27)"""
|
||||
okr = _get_objective_or_404(db, okr_id, entity_id)
|
||||
|
||||
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
|
||||
bsc_o = db.query(Objective).filter(
|
||||
Objective.dimension == okr.dimension,
|
||||
Objective.quarter.is_(None),
|
||||
Objective.entity_id == entity_id,
|
||||
).first()
|
||||
|
||||
# 2. 本OKR的所有KR(krs表)
|
||||
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
|
||||
|
||||
# 3. 当前周的ActionPlan(本周行动计划)
|
||||
now = datetime.now()
|
||||
week_start = now - timedelta(days=now.weekday())
|
||||
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_end = week_start + timedelta(days=7)
|
||||
action_plans = db.query(ActionPlan).filter(
|
||||
ActionPlan.objective_id == okr_id,
|
||||
ActionPlan.due_date.between(week_start, week_end)
|
||||
).all()
|
||||
|
||||
return {
|
||||
"annual_o": bsc_o.title if bsc_o else None,
|
||||
"quarterly_o": okr.title,
|
||||
"krs": [
|
||||
{
|
||||
"kr_id": kr.id,
|
||||
"title": kr.title,
|
||||
"progress": kr.progress,
|
||||
"milestones": kr.monthly_milestones or []
|
||||
}
|
||||
for kr in krs
|
||||
],
|
||||
"weekly_actions": [
|
||||
{
|
||||
"id": ap.id,
|
||||
"title": ap.title,
|
||||
"status": ap.status,
|
||||
"deadline": ap.due_date.isoformat() if ap.due_date else None
|
||||
}
|
||||
for ap in action_plans
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
OKR模板库 API — 按维度筛选 + 用户自定义模板
|
||||
"""
|
||||
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 import OKRTemplate, StrategicMap
|
||||
|
||||
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
DIMENSION_LAYER_NAMES = {
|
||||
"finance": "财务层",
|
||||
"customer": "客户层",
|
||||
"process": "内部流程层",
|
||||
"learning": "学习成长层",
|
||||
}
|
||||
|
||||
DIMENSION_LAYER_ICONS = {
|
||||
"finance": "💰",
|
||||
"customer": "👥",
|
||||
"process": "⚙️",
|
||||
"learning": "📚",
|
||||
}
|
||||
|
||||
DIMENSION_LAYER_COLORS = {
|
||||
"finance": "#F56C6C",
|
||||
"customer": "#409EFF",
|
||||
"process": "#67C23A",
|
||||
"learning": "#E6A23C",
|
||||
}
|
||||
|
||||
@router.get("")
|
||||
def list_okr_templates(
|
||||
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
|
||||
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
|
||||
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
|
||||
search: Optional[str] = Query(None, description="按O名称关键词搜索"),
|
||||
active_only: bool = Query(True, description="仅返回启用模板"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索"""
|
||||
q = db.query(OKRTemplate)
|
||||
if dimension:
|
||||
q = q.filter(OKRTemplate.dimension == dimension)
|
||||
if source:
|
||||
q = q.filter(OKRTemplate.source == source)
|
||||
if industry_tag:
|
||||
q = q.filter(OKRTemplate.industry_tag == industry_tag)
|
||||
if search:
|
||||
q = q.filter(OKRTemplate.name.like(f"%{search}%"))
|
||||
if active_only:
|
||||
q = q.filter(OKRTemplate.is_active == 1)
|
||||
templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all()
|
||||
return {
|
||||
"total": len(templates),
|
||||
"items": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"dimension": t.dimension,
|
||||
"layer": t.layer,
|
||||
"industry_tag": t.industry_tag,
|
||||
"preset_krs": t.preset_krs,
|
||||
"source": t.source,
|
||||
"owner": t.owner,
|
||||
"use_count": t.use_count,
|
||||
"sort_order": t.sort_order,
|
||||
"is_active": t.is_active,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
}
|
||||
for t in templates
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
def get_okr_template(template_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单个 OKR 模板详情"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
return {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"dimension": t.dimension,
|
||||
"layer": t.layer,
|
||||
"industry_tag": t.industry_tag,
|
||||
"preset_krs": t.preset_krs,
|
||||
"source": t.source,
|
||||
"owner": t.owner,
|
||||
"use_count": t.use_count,
|
||||
"sort_order": t.sort_order,
|
||||
"is_active": t.is_active,
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
||||
"""用户自定义 OKR 模板(治理: 同名去重 + owner 标记)"""
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "模板名称不能为空")
|
||||
dimension = data.get("dimension", "")
|
||||
if dimension not in ("finance", "customer", "process", "learning"):
|
||||
raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning")
|
||||
preset_krs = data.get("preset_krs", [])
|
||||
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
|
||||
raise HTTPException(400, "至少需要一个预设KR")
|
||||
# P2治理: 同名+同维度去重(防止 id=33 式重复模板,2026-08-21)
|
||||
dup = db.query(OKRTemplate).filter(
|
||||
OKRTemplate.name == name,
|
||||
OKRTemplate.dimension == dimension,
|
||||
OKRTemplate.is_active == 1,
|
||||
).first()
|
||||
if dup:
|
||||
raise HTTPException(409, f"已存在同名模板「{name}」(#{dup.id}, source={dup.source}),请改用现有模板或改名")
|
||||
|
||||
t = OKRTemplate(
|
||||
name=name,
|
||||
description=data.get("description", ""),
|
||||
dimension=dimension,
|
||||
layer=data.get("layer", "level3"),
|
||||
industry_tag=data.get("industry_tag", "general"),
|
||||
preset_krs=preset_krs,
|
||||
source="user",
|
||||
owner="用户自定义",
|
||||
sort_order=data.get("sort_order", 0),
|
||||
)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return {"ok": True, "id": t.id, "name": t.name}
|
||||
|
||||
|
||||
@router.post("/{template_id}/use")
|
||||
def increment_use_count(template_id: int, db: Session = Depends(get_db)):
|
||||
"""增加模板使用次数"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
t.use_count = (t.use_count or 0) + 1
|
||||
db.commit()
|
||||
return {"ok": True, "use_count": t.use_count}
|
||||
|
||||
|
||||
@router.post("/{template_id}/apply")
|
||||
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""应用 OKR 模板 — 创建战略地图并填入 O+KR"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
|
||||
map_title = data.get("title", t.name)
|
||||
dim = t.dimension
|
||||
preset_krs = t.preset_krs or []
|
||||
|
||||
# 构建 dimensions: 仅包含模板所在的维度层
|
||||
dimensions = []
|
||||
for dk in ("finance", "customer", "process", "learning"):
|
||||
objectives = []
|
||||
if dk == dim:
|
||||
objectives.append({
|
||||
"name": t.name,
|
||||
"description": t.description or "",
|
||||
"kpis": [],
|
||||
"krs": [
|
||||
{
|
||||
"name": kr.get("name", ""),
|
||||
"target_value": kr.get("target_value", ""),
|
||||
"weight": kr.get("weight", 33),
|
||||
"metric_kpi_id": kr.get("metric_kpi_id"),
|
||||
"metric_kpi_code": kr.get("metric_kpi_code"),
|
||||
}
|
||||
for kr in preset_krs
|
||||
],
|
||||
})
|
||||
dimensions.append({
|
||||
"key": dk,
|
||||
"name": DIMENSION_LAYER_NAMES.get(dk, dk),
|
||||
"icon": DIMENSION_LAYER_ICONS.get(dk, "📌"),
|
||||
"color": DIMENSION_LAYER_COLORS.get(dk, "#909399"),
|
||||
"objectives": objectives,
|
||||
})
|
||||
|
||||
m = StrategicMap(
|
||||
title=map_title,
|
||||
version=data.get("version", "v1.0"),
|
||||
status="draft",
|
||||
dimensions=dimensions,
|
||||
canvas_data={"connections": []},
|
||||
)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
|
||||
# 使用 maps API 的 _sync_map_objectives 同步到 map_objectives 表
|
||||
from app.api.maps import _sync_map_objectives
|
||||
_sync_map_objectives(m, db)
|
||||
|
||||
# 增加模板使用次数
|
||||
t.use_count = (t.use_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"map_id": m.id,
|
||||
"title": m.title,
|
||||
"template_id": template_id,
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
CMA本体三支柱追溯链 API — 科目 ↔ KPI ↔ OKR的O 三层互联 (2026-08-19)
|
||||
|
||||
追溯链: 目标(O) → 指标(KPI) → 科目(数据)
|
||||
objective_kpi 表: O 由哪些 KPI 度量
|
||||
kpi_subject_map 表: KPI 由哪些科目计算
|
||||
krs 表: O 的关键结果 KR (OKR完整化)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import Objective, KPIDefinition, ObjectiveKPI, KPISubjectMap, KR, Subject
|
||||
|
||||
router = APIRouter(prefix="/api/cma/ontology", tags=["本体三支柱"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it", "business"))],
|
||||
)
|
||||
|
||||
|
||||
def _num(v):
|
||||
"""Decimal → float 便于 JSON 序列化"""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
@router.get("/trace")
|
||||
def trace_ontology(
|
||||
objective_id: int = Query(..., description="OKR目标ID, 从O→KPI→科目逐层追溯"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""本体追溯链: O(目标) → KPI(指标) → 科目(数据) — 账套隔离 (2026-08-23 P2)"""
|
||||
obj = db.query(Objective).filter(Objective.id == objective_id, Objective.entity_id == entity_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
|
||||
# 第2层: O 支撑的 KPI (objective_kpi)
|
||||
links = (
|
||||
db.query(ObjectiveKPI, KPIDefinition)
|
||||
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
|
||||
.filter(ObjectiveKPI.objective_id == objective_id)
|
||||
.order_by(ObjectiveKPI.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 第3层: 每个 KPI 依赖的科目 (kpi_subject_map)
|
||||
subject_by_code = {
|
||||
s.subject_code: s.subject_name
|
||||
for s in db.query(Subject).filter(Subject.is_active == 1).all()
|
||||
}
|
||||
kpi_layer = []
|
||||
for link, kpi in links:
|
||||
maps = (
|
||||
db.query(KPISubjectMap)
|
||||
.filter(KPISubjectMap.kpi_id == kpi.id)
|
||||
.order_by(KPISubjectMap.id)
|
||||
.all()
|
||||
)
|
||||
subjects = [
|
||||
{
|
||||
"subject_code": m.subject_code,
|
||||
"subject_name": subject_by_code.get(m.subject_code, ""),
|
||||
"calc_type": m.calc_type,
|
||||
"weight": _num(m.weight),
|
||||
"remark": m.remark,
|
||||
}
|
||||
for m in maps
|
||||
]
|
||||
kpi_layer.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"unit": kpi.unit,
|
||||
"weight": _num(link.weight),
|
||||
"formula": kpi.formula,
|
||||
"subjects": subjects,
|
||||
})
|
||||
|
||||
# KR 层 (OKR完整化: O→KR)
|
||||
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
|
||||
kr_list = []
|
||||
for kr in krs:
|
||||
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first() if kr.metric_kpi_id else None
|
||||
kr_list.append({
|
||||
"id": kr.id,
|
||||
"title": kr.title,
|
||||
"metric_kpi_id": kr.metric_kpi_id,
|
||||
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||
"operator": kr.operator or ">=",
|
||||
"weight": _num(kr.weight),
|
||||
"target_value": _num(kr.target_value),
|
||||
"current_value": _num(kr.current_value),
|
||||
"progress": kr.progress,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||||
})
|
||||
|
||||
subject_total = sum(len(k["subjects"]) for k in kpi_layer)
|
||||
return {
|
||||
"objective": {
|
||||
"id": obj.id,
|
||||
"title": obj.title,
|
||||
"description": obj.description,
|
||||
"dimension": obj.dimension,
|
||||
"quarter": obj.quarter,
|
||||
"owner": obj.owner,
|
||||
"status": obj.status,
|
||||
"progress": obj.progress,
|
||||
},
|
||||
"krs": kr_list,
|
||||
"kpis": kpi_layer,
|
||||
"chain": {
|
||||
"objective_id": obj.id,
|
||||
"objective_title": obj.title,
|
||||
"kpi_count": len(kpi_layer),
|
||||
"subject_count": subject_total,
|
||||
"path": "O(目标) → KPI(指标) → 科目(数据)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/objectives")
|
||||
def list_ontology_objectives(
|
||||
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""所有OKR目标的三层链路概览(前端OKR页用) — 账套隔离 (2026-08-23 P2)"""
|
||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
||||
results = []
|
||||
for o in objs:
|
||||
kpi_links = (
|
||||
db.query(ObjectiveKPI, KPIDefinition)
|
||||
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
|
||||
.filter(ObjectiveKPI.objective_id == o.id)
|
||||
.all()
|
||||
)
|
||||
krs = db.query(KR).filter(KR.objective_id == o.id).all()
|
||||
results.append({
|
||||
"id": o.id,
|
||||
"title": o.title,
|
||||
"dimension": o.dimension,
|
||||
"quarter": o.quarter,
|
||||
"owner": o.owner,
|
||||
"status": o.status,
|
||||
"progress": o.progress,
|
||||
"kpi_count": len(kpi_links),
|
||||
"kr_count": len(krs),
|
||||
"kpis": [{"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"weight": _num(l.weight)} for l, k in kpi_links],
|
||||
"krs": [{"id": kr.id, "title": kr.title, "progress": kr.progress,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None}
|
||||
for kr in krs],
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
+824
-1
@@ -1,10 +1,17 @@
|
||||
"""预测模拟API — 管理会计OS"""
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||||
from app.utils.predict_engine import (
|
||||
cvp_analysis, npv, irr,
|
||||
sensitivity_analysis, scenario_analysis,
|
||||
)
|
||||
from app.utils.cash_forecast_engine import (
|
||||
forecast_cash_flow, save_forecast_to_db,
|
||||
calculate_accuracy, generate_scenario_suggestion,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger("cma.predict")
|
||||
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
|
||||
@@ -96,3 +103,819 @@ 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)}")
|
||||
|
||||
|
||||
# ── 相关成本决策(CMA P2商业决策分析25%权重核心) ──────────────────
|
||||
|
||||
|
||||
@router.post("/relevant-decision")
|
||||
def api_relevant_decision(data: dict):
|
||||
"""相关成本决策分析(CMA P2 商业决策分析核心内容)
|
||||
场景: make-or-buy自制外购 / special-order特殊订单 / product-mix产品组合
|
||||
"""
|
||||
try:
|
||||
decision_type = data.get("type", "make_or_buy")
|
||||
|
||||
if decision_type == "make_or_buy":
|
||||
# 自制vs外购决策
|
||||
# 相关成本 = 增量成本(只有随决策变化的成本才是相关的)
|
||||
make_var_cost = float(data.get("make_variable_cost", 0)) # 自制单位变动成本
|
||||
make_fixed = float(data.get("make_fixed_cost", 0)) # 自制新增固定成本
|
||||
buy_price = float(data.get("buy_price", 0)) # 外购单价
|
||||
demand = float(data.get("demand", 0)) # 需求量
|
||||
existing_fixed = float(data.get("existing_fixed_cost", 0)) # 现有固定成本(无关成本,自制不增加则忽略)
|
||||
|
||||
make_total = make_var_cost * demand + make_fixed
|
||||
buy_total = buy_price * demand
|
||||
diff = buy_total - make_total # >0自制省钱
|
||||
|
||||
return {
|
||||
"type": "自制vs外购",
|
||||
"make_total_cost": round(make_total, 2),
|
||||
"buy_total_cost": round(buy_total, 2),
|
||||
"difference": round(diff, 2),
|
||||
"recommendation": "自制" if diff > 0 else "外购",
|
||||
"reason": f"自制总成本{make_total:.2f} vs 外购总成本{buy_total:.2f},{'自制节省' + str(round(diff,2)) if diff > 0 else '外购节省' + str(round(-diff,2))}",
|
||||
"unit_make_cost": round(make_var_cost + (make_fixed / demand if demand else 0), 2),
|
||||
"unit_buy_price": buy_price,
|
||||
"indifferent_point": round(make_fixed / (buy_price - make_var_cost), 2) if buy_price > make_var_cost else None,
|
||||
"notes": "仅考虑相关成本(增量成本);现有固定成本若不受决策影响则无关",
|
||||
}
|
||||
|
||||
elif decision_type == "special_order":
|
||||
# 特殊订单决策(有剩余产能时,只要价格>单位变动成本即接受)
|
||||
normal_price = float(data.get("normal_price", 0))
|
||||
special_price = float(data.get("special_price", 0))
|
||||
var_cost = float(data.get("variable_cost", 0))
|
||||
order_qty = float(data.get("order_qty", 0))
|
||||
capacity_used = float(data.get("capacity_used", 0)) # 特殊订单占用产能%
|
||||
extra_fixed = float(data.get("extra_fixed_cost", 0)) # 一次性额外固定成本
|
||||
|
||||
contribution_per_unit = special_price - var_cost
|
||||
total_contribution = contribution_per_unit * order_qty - extra_fixed
|
||||
accept = total_contribution > 0 and capacity_used <= 100
|
||||
|
||||
return {
|
||||
"type": "特殊订单",
|
||||
"unit_contribution": round(contribution_per_unit, 2),
|
||||
"total_contribution": round(total_contribution, 2),
|
||||
"extra_fixed_cost": extra_fixed,
|
||||
"capacity_used_pct": capacity_used,
|
||||
"recommendation": "接受" if accept else "拒绝",
|
||||
"reason": f"单价{special_price} - 变动成本{var_cost} = 单位贡献{contribution_per_unit:.2f}" +
|
||||
(f",共{total_contribution:.2f} > 0 且产能{capacity_used}%够用 → 接受(增量利润)" if accept else
|
||||
f",总贡献{total_contribution:.2f} ≤ 0 或产能不足 → 拒绝"),
|
||||
"notes": "有剩余产能时,只要价格>变动成本且不冲击正常市场即可接受;固定成本无关",
|
||||
}
|
||||
|
||||
elif decision_type == "product_mix":
|
||||
# 产品组合决策(约束理论:单位约束资源的边际贡献最大者优先)
|
||||
products = data.get("products", []) # [{name, price, var_cost, constraint_usage, demand}]
|
||||
results = []
|
||||
for p in products:
|
||||
cm_per_unit = float(p.get("price", 0)) - float(p.get("var_cost", 0))
|
||||
cm_per_constraint = cm_per_unit / float(p.get("constraint_usage", 1))
|
||||
results.append({
|
||||
"name": p.get("name", ""),
|
||||
"unit_contribution": round(cm_per_unit, 2),
|
||||
"constraint_usage": float(p.get("constraint_usage", 1)),
|
||||
"contribution_per_constraint": round(cm_per_constraint, 2),
|
||||
"demand": float(p.get("demand", 0)),
|
||||
})
|
||||
# 按单位约束资源贡献排序(约束理论优先)
|
||||
results.sort(key=lambda x: x["contribution_per_constraint"], reverse=True)
|
||||
return {
|
||||
"type": "产品组合(约束理论)",
|
||||
"ranking": results,
|
||||
"recommendation": f"优先生产「{results[0]['name']}」(单位约束贡献{results[0]['contribution_per_constraint']}最高)",
|
||||
"notes": "瓶颈资源下,按单位约束资源的边际贡献排序,而非单位边际贡献",
|
||||
}
|
||||
|
||||
raise HTTPException(400, "未知决策类型: " + str(decision_type))
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"相关成本决策失败: {str(e)}")
|
||||
|
||||
|
||||
# ── 现金流预测(AI事前预警) ────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/cash-forecast")
|
||||
def api_cash_forecast(request: Request, data: dict, db: Session = Depends(get_db)):
|
||||
"""现金流预测 — 根据历史KPI推算未来30天现金流"""
|
||||
try:
|
||||
entity_id = resolve_entity_for_request(request, int(data.get("entity_id", 1)))
|
||||
days = int(data.get("days", 30))
|
||||
current_cash = float(data["current_cash"]) if data.get("current_cash") else None
|
||||
result = forecast_cash_flow(entity_id, db, days, current_cash)
|
||||
# 保存到数据库
|
||||
try:
|
||||
save_forecast_to_db(entity_id, result, db)
|
||||
except Exception as e:
|
||||
logger.warning(f"保存预测结果失败: {e}")
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"现金流预测失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/cash-forecast/history")
|
||||
def api_cash_forecast_history(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取已保存的现金流预测历史"""
|
||||
from app.models import CashForecast
|
||||
forecasts = db.query(CashForecast).filter(
|
||||
CashForecast.entity_id == entity_id,
|
||||
).order_by(CashForecast.forecast_date.desc()).limit(days).all()
|
||||
return {
|
||||
"data": [{
|
||||
"id": f.id,
|
||||
"forecast_date": f.forecast_date.isoformat(),
|
||||
"predicted_cash": f.predicted_cash,
|
||||
"lower_bound": f.lower_bound,
|
||||
"upper_bound": f.upper_bound,
|
||||
"alert_status": f.alert_status,
|
||||
} for f in forecasts]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/accuracy")
|
||||
def api_forecast_accuracy(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""预测准确率报表 — 上期预测 vs 本期实际"""
|
||||
try:
|
||||
results = calculate_accuracy(entity_id, db)
|
||||
|
||||
# 计算整体MAE/MAPE
|
||||
if results:
|
||||
total_mae = sum(r["mae"] for r in results) / len(results)
|
||||
total_mape = sum(r["mape"] for r in results) / len(results)
|
||||
else:
|
||||
total_mae = 0
|
||||
total_mape = 0
|
||||
|
||||
return {
|
||||
"data": results,
|
||||
"summary": {
|
||||
"total_periods": len(results),
|
||||
"avg_mae": round(total_mae, 2),
|
||||
"avg_mape": round(total_mape, 2),
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"获取准确率失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/scenario-suggestions")
|
||||
def api_scenario_suggestions(alert_type: str = None):
|
||||
"""获取情景建议模板"""
|
||||
types = ["cash_low", "cash_critical", "cost_high", "revenue_drop"]
|
||||
results = []
|
||||
for at in types:
|
||||
if alert_type and at != alert_type:
|
||||
continue
|
||||
sug = generate_scenario_suggestion(at, "")
|
||||
results.append({"alert_type": at, **sug})
|
||||
return {"data": results}
|
||||
|
||||
|
||||
@router.post("/scenario-suggestion/generate")
|
||||
def api_generate_suggestion(data: dict):
|
||||
"""根据预警信息动态生成情景建议"""
|
||||
try:
|
||||
alert_type = data.get("alert_type", "cash_low")
|
||||
kpi_name = data.get("kpi_name", "未知KPI")
|
||||
extra = data.get("extra", {})
|
||||
sug = generate_scenario_suggestion(alert_type, kpi_name, extra)
|
||||
return sug
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"生成建议失败: {str(e)}")
|
||||
|
||||
|
||||
# ── 实物期权计算器 ─────────────────────────────────────────────
|
||||
import math
|
||||
|
||||
def _norm_cdf(x: float) -> float:
|
||||
"""标准正态分布CDF — Abramowitz & Stegun 近似 (max error ≈ 1.5×10⁻⁷)"""
|
||||
a1, a2, a3, a4, a5 = 0.254829592, -0.284496736, 1.421413741, -1.453152027, 1.061405429
|
||||
p = 0.3275911
|
||||
sign = 1.0
|
||||
if x < 0:
|
||||
sign = -1.0
|
||||
x_abs = abs(x) / math.sqrt(2.0)
|
||||
t = 1.0 / (1.0 + p * x_abs)
|
||||
y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * math.exp(-x_abs * x_abs)
|
||||
return 0.5 * (1.0 + sign * y)
|
||||
|
||||
def _black_scholes_call(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
|
||||
"""BSM看涨期权定价(扩张期权/延迟期权)"""
|
||||
sqrt_t = math.sqrt(t)
|
||||
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
|
||||
d2 = d1 - sigma * sqrt_t
|
||||
nd1 = _norm_cdf(d1)
|
||||
nd2 = _norm_cdf(d2)
|
||||
call_value = max(S0 * nd1 - X * math.exp(-r * t) * nd2, 0.0)
|
||||
return {"value": round(call_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "Nd1": round(nd1, 4), "Nd2": round(nd2, 4)}
|
||||
|
||||
def _black_scholes_put(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
|
||||
"""BSM看跌期权定价(放弃期权/收缩期权)"""
|
||||
sqrt_t = math.sqrt(t)
|
||||
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
|
||||
d2 = d1 - sigma * sqrt_t
|
||||
nd1 = _norm_cdf(-d1)
|
||||
nd2 = _norm_cdf(-d2)
|
||||
put_value = max(X * math.exp(-r * t) * nd2 - S0 * nd1, 0.0)
|
||||
return {"value": round(put_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "N(-d1)": round(nd1, 4), "N(-d2)": round(nd2, 4)}
|
||||
|
||||
def _binomial_tree_call(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
|
||||
"""二叉树欧式看涨期权定价(延迟期权)"""
|
||||
dt = t / n
|
||||
u = math.exp(sigma * math.sqrt(dt))
|
||||
d = 1.0 / u
|
||||
p = (math.exp(r * dt) - d) / (u - d)
|
||||
discount = math.exp(-r * dt)
|
||||
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
|
||||
values = [max(p - X, 0.0) for p in prices]
|
||||
for i in range(n - 1, -1, -1):
|
||||
for j in range(i + 1):
|
||||
values[j] = discount * (p * values[j] + (1 - p) * values[j + 1])
|
||||
return max(values[0], 0.0)
|
||||
|
||||
def _binomial_tree_american_put(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
|
||||
"""二叉树美式看跌期权定价(可随时放弃的放弃期权)"""
|
||||
dt = t / n
|
||||
u = math.exp(sigma * math.sqrt(dt))
|
||||
d = 1.0 / u
|
||||
p = (math.exp(r * dt) - d) / (u - d)
|
||||
discount = math.exp(-r * dt)
|
||||
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
|
||||
values = [max(X - p, 0.0) for p in prices]
|
||||
for i in range(n - 1, -1, -1):
|
||||
for j in range(i + 1):
|
||||
hold = discount * (p * values[j] + (1 - p) * values[j + 1])
|
||||
exercise = X - (S0 * (u ** (i - j)) * (d ** j))
|
||||
values[j] = max(hold, exercise)
|
||||
return max(values[0], 0.0)
|
||||
|
||||
@router.post("/real-option")
|
||||
def api_real_option(data: dict):
|
||||
"""实物期权计算器"""
|
||||
try:
|
||||
opt_type = data.get("opt_type", "expansion") # expansion|abandon|delay|shrink
|
||||
model = data.get("model", "bs") # bs|binomial
|
||||
S0 = float(data.get("S0", 100.0))
|
||||
X = float(data.get("X", 80.0))
|
||||
t = float(data.get("t", 3.0))
|
||||
r = float(data.get("r", 0.0174))
|
||||
sigma = float(data.get("sigma", 0.30))
|
||||
expansion_factor = float(data.get("expansion_factor", 1.5))
|
||||
salvage_value = float(data.get("salvage_value", S0 * 0.3))
|
||||
n_steps = int(data.get("n_steps", 100))
|
||||
|
||||
# 输入校验
|
||||
if S0 <= 0 or X <= 0 or t <= 0 or sigma <= 0:
|
||||
raise HTTPException(400, "参数必须为正数")
|
||||
if sigma > 2.0:
|
||||
raise HTTPException(400, "波动率σ不能超过200%")
|
||||
|
||||
result = {"option_type": opt_type, "model": model, "S0": S0, "X": X, "t": t, "r": r, "sigma": sigma}
|
||||
|
||||
# 计算期权价值
|
||||
if opt_type in ("expansion", "delay") and model == "bs":
|
||||
bs = _black_scholes_call(S0, X, t, r, sigma)
|
||||
result["option_value"] = bs["value"]
|
||||
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
|
||||
elif opt_type == "expansion" and model == "binomial":
|
||||
adj_X = X / expansion_factor
|
||||
bt_val = _binomial_tree_call(S0, adj_X, t, r, sigma, n_steps)
|
||||
option_value = max(bt_val * expansion_factor, 0.0)
|
||||
result["option_value"] = round(option_value, 4)
|
||||
result["intermediate"] = {"expansion_factor": expansion_factor, "adjusted_X": round(adj_X, 4), "tree_value": round(bt_val, 4)}
|
||||
elif opt_type == "delay" and model == "binomial":
|
||||
option_value = _binomial_tree_call(S0, X, t, r, sigma, n_steps)
|
||||
result["option_value"] = round(option_value, 4)
|
||||
# Also compute BS for reference
|
||||
bs = _black_scholes_call(S0, X, t, r, sigma)
|
||||
result["intermediate"] = {"n_steps": n_steps, "bs_reference": round(bs["value"], 4)}
|
||||
elif opt_type in ("abandon", "shrink") and model == "bs":
|
||||
effective_X = salvage_value if opt_type == "abandon" else X
|
||||
bs = _black_scholes_put(S0, effective_X, t, r, sigma)
|
||||
result["option_value"] = bs["value"]
|
||||
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
|
||||
if opt_type == "abandon":
|
||||
result["intermediate"]["salvage_value"] = effective_X
|
||||
elif opt_type == "abandon" and model == "binomial":
|
||||
bt_val = _binomial_tree_american_put(S0, salvage_value, t, r, sigma, n_steps)
|
||||
result["option_value"] = round(bt_val, 4)
|
||||
result["intermediate"] = {"n_steps": n_steps, "salvage_value": salvage_value}
|
||||
else:
|
||||
raise HTTPException(400, f"不支持的组合: {opt_type} + {model}")
|
||||
|
||||
# 决策建议
|
||||
val = result["option_value"]
|
||||
if val > 0:
|
||||
result["suggestion"] = "期权价值 > 0,管理弹性有价值,建议保留决策弹性,在有利时机行权"
|
||||
result["suggestion_type"] = "positive"
|
||||
else:
|
||||
result["suggestion"] = "期权价值 ≈ 0,弹性无明显价值,建议按传统NPV决策,无需等待"
|
||||
result["suggestion_type"] = "neutral"
|
||||
|
||||
# 扩展NPV(假设传统NPV = S0 - X)
|
||||
npv_without = S0 - X
|
||||
expanded_npv = npv_without + val
|
||||
result["npv_without_flexibility"] = round(npv_without, 4)
|
||||
result["expanded_npv"] = round(expanded_npv, 4)
|
||||
|
||||
if expanded_npv > 0:
|
||||
result["decision"] = "✅ 扩展NPV > 0,含弹性后项目整体值得投资"
|
||||
else:
|
||||
result["decision"] = "❌ 扩展NPV ≤ 0,含弹性后项目仍不值得投资"
|
||||
|
||||
# 敏感性分析数据(σ从10%~90%变化)
|
||||
sensitivity = []
|
||||
for s_pct in range(5, 96, 5):
|
||||
s = s_pct / 100.0
|
||||
if opt_type in ("expansion", "delay"):
|
||||
if model == "bs":
|
||||
v = _black_scholes_call(S0, X, t, r, s)["value"]
|
||||
else:
|
||||
bt = _binomial_tree_call(S0, X, t, r, s, n_steps)
|
||||
v = bt * expansion_factor if opt_type == "expansion" else bt
|
||||
else:
|
||||
eff_X = salvage_value if opt_type == "abandon" else X
|
||||
if model == "bs":
|
||||
v = _black_scholes_put(S0, eff_X, t, r, s)["value"]
|
||||
else:
|
||||
v = _binomial_tree_american_put(S0, eff_X, t, r, s, n_steps)
|
||||
sensitivity.append({"sigma": s_pct, "option_value": round(v, 4)})
|
||||
result["sensitivity"] = sensitivity
|
||||
|
||||
# 警告提示
|
||||
warnings = []
|
||||
if t * sigma * sigma * 0.5 > r:
|
||||
warnings.append("高波动+长时间,延迟价值显著")
|
||||
if S0 < X:
|
||||
warnings.append("价外期权,期权价值较低")
|
||||
if S0 > X * 1.5:
|
||||
warnings.append("深度价内,几乎确定行权")
|
||||
if sigma < 0.10:
|
||||
warnings.append("波动率过低,期权价值趋近于0")
|
||||
if t > 10:
|
||||
warnings.append("长期期权,贴现因子影响大")
|
||||
result["warnings"] = warnings
|
||||
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"实物期权计算失败: {str(e)}")
|
||||
|
||||
|
||||
# ── 增长质量诊断 ─────────────────────────────────────────────
|
||||
|
||||
def _score_revenue_structure(entity: dict) -> int:
|
||||
"""营收结构评分:渠补率越低越好"""
|
||||
rebate_rate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||||
if rebate_rate > 80: return 1
|
||||
if rebate_rate > 60: return 2
|
||||
if rebate_rate > 40: return 3
|
||||
if rebate_rate > 20: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _score_profit_structure(entity: dict) -> int:
|
||||
"""利润结构评分:真实毛利率越高越好"""
|
||||
gross_margin = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||||
if gross_margin < 0: return 1
|
||||
if gross_margin < 10: return 2
|
||||
if gross_margin < 20: return 3
|
||||
if gross_margin < 30: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _score_cash_assets(entity: dict) -> int:
|
||||
"""现金资产评分:现金比率越高越好"""
|
||||
cash_ratio = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||||
if cash_ratio < 10: return 1
|
||||
if cash_ratio < 30: return 2
|
||||
if cash_ratio < 50: return 3
|
||||
if cash_ratio < 100: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _score_growth_driver(entity: dict) -> int:
|
||||
"""增长驱动评分:费用增速相对收入增速越低越好"""
|
||||
expense_growth = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||||
revenue_growth = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
|
||||
if revenue_growth <= 0: revenue_growth = 1 # prevent div by zero
|
||||
ratio = expense_growth / revenue_growth
|
||||
if ratio > 1.5: return 1
|
||||
if ratio > 1.2: return 2
|
||||
if ratio > 1.0: return 3
|
||||
if ratio > 0.8: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _score_org_efficiency(entity: dict) -> int:
|
||||
"""组织效率评分:管理费/净收入越低越好"""
|
||||
mgmt_ratio = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||||
if mgmt_ratio > 300: return 1
|
||||
if mgmt_ratio > 200: return 2
|
||||
if mgmt_ratio > 100: return 3
|
||||
if mgmt_ratio > 50: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _diagnosis_text(overall: float, dimensions: dict, entity_name: str) -> str:
|
||||
"""根据评分生成诊断结论"""
|
||||
lines = []
|
||||
low_dims = {k: v for k, v in dimensions.items() if v["score"] <= 2}
|
||||
mid_dims = {k: v for k, v in dimensions.items() if 2 < v["score"] < 4}
|
||||
|
||||
dim_labels = {
|
||||
"revenueStructure": "营收结构",
|
||||
"profitStructure": "利润结构",
|
||||
"cashAssets": "现金资产",
|
||||
"growthDriver": "增长驱动",
|
||||
"orgEfficiency": "组织效率",
|
||||
}
|
||||
|
||||
if overall < 2:
|
||||
lines.append(f"{entity_name}的增长质量评分仅{overall}分,属于「越增长越重」类型。")
|
||||
lines.append("增长主要依赖资源投入而非核心能力积累,可持续性堪忧。")
|
||||
elif overall < 3:
|
||||
lines.append(f"{entity_name}的增长质量评分{overall}分,需重点关注。")
|
||||
lines.append("部分维度存在风险,增长质量有待改善。")
|
||||
elif overall < 4:
|
||||
lines.append(f"{entity_name}的增长质量评分{overall}分,处于中等水平。")
|
||||
lines.append("多数维度表现尚可,仍有优化空间。")
|
||||
else:
|
||||
lines.append(f"{entity_name}的增长质量评分{overall}分,「越增长越轻」。")
|
||||
lines.append("增长模式健康,具备持续增长能力。")
|
||||
|
||||
if low_dims:
|
||||
low_names = [dim_labels.get(k, k) for k in low_dims]
|
||||
lines.append(f"⚠️ 需重点关注:{'、'.join(low_names)}评分偏低(≤2分)。")
|
||||
|
||||
if mid_dims:
|
||||
mid_names = [dim_labels.get(k, k) for k in mid_dims]
|
||||
lines.append(f"💡 可优化:{'、'.join(mid_names)}有提升空间。")
|
||||
|
||||
# 具体建议(硬编码的关键诊断)
|
||||
if dimensions.get("revenueStructure", {}).get("score", 5) <= 2:
|
||||
lines.append("• 营收依赖渠道返利,建议降低渠补率、拓展直销渠道。")
|
||||
if dimensions.get("orgEfficiency", {}).get("score", 5) <= 2:
|
||||
lines.append("• 管理费率高企,建议精简费用结构、优化运营效率。")
|
||||
if dimensions.get("cashAssets", {}).get("score", 5) <= 2:
|
||||
lines.append("• 现金比率极低,存在断流风险,建议加强现金流管理。")
|
||||
if dimensions.get("growthDriver", {}).get("score", 5) <= 2:
|
||||
lines.append("• 费用增速远超收入增速,增长不可持续,需控制费用膨胀。")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_improvement_suggestions(dimension: str, score: int, entity: dict) -> list:
|
||||
"""为指定维度生成改善建议"""
|
||||
suggestions = []
|
||||
if dimension == "revenueStructure":
|
||||
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||||
if score <= 2:
|
||||
target_rebate = max(rebate - 10, 0)
|
||||
savings = f"释放现金{round(rebate - target_rebate, 1)}%/月"
|
||||
suggestions.append(f"渠补谈判:{rebate}%→{target_rebate}%({savings})")
|
||||
suggestions.append("客户分散:拓展直销渠道,降低渠道依赖")
|
||||
suggestions.append("渠补制度:分级管理,差异化返利")
|
||||
else:
|
||||
suggestions.append("维持现有渠补政策")
|
||||
elif dimension == "profitStructure":
|
||||
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||||
if score <= 2:
|
||||
suggestions.append(f"成本优化:毛利率仅{gm}%,需分析成本构成")
|
||||
suggestions.append("产品结构:提高高毛利产品占比")
|
||||
suggestions.append("定价策略:评估提价空间")
|
||||
else:
|
||||
suggestions.append("维持毛利率水平")
|
||||
elif dimension == "cashAssets":
|
||||
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||||
if score <= 2:
|
||||
suggestions.append(f"现金管理:现金比率仅{cr}%,存在断流风险")
|
||||
suggestions.append("应收账款:加快回款周期")
|
||||
suggestions.append("融资安排:准备短期授信额度")
|
||||
else:
|
||||
suggestions.append("维持现金流健康")
|
||||
elif dimension == "growthDriver":
|
||||
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||||
if score <= 2:
|
||||
suggestions.append(f"费用管控:费用增速{eg}倍于收入,需严控费用")
|
||||
suggestions.append("预算管理:建立费用增长红线机制")
|
||||
suggestions.append("投资回报:评估每项投入的ROI")
|
||||
else:
|
||||
suggestions.append("维持费用增长与收入增长匹配")
|
||||
elif dimension == "orgEfficiency":
|
||||
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||||
if score <= 2:
|
||||
suggestions.append(f"管理效率:管理费/净收入{mr}%,急需降本增效")
|
||||
suggestions.append("组织精简:评估管理层级压缩空间")
|
||||
suggestions.append("流程优化:推进数字化降本")
|
||||
else:
|
||||
suggestions.append("维持管理效率水平")
|
||||
return suggestions
|
||||
|
||||
|
||||
def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
|
||||
"""获取维度的明细诊断指标"""
|
||||
indicators = []
|
||||
if dimension == "revenueStructure":
|
||||
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||||
net_ratio = round(100 - rebate, 1)
|
||||
indicators.append({"label": "渠补率", "value": f"{rebate}%",
|
||||
"verdict": "收入依赖渠道返利" if rebate > 50 else "渠道依赖程度中等",
|
||||
"status": "danger" if rebate > 50 else "warning" if rebate > 20 else "success"})
|
||||
indicators.append({"label": "净收入占比", "value": f"{net_ratio}%",
|
||||
"verdict": f"仅{net_ratio}%归公司" if net_ratio < 30 else "净收入占比合理",
|
||||
"status": "danger" if net_ratio < 30 else "success"})
|
||||
elif dimension == "profitStructure":
|
||||
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||||
indicators.append({"label": "真实毛利率", "value": f"{gm}%",
|
||||
"verdict": "毛利偏低" if gm < 15 else "毛利正常",
|
||||
"status": "danger" if gm < 10 else "warning" if gm < 20 else "success"})
|
||||
elif dimension == "cashAssets":
|
||||
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||||
indicators.append({"label": "现金比率", "value": f"{cr}%",
|
||||
"verdict": "断流风险" if cr < 5 else "现金紧张" if cr < 30 else "现金充足",
|
||||
"status": "danger" if cr < 5 else "warning" if cr < 30 else "success"})
|
||||
elif dimension == "growthDriver":
|
||||
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||||
rg = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
|
||||
ratio = eg / rg if rg > 0 else 99
|
||||
indicators.append({"label": "费用增速/收入增速", "value": f"{ratio:.1f}倍",
|
||||
"verdict": "费用增速过快" if ratio > 1.5 else "费用可控" if ratio > 1 else "增长健康",
|
||||
"status": "danger" if ratio > 1.5 else "warning" if ratio > 1 else "success"})
|
||||
elif dimension == "orgEfficiency":
|
||||
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||||
indicators.append({"label": "管理费/净收入", "value": f"{mr}%",
|
||||
"verdict": "管理费极高" if mr > 200 else "管理费偏高" if mr > 100 else "管理费正常",
|
||||
"status": "danger" if mr > 200 else "warning" if mr > 100 else "success"})
|
||||
return indicators
|
||||
|
||||
|
||||
@router.post("/growth-quality")
|
||||
def api_growth_quality(request: Request, data: dict):
|
||||
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
|
||||
try:
|
||||
entity_id = resolve_entity_for_request(request, data.get("entity_id"))
|
||||
ENTITY_DATA = {
|
||||
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447},
|
||||
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4},
|
||||
}
|
||||
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
|
||||
entity_name = entity.get("entity", entity.get("name", "该企业"))
|
||||
period = entity.get("period", data.get("period", "当前"))
|
||||
|
||||
# 五维度评分
|
||||
dim_scores = {
|
||||
"revenueStructure": _score_revenue_structure(entity),
|
||||
"profitStructure": _score_profit_structure(entity),
|
||||
"cashAssets": _score_cash_assets(entity),
|
||||
"growthDriver": _score_growth_driver(entity),
|
||||
"orgEfficiency": _score_org_efficiency(entity),
|
||||
}
|
||||
|
||||
overall = round(sum(dim_scores.values()) / 5, 1)
|
||||
|
||||
# 综合等级
|
||||
if overall >= 4:
|
||||
level = "🟢 越增长越轻"
|
||||
level_type = "excellent"
|
||||
elif overall >= 3:
|
||||
level = "🟡 增长质量中等"
|
||||
level_type = "medium"
|
||||
elif overall >= 2:
|
||||
level = "🟠 需关注"
|
||||
level_type = "warning"
|
||||
else:
|
||||
level = "🔴 越增长越重"
|
||||
level_type = "danger"
|
||||
|
||||
# 诊断结论
|
||||
dimensions_payload = {}
|
||||
detail_payload = {}
|
||||
for dim, score in dim_scores.items():
|
||||
dimensions_payload[dim] = {"score": score, "weight": 20}
|
||||
detail_payload[dim] = {
|
||||
"score": score,
|
||||
"indicators": _get_dim_detail_indicators(dim, entity),
|
||||
"suggestions": _generate_improvement_suggestions(dim, score, entity),
|
||||
}
|
||||
|
||||
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
|
||||
|
||||
# 对比数据(如果请求中包含多个实体)
|
||||
compare = data.get("compare", None)
|
||||
compare_result = None
|
||||
if compare:
|
||||
compare_entity = compare
|
||||
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
|
||||
cdims = {
|
||||
"revenueStructure": _score_revenue_structure(compare_entity),
|
||||
"profitStructure": _score_profit_structure(compare_entity),
|
||||
"cashAssets": _score_cash_assets(compare_entity),
|
||||
"growthDriver": _score_growth_driver(compare_entity),
|
||||
"orgEfficiency": _score_org_efficiency(compare_entity),
|
||||
}
|
||||
compare_overall = round(sum(cdims.values()) / 5, 1)
|
||||
compare_result = {
|
||||
"entity_name": compare_name,
|
||||
"overall": compare_overall,
|
||||
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
|
||||
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
|
||||
"🟡 增长质量中等" if compare_overall >= 3 else
|
||||
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
|
||||
}
|
||||
|
||||
return {
|
||||
"entity_name": entity_name,
|
||||
"period": period,
|
||||
"overall": overall,
|
||||
"level": level,
|
||||
"level_type": level_type,
|
||||
"dimensions": dimensions_payload,
|
||||
"detail": detail_payload,
|
||||
"diagnosis": diagnosis,
|
||||
"compare": compare_result,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
|
||||
|
||||
|
||||
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
||||
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
||||
MODELS, forecast_kpi, forecast_finance_kpis,
|
||||
MACRO_FACTORS, factor_sensitivity_for_kpi, factor_sensitivity_with_history,
|
||||
adjusted_next_with_factor, save_forecast_logs,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/kpi-forecast")
|
||||
def api_kpi_forecast(
|
||||
kpi_code: str,
|
||||
periods: int = 3,
|
||||
model: str = "linear",
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""单个财务KPI预测 — 线性回归/移动平均,多租户隔离(entity_id 权限校验)"""
|
||||
if periods < 0 or periods > 24:
|
||||
raise HTTPException(400, "periods 必须在 0~24 之间")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
result = forecast_kpi(entity_id, kpi_code, db, periods=periods, model=model)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
404,
|
||||
f"KPI {kpi_code} 在企业 entity_id={entity_id} 下不存在,或历史数据不足(至少2条)",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/kpi-forecast/finance")
|
||||
def api_kpi_forecast_finance(
|
||||
periods: int = 3,
|
||||
model: str = "linear",
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""批量预测该企业全部财务维度KPI(历史≥3条),按可预测性排序"""
|
||||
if periods < 0 or periods > 24:
|
||||
raise HTTPException(400, "periods 必须在 0~24 之间")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
|
||||
try:
|
||||
save_forecast_logs(entity_id, results, db, model=model) # 升级2a: 预测落库(供偏差告警)
|
||||
except Exception as e:
|
||||
logger.warning(f"预测落库失败(不影响返回): {e}")
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"model": model,
|
||||
"periods": periods,
|
||||
"total": len(results),
|
||||
"data": results,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/kpi-forecast/sensitivity")
|
||||
def api_kpi_forecast_sensitivity(
|
||||
pct: float = Query(10, description="宏观因素变动幅度% (±)"),
|
||||
periods: int = Query(3),
|
||||
model: str = Query("linear"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""宏观敏感性因素联动(IMA 2026.7)— 财务KPI × 宏观因素(油价/汇率/CPI)敏感性矩阵
|
||||
输出:每个KPI的预测值 + 各因素 ±pct% 情景下的调整后预测值
|
||||
MVP:弹性系数为规则推断(按KPI类别),诚实标注"模型弹性"非历史回归"""
|
||||
if abs(pct) > 50:
|
||||
raise HTTPException(400, "pct 必须在 ±50 以内")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
|
||||
matrix = []
|
||||
for r in results:
|
||||
kpi_info = r.get("kpi", {})
|
||||
# v2: 有历史数据用变化率弹性校准,无数据回退规则推断
|
||||
sens = factor_sensitivity_with_history(
|
||||
kpi_info.get("name", ""), kpi_info.get("code", ""), r.get("history", []))
|
||||
next_val = r.get("next_target")
|
||||
factor_effects = []
|
||||
for s in sens:
|
||||
up_val = adjusted_next_with_factor(next_val, pct, s["direction"], s["elasticity"])
|
||||
down_val = adjusted_next_with_factor(next_val, -pct, s["direction"], s["elasticity"])
|
||||
factor_effects.append({
|
||||
"factor_key": s["factor_key"],
|
||||
"factor_name": s["factor_name"],
|
||||
"factor_unit": s["factor_unit"],
|
||||
"direction": s["direction"],
|
||||
"elasticity": s["elasticity"],
|
||||
"elasticity_source": s.get("elasticity_source", "rule"),
|
||||
"matched_periods": s.get("matched_periods"),
|
||||
"rule_direction": s.get("rule_direction"),
|
||||
"adj_up": up_val,
|
||||
"adj_down": down_val,
|
||||
})
|
||||
matrix.append({
|
||||
"kpi": kpi_info,
|
||||
"category": sens[0]["category"] if sens else "profit",
|
||||
"next_target": next_val,
|
||||
"confidence": r.get("confidence"),
|
||||
"trend": r.get("trend"),
|
||||
"factors": factor_effects,
|
||||
})
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"model": model,
|
||||
"periods": periods,
|
||||
"pct": pct,
|
||||
"factors": MACRO_FACTORS,
|
||||
"total": len(matrix),
|
||||
"data": matrix,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""波士顿产品矩阵 API — 四象限分析"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import ProductSales
|
||||
from collections import defaultdict
|
||||
|
||||
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"])
|
||||
|
||||
|
||||
def _calc_quadrant(trend: float, margin: float) -> str:
|
||||
"""四象限分类:
|
||||
横轴=近3月销售趋势(正=增长),纵轴=毛利率
|
||||
明星(Star) = 高趋势+高毛利
|
||||
现金牛(CashCow) = 低趋势+高毛利
|
||||
问题(QuestionMark) = 高趋势+低毛利
|
||||
瘦狗(Dog) = 低趋势+低毛利
|
||||
"""
|
||||
trend_high = trend >= 0
|
||||
margin_high = margin >= 0
|
||||
if trend_high and margin_high:
|
||||
return "star"
|
||||
if not trend_high and margin_high:
|
||||
return "cash_cow"
|
||||
if trend_high and not margin_high:
|
||||
return "question_mark"
|
||||
return "dog"
|
||||
|
||||
|
||||
@router.get("/matrix")
|
||||
def get_product_matrix(
|
||||
entity_id: int = Query(1, description="1=酣客 2=博海"),
|
||||
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额"""
|
||||
# 取最近 months+1 个月(多取1个月用于计算趋势)
|
||||
periods = db.query(ProductSales.period_month).filter(
|
||||
ProductSales.entity_id == entity_id
|
||||
).distinct().order_by(ProductSales.period_month.desc()).limit(months + 1).all()
|
||||
periods = sorted([p[0] for p in periods])
|
||||
|
||||
if len(periods) < 2:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"has_data": False,
|
||||
"message": "数据不足,至少需要2个月数据",
|
||||
"quadrants": [],
|
||||
"products": [],
|
||||
}
|
||||
|
||||
trend_periods = periods[-months:] # 最近 months 个月
|
||||
prev_periods = periods[:-months] if len(periods) > months else periods[:1]
|
||||
|
||||
# 加载数据
|
||||
rows = db.query(ProductSales).filter(
|
||||
ProductSales.entity_id == entity_id,
|
||||
ProductSales.period_month.in_(periods),
|
||||
).all()
|
||||
|
||||
# 按商品聚合
|
||||
products = defaultdict(lambda: {
|
||||
"code": "", "name": "", "months": {},
|
||||
"total_sales": 0, "total_qty": 0, "total_gross": 0,
|
||||
})
|
||||
for row in rows:
|
||||
p = products[row.product_code]
|
||||
p["code"] = row.product_code
|
||||
p["name"] = row.product_name
|
||||
p["months"][row.period_month] = {
|
||||
"sales": float(row.sales_amount or 0),
|
||||
"margin": float(row.gross_margin_rate or 0),
|
||||
"gross": float(row.gross_profit or 0),
|
||||
"qty": int(row.sales_qty or 0),
|
||||
}
|
||||
p["total_sales"] += float(row.sales_amount or 0)
|
||||
p["total_qty"] += int(row.sales_qty or 0)
|
||||
p["total_gross"] += float(row.gross_profit or 0)
|
||||
|
||||
# 计算每个商品的趋势和毛利率
|
||||
result_products = []
|
||||
for code, p in products.items():
|
||||
# 趋势 = 最近月份 vs 前一月的环比(取趋势期间的平均环比增速)
|
||||
# 用最近3个月的销售序列做简单线性趋势
|
||||
trend_sales = []
|
||||
for pp in periods:
|
||||
trend_sales.append(p["months"].get(pp, {}).get("sales", 0))
|
||||
# 线性回归斜率(最小二乘)
|
||||
n = len(trend_sales)
|
||||
if n >= 2:
|
||||
xs = list(range(n))
|
||||
x_mean = sum(xs) / n
|
||||
y_mean = sum(trend_sales) / n
|
||||
numerator = sum((xs[i] - x_mean) * (trend_sales[i] - y_mean) for i in range(n))
|
||||
denominator = sum((xs[i] - x_mean) ** 2 for i in range(n))
|
||||
slope = numerator / denominator if denominator else 0
|
||||
# 斜率转为百分比(相对期间平均销售)
|
||||
avg = y_mean if y_mean != 0 else 1
|
||||
trend = slope / abs(avg) * 100
|
||||
else:
|
||||
trend = 0.0
|
||||
|
||||
# 毛利率 = 加权平均(按销售额)
|
||||
weighted_margin = 0.0
|
||||
total_sales_for_margin = 0
|
||||
for pp in trend_periods:
|
||||
m = p["months"].get(pp)
|
||||
if m and m["sales"] > 0:
|
||||
weighted_margin += m["margin"] * m["sales"]
|
||||
total_sales_for_margin += m["sales"]
|
||||
if total_sales_for_margin > 0:
|
||||
weighted_margin = weighted_margin / total_sales_for_margin
|
||||
else:
|
||||
# 无销售用平均毛利率
|
||||
margins = [p["months"][pp]["margin"] for pp in p["months"] if p["months"][pp]["margin"] != 0]
|
||||
weighted_margin = sum(margins) / len(margins) if margins else 0
|
||||
|
||||
quadrant = _calc_quadrant(trend, weighted_margin)
|
||||
|
||||
result_products.append({
|
||||
"code": code,
|
||||
"name": p["name"],
|
||||
"total_sales": round(p["total_sales"], 2),
|
||||
"total_qty": p["total_qty"],
|
||||
"total_gross": round(p["total_gross"], 2),
|
||||
"trend_pct": round(trend, 1),
|
||||
"margin_pct": round(weighted_margin, 1),
|
||||
"quadrant": quadrant,
|
||||
})
|
||||
|
||||
# 按销售额排序
|
||||
result_products.sort(key=lambda x: -x["total_sales"])
|
||||
|
||||
# 四象限汇总
|
||||
quadrant_labels = {
|
||||
"star": {"label": "明星产品", "icon": "🌟", "advice": "高增长+有毛利,重点主推,加大投入"},
|
||||
"cash_cow": {"label": "现金牛", "icon": "🥇", "advice": "销量大但增长放缓,维持稳定产出"},
|
||||
"question_mark": {"label": "问题产品", "icon": "❓", "advice": "增长好但毛利低,优化成本或提价"},
|
||||
"dog": {"label": "瘦狗产品", "icon": "🐶", "advice": "低增长+低毛利,考虑清库存或停产"},
|
||||
}
|
||||
quadrants = []
|
||||
for q in ["star", "cash_cow", "question_mark", "dog"]:
|
||||
items = [p for p in result_products if p["quadrant"] == q]
|
||||
quadrants.append({
|
||||
"key": q,
|
||||
**quadrant_labels[q],
|
||||
"count": len(items),
|
||||
"products": items,
|
||||
})
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"has_data": True,
|
||||
"periods": periods,
|
||||
"months_analyzed": months,
|
||||
"quadrants": quadrants,
|
||||
"products": result_products,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,95 @@
|
||||
"""会计科目管理 — 新30号准则适配"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import Optional, List
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import Subject
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.subjects")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/subjects", tags=["会计科目"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_subjects(
|
||||
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
|
||||
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""科目列表 — 支持新30号准则分类筛选"""
|
||||
query = db.query(Subject).filter(Subject.is_active == 1)
|
||||
|
||||
if category:
|
||||
query = query.filter(Subject.new_standard_category == category)
|
||||
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.filter(
|
||||
Subject.subject_name.like(like) | Subject.subject_code.like(like)
|
||||
)
|
||||
|
||||
subjects = query.order_by(Subject.subject_code).all()
|
||||
|
||||
return {
|
||||
"total": len(subjects),
|
||||
"data": [
|
||||
{
|
||||
"id": s.id,
|
||||
"subject_code": s.subject_code,
|
||||
"subject_name": s.subject_name,
|
||||
"parent_code": s.parent_code,
|
||||
"level": s.level,
|
||||
"category": s.category,
|
||||
"new_standard_category": s.new_standard_category,
|
||||
"is_active": s.is_active,
|
||||
"remark": s.remark,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
}
|
||||
for s in subjects
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{subject_id}")
|
||||
def update_subject_category(
|
||||
subject_id: int,
|
||||
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""更新单个科目的新30号准则分类"""
|
||||
subject = db.query(Subject).filter(Subject.id == subject_id).first()
|
||||
if not subject:
|
||||
raise HTTPException(status_code=404, detail="科目不存在")
|
||||
|
||||
valid = {"operating", "operating_rd", "operating_fx", "investing",
|
||||
"financing", "financing_fx", "tax", "discontinued"}
|
||||
if new_standard_category not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
|
||||
|
||||
subject.new_standard_category = new_standard_category
|
||||
db.commit()
|
||||
return {"message": "更新成功", "subject_id": subject_id, "new_standard_category": new_standard_category}
|
||||
|
||||
|
||||
@router.put("/batch/category")
|
||||
def batch_update_category(
|
||||
ids: List[int] = Query(..., description="科目ID列表"),
|
||||
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""批量更新科目新30号准则分类"""
|
||||
valid = {"operating", "operating_rd", "operating_fx", "investing",
|
||||
"financing", "financing_fx", "tax", "discontinued"}
|
||||
if new_standard_category not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
|
||||
|
||||
updated = db.query(Subject).filter(
|
||||
Subject.id.in_(ids), Subject.is_active == 1
|
||||
).update({"new_standard_category": new_standard_category}, synchronize_session=False)
|
||||
db.commit()
|
||||
return {"message": f"批量更新成功", "updated_count": updated}
|
||||
@@ -0,0 +1,888 @@
|
||||
"""税务合规智能体 API — 管理会计OS
|
||||
① 税负监控: 税务记录CRUD + 税负率计算(实缴/收入×100%) + 行业基准预警(增值税3.5%/所得税2.5%, 超±20%预警)
|
||||
② 发票校验: 发票录入 + 批量校验(发票号33位数字 / 金额与报销单匹配 / 供应商与合同匹配) + 异常查询
|
||||
③ 社保比对: 缴费记录CRUD + 比对(基数与工资匹配60%~300% / 单位缴纳比例24.5% / 漏缴提醒) + 异常查询
|
||||
看板: /tax/dashboard 聚合税负趋势+行业对比 + 发票异常列表 + 社保异常列表
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import TaxRecord, InvoiceCheck, SocialSecurity, ExpenseReimbursement
|
||||
|
||||
router = APIRouter(prefix="/api/cma/tax", tags=["税务合规"])
|
||||
|
||||
# ── 行业平均税负率参考值 (%) ──
|
||||
TAX_BENCHMARKS = {
|
||||
"vat": 3.5, # 增值税平均税负率
|
||||
"income": 2.5, # 企业所得税平均税负率
|
||||
"surtax": 0.5, # 附加税平均税负率
|
||||
}
|
||||
TAX_TYPE_LABELS = {"vat": "增值税", "income": "所得税", "surtax": "附加税"}
|
||||
INVOICE_TYPE_LABELS = {"vat": "增值税专用发票", "vat_normal": "增值税普通发票", "electronic": "电子发票", "other": "其他"}
|
||||
SS_COMPANY_RATE = 24.5 # 单位缴纳比例: 养老16% + 医疗8% + 失业0.5%
|
||||
SS_RATE_TOLERANCE = 1.0 # 比例允许偏差 ±1%
|
||||
SS_BASE_LOW = 0.6 # 基数下限 = 工资60%
|
||||
SS_BASE_HIGH = 3.0 # 基数上限 = 工资300%
|
||||
INVOICE_NO_LEN = 33 # 发票号位数(全数字)
|
||||
|
||||
|
||||
# ── 序列化 ──
|
||||
def _tax_to_dict(t: TaxRecord) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"entity_id": t.entity_id,
|
||||
"period": t.period,
|
||||
"tax_type": t.tax_type,
|
||||
"tax_type_label": TAX_TYPE_LABELS.get(t.tax_type, t.tax_type),
|
||||
"tax_payable": t.tax_payable,
|
||||
"tax_paid": t.tax_paid,
|
||||
"tax_rate": t.tax_rate,
|
||||
"income": t.income,
|
||||
"tax_burden_rate": t.tax_burden_rate,
|
||||
"benchmark": TAX_BENCHMARKS.get(t.tax_type),
|
||||
"burden_status": t.burden_status,
|
||||
"warning_msg": t.warning_msg,
|
||||
"remark": t.remark,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _invoice_to_dict(i: InvoiceCheck) -> dict:
|
||||
return {
|
||||
"id": i.id,
|
||||
"entity_id": i.entity_id,
|
||||
"invoice_no": i.invoice_no,
|
||||
"amount": i.amount,
|
||||
"invoice_type": i.invoice_type,
|
||||
"invoice_type_label": INVOICE_TYPE_LABELS.get(i.invoice_type, i.invoice_type),
|
||||
"invoice_date": i.invoice_date.isoformat() if i.invoice_date else None,
|
||||
"supplier": i.supplier,
|
||||
"reimb_no": i.reimb_no,
|
||||
"contract_no": i.contract_no,
|
||||
"check_status": i.check_status,
|
||||
"check_result": i.check_result,
|
||||
"check_reason": i.check_reason,
|
||||
"checked_at": i.checked_at.isoformat() if i.checked_at else None,
|
||||
"created_at": i.created_at.isoformat() if i.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _ss_to_dict(s: SocialSecurity) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"entity_id": s.entity_id,
|
||||
"employee": s.employee,
|
||||
"period": s.period,
|
||||
"base_amount": s.base_amount,
|
||||
"salary": s.salary,
|
||||
"company_amount": s.company_amount,
|
||||
"personal_amount": s.personal_amount,
|
||||
"company_rate": s.company_rate,
|
||||
"check_status": s.check_status,
|
||||
"warning_msg": s.warning_msg,
|
||||
"remark": s.remark,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(v) -> datetime | None:
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
if isinstance(v, datetime):
|
||||
return v
|
||||
if isinstance(v, date):
|
||||
return datetime(v.year, v.month, v.day)
|
||||
return datetime.fromisoformat(str(v)[:10])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ① 税负监控 — 税务记录 CRUD + 税负率计算 + 行业基准预警
|
||||
# ============================================================
|
||||
|
||||
def _calc_burden(t: TaxRecord):
|
||||
"""计算单条税务记录的税负率 + 行业基准预警"""
|
||||
rate = None
|
||||
if t.income and t.income > 0:
|
||||
rate = round(t.tax_paid / t.income * 100, 2)
|
||||
t.tax_burden_rate = rate
|
||||
bench = TAX_BENCHMARKS.get(t.tax_type)
|
||||
t.burden_status = "normal"
|
||||
t.warning_msg = None
|
||||
if rate is None or bench is None:
|
||||
return
|
||||
low, high = bench * 0.8, bench * 1.2
|
||||
if rate < low or rate > high:
|
||||
t.burden_status = "alert"
|
||||
t.warning_msg = (
|
||||
f"税负率{rate}%超出行业均值{bench}%的±20%区间({low}%~{high}%),"
|
||||
f"{'偏高需核查进项/优惠' if rate > high else '偏低需核查申报完整性'}"
|
||||
)
|
||||
|
||||
|
||||
def _apply_burden_to_all(db: Session, entity_id: int):
|
||||
"""重算某企业全部税务记录的税负率与预警"""
|
||||
records = db.query(TaxRecord).filter(TaxRecord.entity_id == entity_id).all()
|
||||
for t in records:
|
||||
_calc_burden(t)
|
||||
db.commit()
|
||||
return records
|
||||
|
||||
|
||||
@router.get("/records")
|
||||
def list_tax_records(
|
||||
period: str = Query(None),
|
||||
tax_type: str = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询税务记录列表"""
|
||||
q = db.query(TaxRecord)
|
||||
if entity_id:
|
||||
q = q.filter(TaxRecord.entity_id == entity_id)
|
||||
if period:
|
||||
q = q.filter(TaxRecord.period == period)
|
||||
if tax_type:
|
||||
q = q.filter(TaxRecord.tax_type == tax_type)
|
||||
records = q.order_by(TaxRecord.period.desc(), TaxRecord.id.asc()).all()
|
||||
return {"data": [_tax_to_dict(t) for t in records], "total": len(records)}
|
||||
|
||||
|
||||
@router.post("/records")
|
||||
def create_tax_record(
|
||||
request: Request,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""新增税务记录"""
|
||||
period = data.get("period")
|
||||
tax_type = data.get("tax_type")
|
||||
if not period or not tax_type:
|
||||
raise HTTPException(400, "缺少必要参数: period, tax_type")
|
||||
if tax_type not in TAX_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效税种: {tax_type},可选 vat/income/surtax")
|
||||
t = TaxRecord(
|
||||
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
|
||||
period=period,
|
||||
tax_type=tax_type,
|
||||
tax_payable=data.get("tax_payable") or 0,
|
||||
tax_paid=data.get("tax_paid") or 0,
|
||||
tax_rate=data.get("tax_rate"),
|
||||
income=data.get("income") or 0,
|
||||
remark=data.get("remark"),
|
||||
)
|
||||
_calc_burden(t)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return {"message": "税务记录已创建", "data": _tax_to_dict(t)}
|
||||
|
||||
|
||||
@router.put("/records/{record_id}")
|
||||
def update_tax_record(
|
||||
record_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新税务记录(自动重算税负率与预警)"""
|
||||
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "税务记录不存在")
|
||||
if "period" in data:
|
||||
t.period = data["period"]
|
||||
if "tax_type" in data:
|
||||
if data["tax_type"] not in TAX_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效税种: {data['tax_type']}")
|
||||
t.tax_type = data["tax_type"]
|
||||
if "tax_payable" in data:
|
||||
t.tax_payable = data["tax_payable"] or 0
|
||||
if "tax_paid" in data:
|
||||
t.tax_paid = data["tax_paid"] or 0
|
||||
if "tax_rate" in data:
|
||||
t.tax_rate = data.get("tax_rate")
|
||||
if "income" in data:
|
||||
t.income = data["income"] or 0
|
||||
if "remark" in data:
|
||||
t.remark = data.get("remark")
|
||||
_calc_burden(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return {"message": "税务记录已更新", "data": _tax_to_dict(t)}
|
||||
|
||||
|
||||
@router.delete("/records/{record_id}")
|
||||
def delete_tax_record(
|
||||
record_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除税务记录"""
|
||||
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "税务记录不存在")
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"message": "税务记录已删除"}
|
||||
|
||||
|
||||
@router.get("/burden")
|
||||
def burden_analysis(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
tax_type: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""税负率计算 + 行业对比 + 预警列表(前端趋势图数据源)"""
|
||||
q = db.query(TaxRecord)
|
||||
if entity_id:
|
||||
q = q.filter(TaxRecord.entity_id == entity_id)
|
||||
if tax_type:
|
||||
q = q.filter(TaxRecord.tax_type == tax_type)
|
||||
records = q.order_by(TaxRecord.period.asc(), TaxRecord.id.asc()).all()
|
||||
for t in records:
|
||||
_calc_burden(t)
|
||||
db.commit()
|
||||
|
||||
# 按期间聚合税负率(每种税一个序列)
|
||||
trend_map: dict[str, dict] = {}
|
||||
for t in records:
|
||||
if t.tax_burden_rate is None:
|
||||
continue
|
||||
entry = trend_map.setdefault(t.period, {"period": t.period})
|
||||
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
|
||||
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
|
||||
trend = sorted(trend_map.values(), key=lambda x: x["period"])
|
||||
|
||||
alerts = [t for t in records if t.burden_status == "alert"]
|
||||
return {
|
||||
"trend": trend,
|
||||
"benchmarks": TAX_BENCHMARKS,
|
||||
"records": [_tax_to_dict(t) for t in records],
|
||||
"alerts": [_tax_to_dict(t) for t in alerts],
|
||||
"alert_count": len(alerts),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
def run_tax_check(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""手动触发税负率重算与预警检查"""
|
||||
records = _apply_burden_to_all(db, entity_id or 1)
|
||||
alerts = [t for t in records if t.burden_status == "alert"]
|
||||
return {"message": f"税负检查完成,共{len(records)}条记录,{len(alerts)}条预警", "alert_count": len(alerts)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ② 发票校验 — 录入 + 批量校验 + 异常查询
|
||||
# ============================================================
|
||||
|
||||
def _check_invoice(db: Session, inv: InvoiceCheck):
|
||||
"""发票校验规则:号码格式 / 金额与报销单匹配 / 供应商与合同匹配"""
|
||||
results: list[dict] = []
|
||||
status = "valid"
|
||||
|
||||
# 规则1: 发票号格式(33位数字)
|
||||
no = (inv.invoice_no or "").strip()
|
||||
if len(no) != INVOICE_NO_LEN or not no.isdigit():
|
||||
status = "invalid"
|
||||
results.append({
|
||||
"rule": "发票号格式",
|
||||
"passed": False,
|
||||
"message": f"发票号格式错误:应为{INVOICE_NO_LEN}位纯数字,当前{len(no)}位",
|
||||
})
|
||||
else:
|
||||
results.append({"rule": "发票号格式", "passed": True, "message": "33位数字格式正确"})
|
||||
|
||||
# 规则2: 金额与报销单匹配
|
||||
if inv.reimb_no:
|
||||
reimb = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == inv.reimb_no).first()
|
||||
if not reimb:
|
||||
status = "invalid"
|
||||
results.append({"rule": "报销单匹配", "passed": False, "message": f"报销单{inv.reimb_no}不存在"})
|
||||
elif abs((inv.amount or 0) - (reimb.amount or 0)) > 0.01:
|
||||
status = "invalid"
|
||||
results.append({
|
||||
"rule": "报销单匹配",
|
||||
"passed": False,
|
||||
"message": f"发票金额{inv.amount}元与报销单{inv.reimb_no}金额{reimb.amount}元不符",
|
||||
})
|
||||
else:
|
||||
results.append({"rule": "报销单匹配", "passed": True, "message": f"与报销单{inv.reimb_no}金额一致"})
|
||||
|
||||
# 规则3: 供应商与合同匹配
|
||||
if inv.contract_no:
|
||||
if not (inv.supplier or "").strip():
|
||||
status = "invalid"
|
||||
results.append({"rule": "供应商匹配", "passed": False, "message": f"合同{inv.contract_no}未关联供应商"})
|
||||
else:
|
||||
# 同合同下其他发票的供应商一致性
|
||||
others = (
|
||||
db.query(InvoiceCheck)
|
||||
.filter(
|
||||
InvoiceCheck.contract_no == inv.contract_no,
|
||||
InvoiceCheck.id != inv.id,
|
||||
InvoiceCheck.supplier.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
mismatch = [o.supplier for o in others if o.supplier != inv.supplier]
|
||||
if mismatch:
|
||||
status = "invalid"
|
||||
results.append({
|
||||
"rule": "供应商匹配",
|
||||
"passed": False,
|
||||
"message": f"供应商{inv.supplier}与合同{inv.contract_no}下其他发票供应商{mismatch[0]}不一致",
|
||||
})
|
||||
else:
|
||||
results.append({"rule": "供应商匹配", "passed": True, "message": f"供应商与合同{inv.contract_no}匹配"})
|
||||
|
||||
inv.check_result = results
|
||||
inv.check_status = status
|
||||
inv.check_reason = ";".join(r["message"] for r in results if not r["passed"]) or None
|
||||
inv.checked_at = datetime.now()
|
||||
|
||||
|
||||
@router.get("/invoices")
|
||||
def list_invoices(
|
||||
status: str = Query(None),
|
||||
keyword: str = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""发票列表"""
|
||||
q = db.query(InvoiceCheck)
|
||||
if entity_id:
|
||||
q = q.filter(InvoiceCheck.entity_id == entity_id)
|
||||
if status:
|
||||
q = q.filter(InvoiceCheck.check_status == status)
|
||||
if keyword:
|
||||
kw = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
(InvoiceCheck.invoice_no.like(kw))
|
||||
| (InvoiceCheck.supplier.like(kw))
|
||||
| (InvoiceCheck.reimb_no.like(kw))
|
||||
)
|
||||
invoices = q.order_by(InvoiceCheck.id.desc()).all()
|
||||
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
|
||||
|
||||
|
||||
@router.post("/invoices")
|
||||
def create_invoice(
|
||||
request: Request,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""录入发票(自动执行校验)"""
|
||||
invoice_no = data.get("invoice_no")
|
||||
if not invoice_no:
|
||||
raise HTTPException(400, "缺少必要参数: invoice_no")
|
||||
if data.get("amount") is None:
|
||||
raise HTTPException(400, "缺少必要参数: amount")
|
||||
inv = InvoiceCheck(
|
||||
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
|
||||
invoice_no=str(invoice_no).strip(),
|
||||
amount=data.get("amount"),
|
||||
invoice_type=data.get("invoice_type") or "vat",
|
||||
invoice_date=_parse_date(data.get("invoice_date")),
|
||||
supplier=data.get("supplier"),
|
||||
reimb_no=data.get("reimb_no"),
|
||||
contract_no=data.get("contract_no"),
|
||||
)
|
||||
_check_invoice(db, inv)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
db.refresh(inv)
|
||||
return {"message": "发票已录入并校验", "data": _invoice_to_dict(inv)}
|
||||
|
||||
|
||||
@router.put("/invoices/{invoice_id}")
|
||||
def update_invoice(
|
||||
invoice_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新发票(自动重新校验)"""
|
||||
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
|
||||
if not inv:
|
||||
raise HTTPException(404, "发票不存在")
|
||||
if "invoice_no" in data:
|
||||
inv.invoice_no = str(data["invoice_no"]).strip()
|
||||
if "amount" in data:
|
||||
inv.amount = data["amount"]
|
||||
if "invoice_type" in data:
|
||||
inv.invoice_type = data["invoice_type"]
|
||||
if "invoice_date" in data:
|
||||
inv.invoice_date = _parse_date(data.get("invoice_date"))
|
||||
if "supplier" in data:
|
||||
inv.supplier = data.get("supplier")
|
||||
if "reimb_no" in data:
|
||||
inv.reimb_no = data.get("reimb_no")
|
||||
if "contract_no" in data:
|
||||
inv.contract_no = data.get("contract_no")
|
||||
_check_invoice(db, inv)
|
||||
db.commit()
|
||||
db.refresh(inv)
|
||||
return {"message": "发票已更新并重新校验", "data": _invoice_to_dict(inv)}
|
||||
|
||||
|
||||
@router.delete("/invoices/{invoice_id}")
|
||||
def delete_invoice(
|
||||
invoice_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除发票"""
|
||||
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
|
||||
if not inv:
|
||||
raise HTTPException(404, "发票不存在")
|
||||
db.delete(inv)
|
||||
db.commit()
|
||||
return {"message": "发票已删除"}
|
||||
|
||||
|
||||
@router.post("/invoices/check")
|
||||
def batch_check_invoices(
|
||||
invoice_id: int = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""批量校验发票(默认校验全部 pending,可指定单张)"""
|
||||
q = db.query(InvoiceCheck)
|
||||
if invoice_id:
|
||||
q = q.filter(InvoiceCheck.id == invoice_id)
|
||||
elif entity_id:
|
||||
q = q.filter(InvoiceCheck.entity_id == entity_id)
|
||||
invoices = q.all()
|
||||
for inv in invoices:
|
||||
_check_invoice(db, inv)
|
||||
db.commit()
|
||||
abnormal = [i for i in invoices if i.check_status in ("invalid", "warning")]
|
||||
return {
|
||||
"message": f"批量校验完成:{len(invoices)}张,异常{len(abnormal)}张",
|
||||
"total": len(invoices),
|
||||
"abnormal_count": len(abnormal),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invoices/abnormal")
|
||||
def list_abnormal_invoices(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
limit: int = Query(50),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询异常发票(invalid/warning)"""
|
||||
q = db.query(InvoiceCheck).filter(InvoiceCheck.check_status.in_(["invalid", "warning"]))
|
||||
if entity_id:
|
||||
q = q.filter(InvoiceCheck.entity_id == entity_id)
|
||||
invoices = q.order_by(InvoiceCheck.id.desc()).limit(limit).all()
|
||||
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ③ 社保比对 — 缴费记录 CRUD + 比对 + 异常查询
|
||||
# ============================================================
|
||||
|
||||
def _check_ss(db: Session, s: SocialSecurity, all_records: list | None = None):
|
||||
"""社保比对规则:基数与工资匹配 / 单位缴纳比例 / 漏缴(含月份断层检测)"""
|
||||
warnings: list[str] = []
|
||||
alerts: list[str] = []
|
||||
|
||||
# 规则1: 缴费基数与工资匹配(60%~300%区间)
|
||||
if s.salary and s.salary > 0 and s.base_amount and s.base_amount > 0:
|
||||
low, high = s.salary * SS_BASE_LOW, s.salary * SS_BASE_HIGH
|
||||
if s.base_amount < low or s.base_amount > high:
|
||||
warnings.append(
|
||||
f"缴费基数{s.base_amount}元超出工资{s.salary}元的{int(SS_BASE_LOW*100)}%~{int(SS_BASE_HIGH*100)}%区间({low:.0f}~{high:.0f})"
|
||||
)
|
||||
|
||||
# 规则2: 单位缴纳比例(养老16%+医疗8%+失业0.5%≈24.5%)
|
||||
rate = None
|
||||
if s.base_amount and s.base_amount > 0 and s.company_amount is not None:
|
||||
rate = round(s.company_amount / s.base_amount * 100, 2)
|
||||
s.company_rate = rate
|
||||
if rate is not None and abs(rate - SS_COMPANY_RATE) > SS_RATE_TOLERANCE:
|
||||
warnings.append(f"单位缴纳比例{rate}%与标准{SS_COMPANY_RATE}%±{SS_RATE_TOLERANCE}%不符")
|
||||
|
||||
# 规则3: 漏缴检测
|
||||
if not s.base_amount or s.base_amount <= 0 or (not s.company_amount and not s.personal_amount):
|
||||
alerts.append(f"{s.employee}本期缴费基数为0或未缴费(疑似漏缴)")
|
||||
|
||||
# 规则3b: 月份断层检测(同人相邻记录期间间隔>1个月 → 漏缴)
|
||||
if all_records is not None:
|
||||
periods = sorted(
|
||||
r.period for r in all_records
|
||||
if r.employee == s.employee and r.period != s.period
|
||||
)
|
||||
prev = None
|
||||
for p in periods:
|
||||
if prev is not None:
|
||||
try:
|
||||
py, pm = map(int, prev.split("-"))
|
||||
cy, cm = map(int, p.split("-"))
|
||||
gap = (cy - py) * 12 + (cm - pm)
|
||||
if gap > 1:
|
||||
alerts.append(f"{s.employee}在{prev}至{p}之间漏缴{max(0, gap-1)}个月")
|
||||
except Exception:
|
||||
pass
|
||||
prev = p
|
||||
|
||||
if alerts:
|
||||
s.check_status = "alert"
|
||||
elif warnings:
|
||||
s.check_status = "warning"
|
||||
else:
|
||||
s.check_status = "normal"
|
||||
s.warning_msg = ";".join(alerts + warnings) or None
|
||||
|
||||
|
||||
@router.get("/ss")
|
||||
def list_ss_records(
|
||||
period: str = Query(None),
|
||||
employee: str = Query(None),
|
||||
status: str = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""社保缴费记录列表"""
|
||||
q = db.query(SocialSecurity)
|
||||
if entity_id:
|
||||
q = q.filter(SocialSecurity.entity_id == entity_id)
|
||||
if period:
|
||||
q = q.filter(SocialSecurity.period == period)
|
||||
if employee:
|
||||
q = q.filter(SocialSecurity.employee.like(f"%{employee}%"))
|
||||
if status:
|
||||
q = q.filter(SocialSecurity.check_status == status)
|
||||
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.asc()).all()
|
||||
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
|
||||
|
||||
|
||||
@router.post("/ss")
|
||||
def create_ss_record(
|
||||
request: Request,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""新增社保缴费记录(自动比对)"""
|
||||
employee = data.get("employee")
|
||||
period = data.get("period")
|
||||
if not employee or not period:
|
||||
raise HTTPException(400, "缺少必要参数: employee, period")
|
||||
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == resolve_entity_for_request(request, data.get("entity_id") or 1)).all()
|
||||
s = SocialSecurity(
|
||||
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
|
||||
employee=employee,
|
||||
period=period,
|
||||
base_amount=data.get("base_amount") or 0,
|
||||
salary=data.get("salary"),
|
||||
company_amount=data.get("company_amount") or 0,
|
||||
personal_amount=data.get("personal_amount") or 0,
|
||||
remark=data.get("remark"),
|
||||
)
|
||||
_check_ss(db, s, all_records)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return {"message": "社保记录已创建", "data": _ss_to_dict(s)}
|
||||
|
||||
|
||||
@router.put("/ss/{ss_id}")
|
||||
def update_ss_record(
|
||||
ss_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新社保记录(自动重新比对)"""
|
||||
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
|
||||
if not s:
|
||||
raise HTTPException(404, "社保记录不存在")
|
||||
if "employee" in data:
|
||||
s.employee = data["employee"]
|
||||
if "period" in data:
|
||||
s.period = data["period"]
|
||||
if "base_amount" in data:
|
||||
s.base_amount = data["base_amount"] or 0
|
||||
if "salary" in data:
|
||||
s.salary = data.get("salary")
|
||||
if "company_amount" in data:
|
||||
s.company_amount = data["company_amount"] or 0
|
||||
if "personal_amount" in data:
|
||||
s.personal_amount = data["personal_amount"] or 0
|
||||
if "remark" in data:
|
||||
s.remark = data.get("remark")
|
||||
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == s.entity_id).all()
|
||||
_check_ss(db, s, all_records)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return {"message": "社保记录已更新", "data": _ss_to_dict(s)}
|
||||
|
||||
|
||||
@router.delete("/ss/{ss_id}")
|
||||
def delete_ss_record(
|
||||
ss_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除社保记录"""
|
||||
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
|
||||
if not s:
|
||||
raise HTTPException(404, "社保记录不存在")
|
||||
db.delete(s)
|
||||
db.commit()
|
||||
return {"message": "社保记录已删除"}
|
||||
|
||||
|
||||
@router.post("/ss/check")
|
||||
def batch_check_ss(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""批量社保比对"""
|
||||
q = db.query(SocialSecurity)
|
||||
if entity_id:
|
||||
q = q.filter(SocialSecurity.entity_id == entity_id)
|
||||
records = q.all()
|
||||
for s in records:
|
||||
_check_ss(db, s, records)
|
||||
db.commit()
|
||||
abnormal = [s for s in records if s.check_status in ("alert", "warning")]
|
||||
return {
|
||||
"message": f"社保比对完成:{len(records)}条,异常{len(abnormal)}条",
|
||||
"total": len(records),
|
||||
"abnormal_count": len(abnormal),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ss/abnormal")
|
||||
def list_abnormal_ss(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
limit: int = Query(50),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询社保异常(alert/warning)"""
|
||||
q = db.query(SocialSecurity).filter(SocialSecurity.check_status.in_(["alert", "warning"]))
|
||||
if entity_id:
|
||||
q = q.filter(SocialSecurity.entity_id == entity_id)
|
||||
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc()).limit(limit).all()
|
||||
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ④ 税务看板聚合
|
||||
# ============================================================
|
||||
|
||||
@router.get("/dashboard")
|
||||
def tax_dashboard(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""税务合规看板聚合:税负趋势+行业对比 / 发票异常 / 社保异常"""
|
||||
eid = entity_id or 1
|
||||
|
||||
# 税负监控
|
||||
records = db.query(TaxRecord).filter(TaxRecord.entity_id == eid).order_by(TaxRecord.period.asc()).all()
|
||||
for t in records:
|
||||
_calc_burden(t)
|
||||
db.commit()
|
||||
trend_map: dict[str, dict] = {}
|
||||
for t in records:
|
||||
if t.tax_burden_rate is None:
|
||||
continue
|
||||
entry = trend_map.setdefault(t.period, {"period": t.period})
|
||||
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
|
||||
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
|
||||
burden_trend = sorted(trend_map.values(), key=lambda x: x["period"])
|
||||
latest_period = max((t.period for t in records), default=None)
|
||||
latest = next((x for x in burden_trend if x["period"] == latest_period), None)
|
||||
tax_alerts = [t for t in records if t.burden_status == "alert"]
|
||||
|
||||
# 发票
|
||||
invoices = db.query(InvoiceCheck).filter(InvoiceCheck.entity_id == eid).all()
|
||||
inv_status_count = {"pending": 0, "valid": 0, "invalid": 0, "warning": 0}
|
||||
for i in invoices:
|
||||
inv_status_count[i.check_status] = inv_status_count.get(i.check_status, 0) + 1
|
||||
inv_abnormal = (
|
||||
db.query(InvoiceCheck)
|
||||
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.check_status.in_(["invalid", "warning"]))
|
||||
.order_by(InvoiceCheck.id.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 社保
|
||||
ss_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
|
||||
ss_status_count = {"normal": 0, "warning": 0, "alert": 0}
|
||||
for s in ss_records:
|
||||
ss_status_count[s.check_status] = ss_status_count.get(s.check_status, 0) + 1
|
||||
ss_abnormal = (
|
||||
db.query(SocialSecurity)
|
||||
.filter(SocialSecurity.entity_id == eid, SocialSecurity.check_status.in_(["alert", "warning"]))
|
||||
.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"entity_id": eid,
|
||||
"burden": {
|
||||
"trend": burden_trend,
|
||||
"latest": latest,
|
||||
"benchmarks": TAX_BENCHMARKS,
|
||||
"alert_count": len(tax_alerts),
|
||||
"alerts": [_tax_to_dict(t) for t in tax_alerts[:10]],
|
||||
},
|
||||
"invoice": {
|
||||
"total": len(invoices),
|
||||
"status_count": inv_status_count,
|
||||
"abnormal": [_invoice_to_dict(i) for i in inv_abnormal],
|
||||
},
|
||||
"ss": {
|
||||
"total": len(ss_records),
|
||||
"status_count": ss_status_count,
|
||||
"abnormal": [_ss_to_dict(s) for s in ss_abnormal],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 演示数据(幂等)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/demo-data")
|
||||
def seed_demo_data(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""生成税务合规演示数据(幂等:已存在期间+税种则跳过)"""
|
||||
eid = entity_id or 1
|
||||
created = {"tax": 0, "invoice": 0, "ss": 0}
|
||||
|
||||
# 税负记录: 2026-01 ~ 2026-07,其中02月增值税故意偏高触发预警
|
||||
tax_demo = [
|
||||
("2026-01", "vat", 52000, 48800, 1380000, 13),
|
||||
("2026-02", "vat", 69000, 66000, 1460000, 13),
|
||||
("2026-03", "vat", 41000, 39800, 1280000, 13),
|
||||
("2026-04", "vat", 47500, 46200, 1350000, 13),
|
||||
("2026-05", "vat", 53000, 51000, 1420000, 13),
|
||||
("2026-06", "vat", 49800, 48200, 1400000, 13),
|
||||
("2026-01", "income", 36000, 33500, 1380000, 25),
|
||||
("2026-02", "income", 38000, 35500, 1460000, 25),
|
||||
("2026-03", "income", 33000, 31000, 1280000, 25),
|
||||
("2026-04", "income", 35000, 32800, 1350000, 25),
|
||||
("2026-05", "income", 37000, 34800, 1420000, 25),
|
||||
("2026-06", "income", 36500, 34000, 1400000, 25),
|
||||
("2026-01", "surtax", 6240, 5900, 1380000, 12),
|
||||
("2026-02", "surtax", 8280, 7900, 1460000, 12),
|
||||
("2026-03", "surtax", 4920, 4700, 1280000, 12),
|
||||
("2026-04", "surtax", 5700, 5500, 1350000, 12),
|
||||
("2026-05", "surtax", 6360, 6100, 1420000, 12),
|
||||
("2026-06", "surtax", 5976, 5800, 1400000, 12),
|
||||
]
|
||||
for period, ttype, payable, paid, income, rate in tax_demo:
|
||||
exists = (
|
||||
db.query(TaxRecord)
|
||||
.filter(TaxRecord.entity_id == eid, TaxRecord.period == period, TaxRecord.tax_type == ttype)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
t = TaxRecord(entity_id=eid, period=period, tax_type=ttype, tax_payable=payable,
|
||||
tax_paid=paid, tax_rate=rate, income=income)
|
||||
_calc_burden(t)
|
||||
db.add(t)
|
||||
created["tax"] += 1
|
||||
|
||||
# 报销单(供发票"金额与报销单匹配"规则使用)
|
||||
reimb_demo = [
|
||||
{"reimb_no": "BX202606150001", "applicant": "张伟", "department": "采购部", "expense_type": "office",
|
||||
"title": "6月办公用品采购", "amount": 5600, "status": "approved"},
|
||||
{"reimb_no": "BX202606200002", "applicant": "李娜", "department": "市场部", "expense_type": "management",
|
||||
"title": "6月市场信息服务费", "amount": 3200, "status": "approved"},
|
||||
{"reimb_no": "BX202607050003", "applicant": "王强", "department": "供应链部", "expense_type": "office",
|
||||
"title": "7月供应链物流服务", "amount": 14000, "status": "pending"},
|
||||
]
|
||||
for d in reimb_demo:
|
||||
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
|
||||
if exists:
|
||||
continue
|
||||
db.add(ExpenseReimbursement(**d))
|
||||
|
||||
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
|
||||
invoice_demo = [
|
||||
{"invoice_no": "9" * 33, "amount": 5600, "invoice_type": "vat", "invoice_date": "2026-06-15",
|
||||
"supplier": "北京云启科技有限公司", "reimb_no": "BX202606150001", "contract_no": "HT-2026-018"},
|
||||
{"invoice_no": "8" * 33, "amount": 3200, "invoice_type": "electronic", "invoice_date": "2026-06-20",
|
||||
"supplier": "上海数联信息服务有限公司", "reimb_no": "BX202606200002", "contract_no": "HT-2026-021"},
|
||||
{"invoice_no": "12345ABC", "amount": 1800, "invoice_type": "vat", "invoice_date": "2026-07-02",
|
||||
"supplier": "广州锐思咨询有限公司", "reimb_no": None, "contract_no": None},
|
||||
{"invoice_no": "7" * 33, "amount": 15000, "invoice_type": "vat", "invoice_date": "2026-07-05",
|
||||
"supplier": "深圳恒达供应链有限公司", "reimb_no": "BX202607050003", "contract_no": "HT-2026-030"},
|
||||
{"invoice_no": "6" * 33, "amount": 9800, "invoice_type": "vat", "invoice_date": "2026-07-08",
|
||||
"supplier": None, "reimb_no": None, "contract_no": "HT-2026-033"},
|
||||
]
|
||||
for d in invoice_demo:
|
||||
exists = (
|
||||
db.query(InvoiceCheck)
|
||||
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.invoice_no == d["invoice_no"])
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
inv = InvoiceCheck(entity_id=eid, **d)
|
||||
_check_invoice(db, inv)
|
||||
db.add(inv)
|
||||
created["invoice"] += 1
|
||||
|
||||
# 社保: 正常3人×2月 + 基数不符1条 + 漏缴1条
|
||||
ss_demo = [
|
||||
{"employee": "张伟", "period": "2026-06", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
|
||||
{"employee": "张伟", "period": "2026-07", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
|
||||
{"employee": "李娜", "period": "2026-06", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
|
||||
{"employee": "李娜", "period": "2026-07", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
|
||||
{"employee": "王强", "period": "2026-06", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
|
||||
{"employee": "王强", "period": "2026-07", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
|
||||
{"employee": "赵敏", "period": "2026-06", "base_amount": 5000, "salary": 20000, "company_amount": 1225, "personal_amount": 520},
|
||||
{"employee": "孙磊", "period": "2026-05", "base_amount": 0, "salary": 15000, "company_amount": 0, "personal_amount": 0},
|
||||
]
|
||||
for d in ss_demo:
|
||||
exists = (
|
||||
db.query(SocialSecurity)
|
||||
.filter(SocialSecurity.entity_id == eid, SocialSecurity.employee == d["employee"],
|
||||
SocialSecurity.period == d["period"])
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
s = SocialSecurity(entity_id=eid, **d)
|
||||
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
|
||||
_check_ss(db, s, all_records)
|
||||
db.add(s)
|
||||
created["ss"] += 1
|
||||
db.commit()
|
||||
return {"message": "演示数据生成完成", "created": created}
|
||||
@@ -0,0 +1,173 @@
|
||||
"""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
|
||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||
|
||||
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} 已存在,请修改")
|
||||
|
||||
# 数据治理:入库必检(规则1) + 编码规范(规则3)
|
||||
merged = {
|
||||
"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),
|
||||
"unit": data.get("unit", t.unit or "%"),
|
||||
"target_value": data.get("target_value", t.target_value),
|
||||
}
|
||||
gov_issues = [i for i in validate_kpi_payload(merged, db=db) if i["rule"] in (1, 3)]
|
||||
if gov_issues:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import SystemConfig, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"])
|
||||
|
||||
TENANT_KEY = "current_tenant"
|
||||
|
||||
class TenantSwitchRequest(BaseModel):
|
||||
entity_id: int
|
||||
source: str = "cma-system"
|
||||
|
||||
|
||||
def _tenant_name(entity_id: int) -> str:
|
||||
"""entity_id → tenant_id"""
|
||||
return "company_b" if entity_id == 2 else "company_a"
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
def get_current_tenant(db: Session = Depends(get_db)):
|
||||
"""查询当前租户"""
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg and cfg.config_value:
|
||||
import json
|
||||
try:
|
||||
return json.loads(cfg.config_value)
|
||||
except:
|
||||
pass
|
||||
return {"tenant_id": "company_a", "entity_id": 1, "name": "陕西酣客文化传媒"}
|
||||
|
||||
|
||||
@router.post("/switch")
|
||||
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
|
||||
"""切换当前租户(CMA前端企业切换器调用)"""
|
||||
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
|
||||
if not ent:
|
||||
raise HTTPException(404, "企业不存在")
|
||||
|
||||
import json
|
||||
state = {
|
||||
"tenant_id": _tenant_name(data.entity_id),
|
||||
"entity_id": data.entity_id,
|
||||
"name": ent.name,
|
||||
"short_name": ent.short_name,
|
||||
"source": data.source,
|
||||
"switched_at": __import__("datetime").datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg:
|
||||
cfg.config_value = json.dumps(state, ensure_ascii=False)
|
||||
else:
|
||||
db.add(SystemConfig(
|
||||
config_key=TENANT_KEY,
|
||||
config_value=json.dumps(state, ensure_ascii=False),
|
||||
description="当前租户状态(CMA企业切换联动)",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "current": state}
|
||||
@@ -0,0 +1,276 @@
|
||||
"""实际值自动归集 API — 管理会计OS (P1-④ 2026-08-28)
|
||||
|
||||
取数映射管理(kpi_value_sources) + 手动触发采集 + 采集日志 + 覆盖率统计
|
||||
采集器本体: scripts/kpi_value_collector.py(系统 crontab 每日 06:30)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIValueSource, KPIValueCollectLog, KPIDefinition, KPIValue
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["实际值归集"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ── 取数映射 CRUD ──────────────────────────────
|
||||
|
||||
@router.get("/value-sources")
|
||||
def list_value_sources(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""取数映射列表(按 entity_id 隔离)"""
|
||||
query = db.query(KPIValueSource).filter(KPIValueSource.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(KPIValueSource.kpi_id == kpi_id)
|
||||
if status:
|
||||
query = query.filter(KPIValueSource.status == status)
|
||||
rows = query.order_by(KPIValueSource.id.desc()).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"entity_id": r.entity_id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"source_table": r.source_table,
|
||||
"source_field": r.source_field,
|
||||
"aggregate": r.aggregate,
|
||||
"filter_rule": r.filter_rule,
|
||||
"period_field": r.period_field,
|
||||
"unit_conversion": r.unit_conversion,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/value-sources")
|
||||
def create_value_source(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建取数映射"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
source_table = data.get("source_table")
|
||||
source_field = data.get("source_field")
|
||||
if not kpi_id or not source_table or not source_field:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, source_table, source_field")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
existing = db.query(KPIValueSource).filter(
|
||||
KPIValueSource.entity_id == entity_id,
|
||||
KPIValueSource.kpi_id == kpi_id,
|
||||
KPIValueSource.source_table == source_table,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"该KPI({kpi_id})已存在 {source_table} 取数映射")
|
||||
|
||||
row = KPIValueSource(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
source_table=source_table,
|
||||
source_field=source_field,
|
||||
aggregate=data.get("aggregate", "sum"),
|
||||
filter_rule=data.get("filter_rule"),
|
||||
period_field=data.get("period_field", "period"),
|
||||
unit_conversion=data.get("unit_conversion", 1),
|
||||
status=data.get("status", "active"),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "取数映射已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/value-sources/{source_id}")
|
||||
def update_value_source(
|
||||
source_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新取数映射"""
|
||||
row = db.query(KPIValueSource).filter(
|
||||
KPIValueSource.id == source_id,
|
||||
KPIValueSource.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "映射不存在")
|
||||
for field in ("source_table", "source_field", "aggregate", "filter_rule",
|
||||
"period_field", "unit_conversion", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "映射已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/value-sources/{source_id}")
|
||||
def delete_value_source(
|
||||
source_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除取数映射"""
|
||||
row = db.query(KPIValueSource).filter(
|
||||
KPIValueSource.id == source_id,
|
||||
KPIValueSource.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "映射不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "映射已删除"}
|
||||
|
||||
|
||||
@router.post("/value-sources/test")
|
||||
def test_value_source(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""试跑单条映射返回预览值(不写库)"""
|
||||
from scripts.kpi_value_collector import collect_for_mapping
|
||||
|
||||
mapping = KPIValueSource(
|
||||
entity_id=entity_id,
|
||||
kpi_id=data.get("kpi_id"),
|
||||
source_table=data.get("source_table"),
|
||||
source_field=data.get("source_field"),
|
||||
aggregate=data.get("aggregate", "sum"),
|
||||
filter_rule=data.get("filter_rule"),
|
||||
period_field=data.get("period_field", "period"),
|
||||
unit_conversion=data.get("unit_conversion", 1),
|
||||
status="active",
|
||||
)
|
||||
period = data.get("period") or _default_period()
|
||||
try:
|
||||
value, message = collect_for_mapping(db, mapping, period, write_kpi=False)
|
||||
return {"success": True, "period": period, "value": value, "message": message}
|
||||
except Exception as e:
|
||||
return {"success": False, "period": period, "value": None, "message": str(e)}
|
||||
|
||||
|
||||
# ── 采集器触发 ──────────────────────────────
|
||||
|
||||
@router.post("/value-collect/run")
|
||||
def run_value_collect(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""手动触发采集器(可选 period 参数,默认当月)"""
|
||||
from scripts.kpi_value_collector import run_collector
|
||||
|
||||
period = data.get("period") or _default_period()
|
||||
kpi_id = data.get("kpi_id") # 可选: 只采集单个KPI
|
||||
result = run_collector(db, entity_id=entity_id, period=period, kpi_id=kpi_id)
|
||||
result["period"] = period
|
||||
return result
|
||||
|
||||
|
||||
# ── 采集日志 ──────────────────────────────
|
||||
|
||||
@router.get("/value-collect/logs")
|
||||
def list_collect_logs(
|
||||
status: Optional[str] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""采集日志(status过滤)"""
|
||||
query = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(KPIValueCollectLog.status == status)
|
||||
if period:
|
||||
query = query.filter(KPIValueCollectLog.period == period)
|
||||
rows = query.order_by(KPIValueCollectLog.collected_at.desc()).limit(limit).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"period": r.period,
|
||||
"source_table": r.source_table,
|
||||
"collected_value": r.collected_value,
|
||||
"status": r.status,
|
||||
"message": r.message,
|
||||
"collected_at": r.collected_at.isoformat() if r.collected_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
# ── 覆盖率统计 ──────────────────────────────
|
||||
|
||||
@router.get("/value-sources/coverage")
|
||||
def value_source_coverage(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""覆盖率统计:已配映射KPI数 / 总活跃KPI数 / 未配置清单"""
|
||||
total_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
).count()
|
||||
|
||||
mapped_rows = db.query(KPIValueSource).filter(
|
||||
KPIValueSource.entity_id == entity_id,
|
||||
KPIValueSource.status == "active",
|
||||
).all()
|
||||
mapped_kpi_ids = {r.kpi_id for r in mapped_rows}
|
||||
|
||||
all_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
unmapped = [{
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
} for k in all_kpis if k.id not in mapped_kpi_ids]
|
||||
|
||||
coverage = round(len(mapped_kpi_ids) / total_kpis * 100, 1) if total_kpis else 0
|
||||
return {
|
||||
"mapped_count": len(mapped_kpi_ids),
|
||||
"total_kpis": total_kpis,
|
||||
"coverage_pct": coverage,
|
||||
"unmapped_count": len(unmapped),
|
||||
"unmapped": unmapped,
|
||||
}
|
||||
|
||||
|
||||
def _default_period() -> str:
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%Y-%m")
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
自动验证引擎 API — 管理会计OS
|
||||
POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果
|
||||
|
||||
数据流:
|
||||
ActionPlan.auto_verify_rule (JSON) → 条件判断 → passed/failed
|
||||
→ KPI值回填 (kpi_current_before/after + KPIValue source_type=verify)
|
||||
→ 验证通过 → 所属OKR progress +15%
|
||||
→ 通知任总 (send_wecom_message)
|
||||
|
||||
规则格式(新):
|
||||
{
|
||||
"kpi_code": "C_REBATE_RATE",
|
||||
"condition": "LESS_THAN", # LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL
|
||||
"target_value": 80,
|
||||
"baseline_value": 86.4,
|
||||
"verify_after_days": 7,
|
||||
"retry_max": 3,
|
||||
"escalate_to": "任富海",
|
||||
"notify": true
|
||||
}
|
||||
|
||||
兼容格式(旧):
|
||||
{"condition": "value < 75", "description": "..."} → 走 bot_bridge_v2._evaluate_condition
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ActionPlan, KPIDefinition, KPIValue, Objective
|
||||
from app.utils.notifier import send_wecom_message
|
||||
from app.auth_middleware import require_role
|
||||
|
||||
logger = logging.getLogger("cma.verify")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/verify",
|
||||
tags=["自动验证"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
CONDITION_FUNCS = {
|
||||
"LESS_THAN": lambda actual, target: actual is not None and actual < target,
|
||||
"GREATER_THAN": lambda actual, target: actual is not None and actual > target,
|
||||
"WITHIN_RANGE": lambda actual, target: target is not None and len(target) == 2 and float(target[0]) <= actual <= float(target[1]),
|
||||
"NOT_NULL": lambda actual, target: actual is not None,
|
||||
}
|
||||
|
||||
|
||||
def _parse_rule(rule) -> dict:
|
||||
"""解析auto_verify_rule(兼容JSON字符串)"""
|
||||
if rule is None:
|
||||
return {}
|
||||
if isinstance(rule, str):
|
||||
try:
|
||||
return json.loads(rule)
|
||||
except Exception:
|
||||
return {"condition": rule}
|
||||
return rule
|
||||
|
||||
|
||||
def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool:
|
||||
"""
|
||||
按规则判断actual是否达标
|
||||
- 新格式: condition为枚举 LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL
|
||||
- 旧格式: condition为表达式字符串(如 "value < 75")→ bot_bridge_v2引擎
|
||||
"""
|
||||
condition = (rule or {}).get("condition", "")
|
||||
if not condition:
|
||||
return actual is not None
|
||||
|
||||
if condition in CONDITION_FUNCS:
|
||||
target = rule.get("target_value")
|
||||
return CONDITION_FUNCS[condition](actual, target)
|
||||
|
||||
# 旧格式表达式(bot-bridge兼容)
|
||||
try:
|
||||
from app.api.bot_bridge_v2 import _evaluate_condition
|
||||
kd = kpi_data or {"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")}
|
||||
return _evaluate_condition(condition, kd)
|
||||
except Exception as e:
|
||||
logger.warning(f"旧格式条件评估失败({condition}): {e}")
|
||||
return False
|
||||
|
||||
|
||||
def update_okr_progress(db: Session, plan: ActionPlan) -> dict:
|
||||
"""验证通过 → 所属OKR progress +15%(每通过1个KR)"""
|
||||
if not plan.objective_id:
|
||||
return {"updated": False, "reason": "no_objective"}
|
||||
obj = db.query(Objective).filter(Objective.id == plan.objective_id).first()
|
||||
if not obj:
|
||||
return {"updated": False, "reason": "objective_not_found"}
|
||||
before = obj.progress or 0
|
||||
obj.progress = min(100, before + 15)
|
||||
db.flush()
|
||||
return {"updated": True, "objective_id": obj.id, "before": before, "after": obj.progress}
|
||||
|
||||
|
||||
def build_auto_verify_rule(kpi, baseline_value=None, verify_after_days: int = 7) -> dict:
|
||||
"""根据KPI阈值自动生成验证规则(验收#1: 创建ActionPlan自动带auto_verify_rule)
|
||||
|
||||
从KPI的绿灯阈值(green)推导达标方向:
|
||||
- 绿灯 '<=X' → 目标是把值压到 X 以下 → LESS_THAN X
|
||||
- 绿灯 '>=X' → 目标是把值抬到 X 以上 → GREATER_THAN X
|
||||
- 有绿色区间 'X~Y' → WITHIN_RANGE
|
||||
- 无阈值 → NOT_NULL
|
||||
"""
|
||||
expr = (kpi.threshold_green or "").strip()
|
||||
condition = "NOT_NULL"
|
||||
target = None
|
||||
import re
|
||||
m = re.match(r"^(<=|>=|<|>|=)\s*([\d.]+)$", expr)
|
||||
if m:
|
||||
op, val = m.group(1), float(m.group(2))
|
||||
if op in ("<", "<="):
|
||||
condition = "LESS_THAN"
|
||||
elif op in (">", ">="):
|
||||
condition = "GREATER_THAN"
|
||||
else:
|
||||
condition = "NOT_NULL"
|
||||
target = val
|
||||
elif "~" in expr:
|
||||
parts = expr.split("~")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
condition = "WITHIN_RANGE"
|
||||
target = [float(parts[0]), float(parts[1])]
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# 无阈值表达式 → 用KPI目标值推方向
|
||||
condition = "NOT_NULL"
|
||||
target = None
|
||||
|
||||
return {
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"condition": condition,
|
||||
"target_value": target,
|
||||
"baseline_value": float(baseline_value) if baseline_value is not None else None,
|
||||
"verify_after_days": verify_after_days,
|
||||
"retry_max": 3,
|
||||
"escalate_to": "任富海",
|
||||
"notify": True,
|
||||
}
|
||||
|
||||
|
||||
def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"):
|
||||
"""回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录"""
|
||||
kpi = None
|
||||
kpi_code = rule.get("kpi_code") if rule else None
|
||||
# 优先按验证规则指定的KPI编码查询;无规则时才回退到plan.kpi_id
|
||||
if kpi_code:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
elif plan.kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||
|
||||
if actual is None:
|
||||
return None
|
||||
|
||||
plan.kpi_current_before = plan.kpi_current_before if plan.kpi_current_before is not None else rule.get("baseline_value")
|
||||
plan.kpi_current_after = actual
|
||||
|
||||
if kpi:
|
||||
new_val = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=datetime.now().strftime("%Y-%m"),
|
||||
actual_value=actual,
|
||||
source_type="verify",
|
||||
source_batch=f"verify-plan-{plan.id}",
|
||||
data_status="verified",
|
||||
calculated_at=datetime.now(),
|
||||
remark=f"行动计划#{plan.id}验证回填",
|
||||
)
|
||||
db.add(new_val)
|
||||
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code}
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{plan_id}")
|
||||
def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db)):
|
||||
"""验证行动计划执行结果(手动验证 / Bot回填)
|
||||
|
||||
请求体:
|
||||
{
|
||||
"actual_value": 78.5, # 可选,缺省时取KPI最新值
|
||||
"source": "财务Bot分析", # 来源
|
||||
"note": "渠补谈判后...", # 备注/验证结果详情
|
||||
"passed": true # 可选,无规则时手动指定
|
||||
}
|
||||
"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "行动计划不存在")
|
||||
|
||||
rule = _parse_rule(plan.auto_verify_rule)
|
||||
actual = payload.get("actual_value")
|
||||
note = payload.get("note", "")
|
||||
source = payload.get("source", "手动验证")
|
||||
|
||||
# 1. 无规则 → 按手动指定 passed 标记
|
||||
if not rule or not rule.get("condition"):
|
||||
passed = payload.get("passed", True)
|
||||
plan.verify_status = "passed" if passed else "failed"
|
||||
plan.verify_result = "pass" if passed else "fail"
|
||||
plan.verify_log = (plan.verify_log or []) + [{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"passed": passed,
|
||||
"note": note,
|
||||
"source": source,
|
||||
}]
|
||||
plan.verified_at = datetime.now()
|
||||
db.commit()
|
||||
return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed}
|
||||
|
||||
# 2. 缺省actual → 取KPI最新值
|
||||
if actual is None:
|
||||
kpi = None
|
||||
kpi_code = rule.get("kpi_code")
|
||||
if plan.kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||
elif kpi_code:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if kpi:
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
|
||||
if latest:
|
||||
actual = latest.actual_value
|
||||
|
||||
# 3. 执行条件判断
|
||||
passed = evaluate_rule(rule, actual, kpi_data={"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")})
|
||||
|
||||
# 4. 回写KPI当前值
|
||||
backfill = backfill_kpi_value(db, plan, actual, rule, source=source)
|
||||
|
||||
# 5. 更新状态
|
||||
plan.verify_status = "passed" if passed else "failed"
|
||||
plan.verify_result = "pass" if passed else "fail"
|
||||
plan.verify_log = (plan.verify_log or []) + [{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"condition": rule.get("condition"),
|
||||
"actual_value": actual,
|
||||
"target_value": rule.get("target_value"),
|
||||
"passed": passed,
|
||||
"source": source,
|
||||
"note": note,
|
||||
}]
|
||||
if passed:
|
||||
plan.status = "done"
|
||||
plan.progress = 100
|
||||
plan.verified_at = datetime.now()
|
||||
|
||||
# 6. OKR进度联动(验证通过 → +15%)
|
||||
okr_update = None
|
||||
if passed:
|
||||
okr_update = update_okr_progress(db, plan)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 7. 通知任总
|
||||
if rule.get("notify", True):
|
||||
delta = ""
|
||||
if plan.kpi_current_before is not None and actual is not None:
|
||||
try:
|
||||
d = float(actual) - float(plan.kpi_current_before)
|
||||
delta = f"({d:+.1f})"
|
||||
except (TypeError, ValueError):
|
||||
delta = ""
|
||||
title = f"✅ 行动计划#{plan.id}验证通过" if passed else f"❌ 行动计划#{plan.id}验证失败"
|
||||
content = (
|
||||
f"{plan.title}\n"
|
||||
f"KPI: {rule.get('kpi_code', '')} {plan.kpi_current_before}→{actual} {delta}\n"
|
||||
f"规则: {rule.get('condition')} {rule.get('target_value')}\n"
|
||||
f"来源: {source}\n"
|
||||
f"{note}"
|
||||
)
|
||||
try:
|
||||
send_wecom_message(content=content, title=title, alert_level="green" if passed else "red")
|
||||
except Exception as e:
|
||||
logger.warning(f"验证通知发送失败: {e}")
|
||||
|
||||
return {
|
||||
"plan_id": plan_id,
|
||||
"verify_status": plan.verify_status,
|
||||
"kpi_current_before": plan.kpi_current_before,
|
||||
"kpi_current_after": plan.kpi_current_after,
|
||||
"improvement": f"{float(actual) - float(plan.kpi_current_before):+.1f}" if plan.kpi_current_before is not None and actual is not None else None,
|
||||
"passed": passed,
|
||||
"okr_progress": okr_update,
|
||||
"kpi_backfill": backfill,
|
||||
"message": (
|
||||
f"验证通过:{plan.title}" if passed
|
||||
else f"验证失败:{plan.title}(条件 {rule.get('condition')} {rule.get('target_value')} 未达成,当前值 {actual})"
|
||||
),
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import StrategicMap, StrategicMapVersion
|
||||
|
||||
@@ -11,18 +12,19 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
|
||||
|
||||
|
||||
@router.get("/{map_id}/versions")
|
||||
def list_versions(map_id: int, db: Session = Depends(get_db)):
|
||||
"""查看版本历史"""
|
||||
def list_versions(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""查看版本历史(账套隔离, OpenCode审查#9)"""
|
||||
versions = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.map_id == map_id
|
||||
StrategicMapVersion.map_id == map_id,
|
||||
StrategicMapVersion.entity_id == entity_id,
|
||||
).order_by(StrategicMapVersion.id.desc()).all()
|
||||
return {"data": [v_to_dict(v) for v in versions]}
|
||||
|
||||
|
||||
@router.post("/{map_id}/versions/snapshot")
|
||||
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""手动创建快照"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""手动创建快照(账套隔离)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
@@ -49,6 +51,7 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
snapshot = StrategicMapVersion(
|
||||
map_id=map_id,
|
||||
entity_id=entity_id, # 继承地图企业
|
||||
version=new_ver,
|
||||
dimensions=dims,
|
||||
canvas_data=canvas,
|
||||
@@ -61,9 +64,9 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{map_id}/versions/{ver_id}/rollback")
|
||||
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db)):
|
||||
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""回滚到指定版本"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""零基预算逐项论证 API — 管理会计OS (P2-① 2026-08-28)
|
||||
|
||||
CRUD 逐项论证项(budget_zero_based_items) + generate 生成零基预算写 budget_plans。
|
||||
method-comparison 的 zero_based 分支优先读论证项(有数据→逐项求和 is_demo=false)。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import BudgetZeroBasedItem, KPIDefinition, BudgetPlan
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["零基预算"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/zero-based/items")
|
||||
def list_zero_based_items(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""零基逐项论证列表(kpi_id+period 过滤)"""
|
||||
query = db.query(BudgetZeroBasedItem).filter(BudgetZeroBasedItem.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetZeroBasedItem.kpi_id == kpi_id)
|
||||
if period:
|
||||
query = query.filter(BudgetZeroBasedItem.period == period)
|
||||
rows = query.order_by(BudgetZeroBasedItem.id.desc()).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"period": r.period,
|
||||
"item_name": r.item_name,
|
||||
"item_category": r.item_category,
|
||||
"base_value": r.base_value,
|
||||
"justification": r.justification,
|
||||
"proposed_value": r.proposed_value,
|
||||
"status": r.status,
|
||||
"created_by": r.created_by,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
total_proposed = round(sum(r.proposed_value for r in rows), 2)
|
||||
return {"data": result, "total": len(result), "total_proposed": total_proposed}
|
||||
|
||||
|
||||
@router.post("/zero-based/items")
|
||||
def create_zero_based_item(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建逐项论证项"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
item_name = data.get("item_name")
|
||||
base_value = data.get("base_value")
|
||||
proposed_value = data.get("proposed_value")
|
||||
if not kpi_id or not period or not item_name:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period, item_name")
|
||||
if base_value is None:
|
||||
base_value = 0
|
||||
if proposed_value is None:
|
||||
proposed_value = 0
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
row = BudgetZeroBasedItem(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
item_name=item_name,
|
||||
item_category=data.get("item_category", "discretionary"),
|
||||
base_value=base_value,
|
||||
justification=data.get("justification"),
|
||||
proposed_value=proposed_value,
|
||||
status=data.get("status", "draft"),
|
||||
created_by=data.get("created_by") or (current_user.name if hasattr(current_user, "name") else None),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "论证项已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/zero-based/items/{item_id}")
|
||||
def update_zero_based_item(
|
||||
item_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新逐项论证项"""
|
||||
row = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.id == item_id,
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "论证项不存在")
|
||||
for field in ("item_name", "item_category", "base_value", "justification",
|
||||
"proposed_value", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "论证项已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/zero-based/items/{item_id}")
|
||||
def delete_zero_based_item(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除逐项论证项"""
|
||||
row = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.id == item_id,
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "论证项不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "论证项已删除"}
|
||||
|
||||
|
||||
@router.post("/zero-based/generate")
|
||||
def generate_zero_based_budget(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""生成零基预算 = Σ(proposed_value),写 budget_plans(version='zbb-YYYYMMDD',calc_logic='zero_based_itemized')"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
year = data.get("year")
|
||||
if not kpi_id or not period:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period")
|
||||
|
||||
items = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
BudgetZeroBasedItem.kpi_id == kpi_id,
|
||||
BudgetZeroBasedItem.period == period,
|
||||
).all()
|
||||
if not items:
|
||||
raise HTTPException(400, f"期间 {period} 无逐项论证项,请先录入")
|
||||
|
||||
total = round(sum(i.proposed_value for i in items), 2)
|
||||
# 解析年份
|
||||
if not year:
|
||||
try:
|
||||
year = int(period.split("-")[0])
|
||||
except Exception:
|
||||
year = datetime.now().year
|
||||
month = 0
|
||||
try:
|
||||
month = int(period.split("-")[1]) if "-" in period else 0
|
||||
except Exception:
|
||||
month = 0
|
||||
|
||||
version = f"zbb-{datetime.now().strftime('%Y%m%d')}"
|
||||
# 删除同KPI同期间同版本旧预算,防重复
|
||||
db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.version == version,
|
||||
).delete()
|
||||
|
||||
bp = BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
budget_value=total,
|
||||
budget_year=year,
|
||||
budget_month=month,
|
||||
version=version,
|
||||
status="active",
|
||||
source_type="zero_based",
|
||||
calc_logic="zero_based_itemized",
|
||||
remark=f"零基逐项论证生成: {len(items)}项 Σ(proposed_value)={total}",
|
||||
)
|
||||
db.add(bp)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"零基预算已生成: {total}({len(items)}项论证)",
|
||||
"kpi_id": kpi_id,
|
||||
"period": period,
|
||||
"total": total,
|
||||
"item_count": len(items),
|
||||
"version": version,
|
||||
"plan_id": bp.id,
|
||||
}
|
||||
@@ -60,7 +60,7 @@ except Exception:
|
||||
logger.warning("Redis不可用,token存储降级到内存(不支持多worker)")
|
||||
|
||||
# 内存 fallback
|
||||
_token_store: dict[str, int] = {}
|
||||
_token_store: dict[str, dict] = {}
|
||||
|
||||
TOKEN_PREFIX = "cma:token:"
|
||||
TOKEN_TTL = 86400 # 24小时
|
||||
@@ -95,22 +95,84 @@ def _load_permissions(db: Session = None):
|
||||
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
|
||||
|
||||
|
||||
def create_token(user_id: int) -> str:
|
||||
def create_token(user_id: int, entity_id: int = None) -> str:
|
||||
"""签发token:Redis存储 JSON {user_id, entity_id}(账套模式)
|
||||
兼容旧调用 create_token(user_id) → entity_id=None(切换器会重新签发)
|
||||
"""
|
||||
token = secrets.token_hex(32)
|
||||
payload = json.dumps({"user_id": user_id, "entity_id": entity_id}, ensure_ascii=False)
|
||||
if _redis_available:
|
||||
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, user_id)
|
||||
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, payload)
|
||||
else:
|
||||
_token_store[token] = user_id
|
||||
_token_store[token] = payload
|
||||
return token
|
||||
|
||||
|
||||
def _resolve_user_id(token: str) -> int | None:
|
||||
def _resolve_token_data(token: str) -> dict | None:
|
||||
"""解析token → {user_id, entity_id}
|
||||
- 新格式 JSON → 返回 dict
|
||||
- 旧格式 int(改造前)→ 返回 None(强制下线,账套模式需重新登录)
|
||||
- 不存在 → None
|
||||
"""
|
||||
if _redis_available:
|
||||
val = _redis.get(f"{TOKEN_PREFIX}{token}")
|
||||
if val is not None:
|
||||
return int(val)
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(val)
|
||||
if isinstance(data, dict) and "user_id" in data:
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
# 旧格式纯 int → 强制下线
|
||||
return None
|
||||
return _token_store.get(token)
|
||||
val = _token_store.get(token)
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, dict):
|
||||
return val
|
||||
try:
|
||||
data = json.loads(val)
|
||||
if isinstance(data, dict) and "user_id" in data:
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_user_id(token: str) -> int | None:
|
||||
data = _resolve_token_data(token)
|
||||
return int(data["user_id"]) if data else None
|
||||
|
||||
|
||||
def get_token_entity_id(token: str) -> int | None:
|
||||
"""从token解析绑定的entity_id(token不存在/旧格式 → None)"""
|
||||
data = _resolve_token_data(token)
|
||||
if not data:
|
||||
return None
|
||||
eid = data.get("entity_id")
|
||||
return int(eid) if eid else None
|
||||
|
||||
|
||||
def user_has_entity(db: Session, user_id: int, entity_id: int) -> bool:
|
||||
"""校验用户是否被授权访问指定企业(账套授权表 user_entities)"""
|
||||
from app.models import UserEntity, Entity
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not ent or ent.status != "active":
|
||||
return False
|
||||
rel = db.query(UserEntity).filter(
|
||||
UserEntity.user_id == user_id,
|
||||
UserEntity.entity_id == entity_id,
|
||||
).first()
|
||||
return rel is not None
|
||||
|
||||
|
||||
def extract_bearer_token(request) -> str | None:
|
||||
"""从Request提取Bearer token(无/非Bearer格式 → None)"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def require_auth(
|
||||
|
||||
@@ -68,6 +68,76 @@ def init_db():
|
||||
except Exception as e:
|
||||
logger.warning(f"组织数据初始化跳过: {e}")
|
||||
|
||||
# ── user_entities 授权表初始化(账套模式平滑迁移)──
|
||||
# 首次建表(表空)时,给存量用户默认授权所有 active 企业,保证现有登录不丢权限
|
||||
try:
|
||||
inspector = inspect(get_engine())
|
||||
if "user_entities" in inspector.get_table_names():
|
||||
Session = get_session_local()
|
||||
session = Session()
|
||||
try:
|
||||
from app.models import UserEntity, User, Entity
|
||||
cnt = session.query(UserEntity).count()
|
||||
if cnt == 0:
|
||||
users = session.query(User).all()
|
||||
entities = session.query(Entity).filter(Entity.status == "active").all()
|
||||
if users and entities:
|
||||
for u in users:
|
||||
for e in entities:
|
||||
exists = session.query(UserEntity).filter(
|
||||
UserEntity.user_id == u.id,
|
||||
UserEntity.entity_id == e.id,
|
||||
).first()
|
||||
if not exists:
|
||||
session.add(UserEntity(user_id=u.id, entity_id=e.id, granted_by=None))
|
||||
session.commit()
|
||||
logger.info(f"user_entities初始化: {len(users)}用户 × {len(entities)}企业")
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"user_entities初始化跳过: {e}")
|
||||
|
||||
# ── scenario_suggestions 告警场景建议 seed(2026-08-28 告警归因 P1-③)──
|
||||
# 幂等:仅补缺失的 alert_type,不覆盖已有模板
|
||||
try:
|
||||
inspector = inspect(get_engine())
|
||||
if "scenario_suggestions" in inspector.get_table_names():
|
||||
Session = get_session_local()
|
||||
session = Session()
|
||||
try:
|
||||
from app.models import ScenarioSuggestion
|
||||
existing_types = {s.alert_type for s in session.query(ScenarioSuggestion).all()}
|
||||
seeds = [
|
||||
dict(alert_type="cash_low", title="现金流紧张 — 加强回款催收",
|
||||
description="现金余额接近警戒线,建议优先处理应收款项,压缩非紧急支出。",
|
||||
action_template="1. 列出未来30天应收清单,逐笔催收\n2. 暂停非紧急采购/费用支出\n3. 与银行沟通短期授信额度",
|
||||
priority="high", sort_order=1),
|
||||
dict(alert_type="cash_critical", title="现金流危急 — 立即止血",
|
||||
description="现金余额已低于安全阈值,存在断流风险,需要立即采取止血措施。",
|
||||
action_template="1. 冻结一切非必要支出\n2. 高管紧急复盘资金计划\n3. 启动应收账款特别催收\n4. 评估短期融资",
|
||||
priority="high", sort_order=2),
|
||||
dict(alert_type="cost_high", title="成本超支 — 核查费用构成",
|
||||
description="实际成本超出预算,建议拆解到科目明细定位超支源头。",
|
||||
action_template="1. 查看科目明细拆解,定位超支前3科目\n2. 分析价差/量差成因(单价上涨/用量增加)\n3. 对可控费用制定压降方案",
|
||||
priority="medium", sort_order=3),
|
||||
dict(alert_type="revenue_drop", title="收入下滑 — 追量提效",
|
||||
description="实际收入低于预算,建议从子KPI量级分解查找差距来源。",
|
||||
action_template="1. 查看子KPI拆解,定位量差最大维度\n2. 分析客户/渠道/产品线缺口\n3. 制定增量获客或转化提升方案",
|
||||
priority="medium", sort_order=4),
|
||||
]
|
||||
added = 0
|
||||
for s in seeds:
|
||||
if s["alert_type"] not in existing_types:
|
||||
session.add(ScenarioSuggestion(**s))
|
||||
added += 1
|
||||
if added:
|
||||
session.commit()
|
||||
logger.info(f"scenario_suggestions seed: 新增{added}条场景建议模板")
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"scenario_suggestions seed跳过: {e}")
|
||||
|
||||
|
||||
def _seed_org_data(db_session):
|
||||
"""插入5层级组织示例数据"""
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
|
||||
from fastapi import Request, Header, Query, Depends, HTTPException
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import get_token_entity_id
|
||||
from app.models import Entity
|
||||
|
||||
|
||||
def get_entity_id(
|
||||
request: Request,
|
||||
x_entity_id: Optional[str] = Header(None, alias="X-Entity-Id"),
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
) -> int:
|
||||
"""解析当前企业ID(账套模式):token优先 → query/header(Bot白名单)→ 默认1
|
||||
|
||||
解析链(倒置后):
|
||||
1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源)
|
||||
2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active)
|
||||
3. body中的 entity_id(POST场景)→ Bot服务通道兼容
|
||||
4. 兜底默认 1(酣客)
|
||||
|
||||
安全说明:登录用户必须通过token绑定账套,query/header传入的entity_id
|
||||
在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。
|
||||
"""
|
||||
# 1. token优先(账套模式唯一来源)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token_entity = get_token_entity_id(auth[7:])
|
||||
if token_entity is not None:
|
||||
# 越权防护:显式传入的 query/header entity_id 与 token 绑定不一致 → 403
|
||||
explicit = None
|
||||
if entity_id is not None:
|
||||
explicit = entity_id
|
||||
elif x_entity_id and x_entity_id.isdigit():
|
||||
explicit = int(x_entity_id)
|
||||
if explicit is not None and explicit != token_entity:
|
||||
raise HTTPException(403, f"无权访问企业 entity_id={explicit}(当前账套: {token_entity})")
|
||||
return token_entity
|
||||
# token存在但是旧格式/无entity → 账套模式下强制走白名单或默认(由require_auth拦截)
|
||||
# 这里不抛401:公开接口可能带旧token,交给require_auth统一处理
|
||||
|
||||
# 2. query/header → Bot服务通道白名单(校验entity状态active)
|
||||
candidate = None
|
||||
if entity_id is not None:
|
||||
candidate = entity_id
|
||||
elif x_entity_id and x_entity_id.isdigit():
|
||||
candidate = int(x_entity_id)
|
||||
|
||||
# 3. body 中的 entity_id(POST场景,Bot通道兼容)
|
||||
if candidate is None and request.method in ("POST", "PUT", "PATCH"):
|
||||
try:
|
||||
body = request.state.body_json or {}
|
||||
if body.get("entity_id"):
|
||||
candidate = int(body["entity_id"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if candidate is not None:
|
||||
ent = db.query(Entity).filter(Entity.id == candidate).first()
|
||||
if ent and ent.status == "active":
|
||||
return candidate
|
||||
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
|
||||
raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活")
|
||||
|
||||
return 1 # 默认酣客(无token、无参数时兜底,兼容存量公开接口)
|
||||
|
||||
|
||||
def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
|
||||
"""账套模式:请求级entity解析(供从body读取entity_id的接口使用)
|
||||
登录用户(带Bearer token)→ token绑定的entity(唯一来源)
|
||||
无token(Bot服务通道)→ 回退到调用方传入的fallback(body中的entity_id等)
|
||||
"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token_entity = get_token_entity_id(auth[7:])
|
||||
if token_entity is not None:
|
||||
return token_entity
|
||||
return fallback
|
||||
+53
-3
@@ -5,10 +5,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, 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, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
from app.api.audit_log import bot_audit_middleware
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -30,8 +31,12 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Bot API 操作审计(L1-L4分级标注 + JSON行审计日志,不阻塞业务)
|
||||
app.middleware("http")(bot_audit_middleware)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(kpis.router)
|
||||
app.include_router(kpi_governance.router)
|
||||
app.include_router(templates.router)
|
||||
app.include_router(maps.router)
|
||||
app.include_router(dashboard.router)
|
||||
@@ -49,10 +54,43 @@ app.include_router(org.router)
|
||||
app.include_router(objectives.router)
|
||||
app.include_router(versions.router)
|
||||
app.include_router(budget.router)
|
||||
app.include_router(value_sources.router)
|
||||
app.include_router(zero_based.router)
|
||||
app.include_router(derivation_rules.router)
|
||||
app.include_router(cash_classify.router)
|
||||
app.include_router(cost.router)
|
||||
app.include_router(predict.router)
|
||||
app.include_router(growth_quality.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(security.router)
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(bot_bridge.router)
|
||||
app.include_router(bot_bridge_v2.router)
|
||||
app.include_router(lead.router)
|
||||
app.include_router(tenant.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.include_router(entities.router)
|
||||
app.include_router(bsc_layers.router)
|
||||
app.include_router(okr.router)
|
||||
app.include_router(okr_templates.router)
|
||||
app.include_router(subjects.router)
|
||||
app.include_router(driver_budget.router)
|
||||
app.include_router(bot_kpis.router)
|
||||
app.include_router(ontology.router)
|
||||
app.include_router(bot_iron_law.router)
|
||||
app.include_router(analysis_results.router)
|
||||
app.include_router(expenses.router)
|
||||
app.include_router(cash.router)
|
||||
app.include_router(data_classification.router)
|
||||
app.include_router(tax_compliance.router)
|
||||
app.include_router(verify.router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
@@ -90,13 +128,25 @@ def admin_erp_sync_dry_run(user=Depends(require_auth), kpi_codes: str = None):
|
||||
|
||||
@app.post("/api/cma/admin/alerts/check")
|
||||
def admin_check_alerts():
|
||||
"""手动触发预警检查"""
|
||||
"""手动触发预警检查(含资金管理预警:缺口前3天 + 到期未收款)"""
|
||||
from app.database import get_session_local
|
||||
from scripts.alert_generator import generate_and_push
|
||||
from app.utils.cash_forecast_engine import check_cash_alerts
|
||||
from app.models import Entity
|
||||
db = get_session_local()()
|
||||
try:
|
||||
result = generate_and_push(db)
|
||||
return {"message": "预警检查完成", "result": result}
|
||||
# 资金管理预警 — 对每个激活企业执行
|
||||
cash_result = None
|
||||
try:
|
||||
entities = db.query(Entity).filter(Entity.status == "active").all()
|
||||
cash_list = []
|
||||
for e in entities:
|
||||
cash_list.append(check_cash_alerts(db, entity_id=e.id))
|
||||
cash_result = {"entities": len(entities), "details": cash_list}
|
||||
except Exception as e:
|
||||
logger.warning(f"资金预警检查失败: {e}")
|
||||
return {"message": "预警检查完成", "result": result, "cash": cash_result}
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"detail": f"检查失败: {str(e)}"})
|
||||
finally:
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
"""管理会计OS 数据模型"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func
|
||||
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint, Numeric, Date
|
||||
from app.database import Base
|
||||
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
from app.models.product_sales import ProductSales
|
||||
|
||||
|
||||
class Entity(Base):
|
||||
"""企业实体"""
|
||||
__tablename__ = "entities"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False, comment="企业全称")
|
||||
short_name = Column(String(50), comment="企业简称")
|
||||
industry = Column(String(50), comment="行业")
|
||||
status = Column(String(20), default="active", comment="active/inactive/demo")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -18,10 +33,22 @@ class User(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class UserEntity(Base):
|
||||
"""用户-企业授权(账套模式多对多)— 新表必须带entity_id(开发规范)"""
|
||||
__tablename__ = "user_entities"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="用户ID")
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, index=True, comment="企业ID(账套)")
|
||||
granted_by = Column(Integer, nullable=True, comment="授权人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
__table_args__ = (UniqueConstraint("user_id", "entity_id", name="uq_user_entity"),)
|
||||
|
||||
|
||||
class StrategicMap(Base):
|
||||
"""战略地图"""
|
||||
__tablename__ = "strategic_maps"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-25)")
|
||||
title = Column(String(200), nullable=False, comment="地图名称")
|
||||
version = Column(String(20), default="v1.0", comment="版本号")
|
||||
status = Column(String(20), default="draft", comment="draft/published")
|
||||
@@ -36,6 +63,7 @@ class KPIDefinition(Base):
|
||||
"""KPI字典"""
|
||||
__tablename__ = "kpi_definitions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID")
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
|
||||
kpi_code = Column(String(50), unique=True, nullable=False, comment="KPI编码")
|
||||
kpi_name = Column(String(200), nullable=False, comment="KPI名称")
|
||||
@@ -45,16 +73,27 @@ class KPIDefinition(Base):
|
||||
formula_desc = Column(String(500), nullable=True, comment="公式说明")
|
||||
data_source_type = Column(String(20), default="manual", comment="erp/business/excel/manual")
|
||||
data_source_config = Column(JSON, nullable=True, comment="数据源配置")
|
||||
data_source = Column(String(500), default="待补充", comment="数据来源")
|
||||
data_owner = Column(String(100), default="待指定", comment="数据责任人")
|
||||
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
|
||||
unit = Column(String(50), default="%", comment="单位")
|
||||
target_value = Column(Float, nullable=True, comment="目标值")
|
||||
target_value = Column(Float, nullable=True, comment="目标值(兼容旧字段)")
|
||||
target_monthly = Column(Float, nullable=True, comment="月度目标值")
|
||||
target_quarterly = Column(Float, nullable=True, comment="季度目标值")
|
||||
target_yearly = Column(Float, nullable=True, comment="年度目标值")
|
||||
target_calc_type = Column(String(20), nullable=True, comment="指标类型: accumulate累计(月×3=季,×12=年) / ratio比率(季/年沿用基准,可手调)")
|
||||
threshold_green = Column(String(100), nullable=True, comment="绿灯阈值")
|
||||
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
|
||||
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
|
||||
category = Column(String(50), nullable=True, comment="BSC二级类别: revenue_growth/profitability/cost_control/asset_efficiency/cash_risk/customer_scale/customer_concentration/customer_satisfaction/supply_chain/delivery_quality/talent_pipeline/employee_engagement/innovation")
|
||||
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
|
||||
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
|
||||
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
|
||||
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
||||
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
||||
kpi_level = Column(String(20), default="operational", comment="strategic/operational")
|
||||
status = Column(String(20), default="active")
|
||||
bot_source = Column(String(50), nullable=True, comment="Bot标识: finance-bot/ops-bot等")
|
||||
epic = Column(String(50), default="Epic2", comment="所属Epic")
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -65,6 +104,7 @@ class KPIValue(Base):
|
||||
"""KPI实际值"""
|
||||
__tablename__ = "kpi_values"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=None, comment="企业ID (P2多租户隔离 2026-08-23, 按kpi_id回填)")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
actual_value = Column(Float, nullable=True, comment="实际值")
|
||||
@@ -98,6 +138,9 @@ class KPIAlert(Base):
|
||||
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
|
||||
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
|
||||
alert_message = Column(String(500), nullable=False)
|
||||
alert_type = Column(String(30), default="actual", comment="actual/forecast — 实际值超限/预测值超限")
|
||||
suggestion = Column(Text, nullable=True, comment="情景建议")
|
||||
action_plan_linked_id = Column(Integer, nullable=True, comment="关联的改善计划ID")
|
||||
status = Column(String(20), default="pending", comment="pending/processing/resolved")
|
||||
assignee = Column(String(100), nullable=True, comment="处理人")
|
||||
resolution = Column(Text, nullable=True, comment="处理结果")
|
||||
@@ -152,12 +195,32 @@ class RolePermission(Base):
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Objective(Base):
|
||||
"""OKR目标"""
|
||||
__tablename__ = "objectives"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
title = Column(String(200), nullable=False, comment="目标标题")
|
||||
description = Column(Text, nullable=True, comment="目标描述")
|
||||
dimension = Column(String(50), nullable=True, comment="关联维度: finance/customer/process/learning")
|
||||
strategic_map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
|
||||
quarter = Column(String(20), nullable=False, comment="季度: 2026Q3")
|
||||
owner = Column(String(100), nullable=True, comment="负责人")
|
||||
status = Column(String(20), default="active", comment="active/completed/cancelled")
|
||||
progress = Column(Integer, default=0, comment="整体进度 0-100")
|
||||
confidence = Column(Integer, default=5, comment="信心指数 1-10")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ActionPlan(Base):
|
||||
"""改善行动计划"""
|
||||
__tablename__ = "action_plans"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=True, comment="关联OKR目标")
|
||||
kr_id = Column(Integer, ForeignKey("krs.id"), nullable=True, comment="关联KR (行动挂KR 2026-08-27)")
|
||||
title = Column(String(200), nullable=False, comment="计划标题")
|
||||
description = Column(Text, nullable=True, comment="详细描述")
|
||||
assignee = Column(String(100), nullable=True, comment="负责人")
|
||||
@@ -166,6 +229,15 @@ class ActionPlan(Base):
|
||||
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled")
|
||||
progress = Column(Integer, default=0, comment="完成进度 0-100")
|
||||
result = Column(Text, nullable=True, comment="改善结果")
|
||||
monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]")
|
||||
auto_verify_rule = Column(JSON, nullable=True, comment="自动验证规则: {\"condition\": \"value > target\"}")
|
||||
verify_result = Column(String(20), nullable=True, comment="验证结果: pass/fail/pending")
|
||||
verify_log = Column(JSON, nullable=True, comment="验证历史日志")
|
||||
verify_status = Column(String(20), default="pending", comment="验证状态: pending/passed/failed/retrying/escalated")
|
||||
verify_attempts = Column(Integer, default=0, comment="验证尝试次数")
|
||||
verified_at = Column(DateTime, nullable=True, comment="验证完成时间")
|
||||
kpi_current_before = Column(Float, nullable=True, comment="执行前KPI值")
|
||||
kpi_current_after = Column(Float, nullable=True, comment="执行后KPI值")
|
||||
created_by = Column(String(100), nullable=True, comment="创建人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -191,6 +263,7 @@ class StrategicMapVersion(Base):
|
||||
"""战略地图版本快照"""
|
||||
__tablename__ = "strategic_map_versions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (OpenCode审查#9 2026-08-26)")
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
||||
version = Column(String(20), nullable=False, comment="版本号 v1.0 v1.1 ...")
|
||||
dimensions = Column(JSON, nullable=False, comment="维度数据快照")
|
||||
@@ -200,15 +273,641 @@ class StrategicMapVersion(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class ReviewRecord(Base):
|
||||
"""复盘记录 — 战略回顾会/运营复盘会结论持久化(大PDCA Act闭环 2026-08-27)"""
|
||||
__tablename__ = "review_records"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-27)")
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联战略地图")
|
||||
review_type = Column(String(20), default="quarterly", comment="monthly/quarterly 月度运营复盘/季度战略回顾")
|
||||
review_date = Column(Date, nullable=False, comment="复盘日期")
|
||||
summary = Column(Text, nullable=False, comment="复盘结论(为什么没达成/怎么调整)")
|
||||
adjustments = Column(JSON, nullable=True, comment="调整项JSON [{type,target,action}]")
|
||||
next_priorities = Column(JSON, nullable=True, comment="下阶段优先级 [str,...]")
|
||||
created_by = Column(Integer, nullable=True, comment="创建人用户ID")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class MapObjective(Base):
|
||||
"""战略地图目标: 每个维度下的具体目标"""
|
||||
__tablename__ = "map_objectives"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-25)")
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
||||
dimension_key = Column(String(50), nullable=False, comment="所属维度: finance/customer/process/learning")
|
||||
name = Column(String(200), nullable=False, comment="目标名称")
|
||||
description = Column(Text, nullable=True, comment="描述")
|
||||
icon = Column(String(50), default="target", comment="图标标识")
|
||||
kpis = Column(JSON, nullable=True, comment="关联KPI编码数组")
|
||||
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)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-27)")
|
||||
source_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="源KPI(因)")
|
||||
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
|
||||
strength = Column(Float, default=0.5, comment="影响强度 0~1")
|
||||
lag_months = Column(Integer, default=1, comment="滞后期(月)")
|
||||
formula = Column(String(500), nullable=True, comment="影响公式描述")
|
||||
direction = Column(String(10), default="positive", comment="positive/negative 正向/负向影响")
|
||||
source_type = Column(String(20), default="manual", comment="建链来源 AI_suggested/manual/imported (2026-08-27 验证机制)")
|
||||
verify_status = Column(String(20), default="pending", comment="验证状态 pending/data_verified/human_verified/disputed (2026-08-27)")
|
||||
verified_at = Column(DateTime, nullable=True, comment="验证时间")
|
||||
verified_by = Column(String(50), nullable=True, comment="验证人/AI/脚本")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class MpmResult(Base):
|
||||
"""MPM财务Bot分析结果记录"""
|
||||
__tablename__ = "mpm_results"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业实体ID")
|
||||
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||
source = Column(String(50), default="finance-bot", comment="来源Bot标识")
|
||||
raw_data = Column(JSON, nullable=False, comment="完整的MPM计算结果")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class KpiForecastLog(Base):
|
||||
"""KPI预测历史 — 预测偏差告警数据源 (2026-08-25 升级2a)"""
|
||||
__tablename__ = "kpi_forecast_log"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, nullable=False, comment="企业ID")
|
||||
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
|
||||
kpi_code = Column(String(50), nullable=False, comment="KPI编码")
|
||||
period = Column(String(20), nullable=False, comment="预测期间")
|
||||
forecast_value = Column(Float, nullable=True, comment="预测值")
|
||||
model = Column(String(30), default="linear", comment="预测模型")
|
||||
confidence = Column(String(10), nullable=True, comment="置信度")
|
||||
trend = Column(String(10), nullable=True, comment="趋势")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class BotBridgeConfig(Base):
|
||||
"""Bot桥接鉴权配置"""
|
||||
__tablename__ = "bot_bridge_config"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
bot_name = Column(String(50), unique=True, nullable=False, comment="Bot名称")
|
||||
token = Column(String(64), nullable=False, comment="鉴权Token")
|
||||
is_active = Column(Boolean, default=True, comment="是否激活")
|
||||
created_at = Column(DateTime, server_default=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)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
name = Column(String(200), nullable=False, comment="模板名称")
|
||||
report_type = Column(String(50), nullable=False, comment="overview/trend/comparison/topn/causality")
|
||||
config = Column(JSON, nullable=False, comment="报表配置")
|
||||
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)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
template_id = Column(Integer, ForeignKey("bi_report_templates.id"), nullable=True)
|
||||
name = Column(String(200), nullable=False, comment="报表名称")
|
||||
config = Column(JSON, nullable=False, comment="报表配置(行/列/值)")
|
||||
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())
|
||||
|
||||
|
||||
class BscLayerConfig(Base):
|
||||
"""BSC四层配置 — 不同企业的权重配置"""
|
||||
__tablename__ = "bsc_layer_config"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业ID")
|
||||
layer = Column(String(20), nullable=False, comment="financial/customer/process/learning")
|
||||
weight = Column(Float, nullable=False, comment="该层权重(%)")
|
||||
kpi_count_min = Column(Integer, default=2, comment="最少KPI数")
|
||||
kpi_count_max = Column(Integer, default=5, comment="最多KPI数")
|
||||
|
||||
|
||||
class KPIHierarchy(Base):
|
||||
"""KPI层级关系 — 公司→部门→个人三级分解"""
|
||||
__tablename__ = "kpi_hierarchy"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
|
||||
parent_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="上级KPI")
|
||||
child_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="下级KPI")
|
||||
level = Column(Integer, default=1, comment="1=公司级 2=部门级 3=个人级")
|
||||
weight = Column(Float, default=1.0, comment="下级对上级的贡献权重(%)")
|
||||
child_name = Column(String(200), nullable=True, comment="下级节点名称(个人或部门名)")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# 兼容性: P2开发新增的模板API需要的模型
|
||||
# KPI模板(独立表)
|
||||
class KPITemplate(Base):
|
||||
__tablename__ = "kpi_templates"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_code = Column(String(50), unique=True, nullable=False)
|
||||
kpi_name = Column(String(200), nullable=False)
|
||||
dimension = Column(String(50))
|
||||
category = Column(String(50))
|
||||
formula = Column(Text)
|
||||
formula_desc = Column(String(500))
|
||||
unit = Column(String(50))
|
||||
target_value = Column(Float)
|
||||
description = Column(Text)
|
||||
is_system = Column(Integer, default=0)
|
||||
usage_count = Column(Integer, default=0)
|
||||
created_by = Column(Integer)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class OKRTemplate(Base):
|
||||
"""OKR模板库"""
|
||||
__tablename__ = "okr_templates"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False, comment="O名称")
|
||||
description = Column(Text, comment="O描述")
|
||||
dimension = Column(String(20), nullable=False, comment="finance/customer/process/learning")
|
||||
layer = Column(String(20), default="level1", comment="level1/level2/level3")
|
||||
industry_tag = Column(String(50), default="general", comment="行业标签")
|
||||
preset_krs = Column(JSON, nullable=False, comment="预设关键结果列表")
|
||||
source = Column(String(20), default="system", comment="system/user/industry_pack")
|
||||
owner = Column(String(50), default="CMA标准库", comment="模板负责人(治理P2: 可追踪)")
|
||||
use_count = Column(Integer, default=0, comment="使用次数")
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class CashForecast(Base):
|
||||
"""现金流预测 — 每日未来30天预测"""
|
||||
__tablename__ = "cash_forecasts"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业ID")
|
||||
forecast_date = Column(DateTime, nullable=False, comment="预测日期(每天一条)")
|
||||
predicted_cash = Column(Float, nullable=True, comment="预测现金余额")
|
||||
lower_bound = Column(Float, nullable=True, comment="置信区间下界")
|
||||
upper_bound = Column(Float, nullable=True, comment="置信区间上界")
|
||||
alert_status = Column(String(20), default="green", comment="green/yellow/red")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class CashPlan(Base):
|
||||
"""收付款计划 — 资金管理智能体(唯一应收载体:含回款登记、负责人、数据来源)"""
|
||||
__tablename__ = "cash_plans"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
|
||||
plan_type = Column(String(10), nullable=False, comment="receive收/pay付")
|
||||
related_kpi_id = Column(Integer, nullable=True, comment="关联KPI(预算联动 2026-08-27)")
|
||||
budget_plan_id = Column(Integer, nullable=True, comment="来源预算计划ID")
|
||||
amount = Column(Float, nullable=False, comment="金额(万元)")
|
||||
plan_date = Column(DateTime, nullable=False, comment="计划日期(应收即到期日)")
|
||||
counterparty = Column(String(200), nullable=True, comment="关联客户/供应商")
|
||||
description = Column(String(500), nullable=True, comment="说明")
|
||||
status = Column(String(20), default="pending", comment="pending/completed/cancelled")
|
||||
owner = Column(String(100), nullable=True, comment="负责人/业务员(应收催收责任人)")
|
||||
source = Column(String(50), default="manual", comment="数据来源: manual/bohai_ar/receivables_migrate")
|
||||
paid_amount = Column(Float, default=0, comment="已回款金额(万元)")
|
||||
completed_at = Column(DateTime, nullable=True, comment="完成时间")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
"""系统配置 — key-value存储"""
|
||||
__tablename__ = "system_configs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
config_key = Column(String(100), unique=True, nullable=False, comment="配置键")
|
||||
config_value = Column(String(500), nullable=True, comment="配置值")
|
||||
description = Column(String(500), nullable=True, comment="配置说明")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class BudgetDeviationAlert(Base):
|
||||
"""预算偏差预警记录"""
|
||||
__tablename__ = "budget_deviation_alerts"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||
budget_value = Column(Float, nullable=True, comment="预算值")
|
||||
actual_value = Column(Float, nullable=True, comment="实际值")
|
||||
deviation_rate = Column(Float, nullable=True, comment="偏差率 %")
|
||||
deviation_value = Column(Float, nullable=True, comment="偏差绝对值")
|
||||
alert_level = Column(String(20), default="warning", comment="warning/critical")
|
||||
status = Column(String(20), default="open", comment="open/resolved/ignored")
|
||||
suggestion = Column(String(500), nullable=True, comment="处理建议")
|
||||
alert_type = Column(String(30), nullable=True, comment="归因场景: cost_high/revenue_drop/cash_low/cash_critical (2026-08-28 告警归因P1-③)")
|
||||
attribution = Column(JSON, nullable=True, comment="归因JSON: 子KPI拆解+科目拆解+量价差+趋势 (2026-08-28)")
|
||||
scenario_id = Column(Integer, nullable=True, comment="FK scenario_suggestions.id 场景建议 (2026-08-28)")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class ReportHistory(Base):
|
||||
"""自动生成的经营分析报告记录"""
|
||||
__tablename__ = "report_history"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
report_type = Column(String(20), nullable=False, comment="weekly/monthly/special")
|
||||
period = Column(String(20), nullable=False, comment="期间: 2026-W30 / 2026-07 / 2026-Q2")
|
||||
title = Column(String(200), nullable=False, comment="报告标题")
|
||||
markdown_content = Column(Text, nullable=True, comment="Markdown格式报告(用于微信推送)")
|
||||
json_content = Column(JSON, nullable=True, comment="JSON结构化数据(写入CMA系统)")
|
||||
status = Column(String(20), default="generated", comment="generated/pushed/failed")
|
||||
trigger_type = Column(String(20), default="manual", comment="manual/scheduled/event")
|
||||
alert_ref = Column(String(50), nullable=True, comment="事件触发时的预警引用")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class AnalysisResult(Base):
|
||||
"""财务Bot分析结论 — 带置信度评分"""
|
||||
__tablename__ = "analysis_results"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||
conclusion = Column(String(1000), nullable=False, comment="分析结论")
|
||||
confidence = Column(Integer, nullable=False, comment="置信度 0-100")
|
||||
data_source = Column(String(500), nullable=True, comment="数据来源")
|
||||
calculation_logic = Column(String(1000), nullable=True, comment="计算逻辑")
|
||||
comparable_benchmark = Column(String(500), nullable=True, comment="可比基准")
|
||||
limitations = Column(String(1000), nullable=True, comment="局限说明")
|
||||
has_actual = Column(Integer, default=0, comment="有实际值")
|
||||
has_target = Column(Integer, default=0, comment="有目标值")
|
||||
has_trend = Column(Integer, default=0, comment="有历史趋势")
|
||||
has_review = Column(Integer, default=0, comment="有人工复核")
|
||||
kpi_code = Column(String(50), nullable=True, comment="关联KPI编码")
|
||||
kpi_name = Column(String(200), nullable=True, comment="关联KPI名称")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class ForecastAccuracy(Base):
|
||||
"""预测准确率 — 上期预测 vs 本期实际"""
|
||||
__tablename__ = "forecast_accuracy"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业ID")
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-07")
|
||||
forecast_value = Column(Float, nullable=True, comment="预测值")
|
||||
actual_value = Column(Float, nullable=True, comment="实际值")
|
||||
mae = Column(Float, nullable=True, comment="绝对误差")
|
||||
mape = Column(Float, nullable=True, comment="百分比误差")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class ScenarioSuggestion(Base):
|
||||
"""情景建议模板 — 根据不同预警类型自动生成建议"""
|
||||
__tablename__ = "scenario_suggestions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
alert_type = Column(String(30), nullable=False, comment="预警类型: cash_low/cash_critical/cost_high/revenue_drop")
|
||||
title = Column(String(200), nullable=False, comment="建议标题")
|
||||
description = Column(Text, nullable=True, comment="详细建议")
|
||||
action_template = Column(Text, nullable=True, comment="改善行动模板")
|
||||
priority = Column(String(20), default="medium", comment="high/medium/low")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 新30号准则模型 (2027)
|
||||
# ============================================================
|
||||
|
||||
class Subject(Base):
|
||||
"""会计科目 — 新30号准则分类"""
|
||||
__tablename__ = "subjects"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subject_code = Column(String(20), nullable=False, unique=True, comment="科目编码")
|
||||
subject_name = Column(String(200), nullable=False, comment="科目名称")
|
||||
parent_code = Column(String(20), nullable=True, comment="上级科目编码")
|
||||
level = Column(Integer, default=1, comment="科目级别 1-4")
|
||||
category = Column(String(50), nullable=True, comment="科目类别")
|
||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类: operating/investing/financing/tax/discontinued")
|
||||
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
|
||||
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
|
||||
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
|
||||
is_active = Column(Integer, default=1, comment="是否启用")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class VoucherDetail(Base):
|
||||
"""凭证明细 — 新30号准则分类 (网银流水导入 2026-08-28)"""
|
||||
__tablename__ = "voucher_details"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)")
|
||||
voucher_no = Column(String(50), nullable=False, comment="凭证编号")
|
||||
voucher_date = Column(DateTime, nullable=False, comment="凭证日期")
|
||||
subject_code = Column(String(20), nullable=False, comment="科目编码")
|
||||
subject_name = Column(String(200), nullable=True, comment="科目名称")
|
||||
debit_amount = Column(Float, default=0, comment="借方金额")
|
||||
credit_amount = Column(Float, default=0, comment="贷方金额")
|
||||
summary = Column(String(500), nullable=True, comment="摘要")
|
||||
carry_forward = Column(Integer, default=0, nullable=False, comment="结转行标记(1=结转行不参与现金流)")
|
||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
|
||||
period = Column(String(20), nullable=True, comment="期间 YYYY-MM")
|
||||
batch = Column(String(100), nullable=True, comment="导入批次号")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class ImportLog(Base):
|
||||
"""数据导入日志 — 网银流水/Excel导入批次记录 (2026-08-28)"""
|
||||
__tablename__ = "import_logs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)")
|
||||
filename = Column(String(500), nullable=False, comment="文件名")
|
||||
batch = Column(String(100), nullable=False, comment="批次号")
|
||||
total_rows = Column(Integer, nullable=True, comment="总行数")
|
||||
success_rows = Column(Integer, nullable=True, comment="成功行数")
|
||||
failed_rows = Column(Integer, nullable=True, comment="失败行数")
|
||||
errors = Column(JSON, nullable=True, comment="失败详情 [{row, field, reason}]")
|
||||
period = Column(String(20), nullable=True, comment="导入期间")
|
||||
import_type = Column(String(20), nullable=True, comment="导入类型: vouchers/kpi")
|
||||
created_by = Column(String(100), nullable=True, comment="导入人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 费用审核智能体 (2026-08)
|
||||
# ============================================================
|
||||
|
||||
class ExpenseRule(Base):
|
||||
"""费用规则 — 自动校验报销单的标准"""
|
||||
__tablename__ = "expense_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
rule_name = Column(String(100), nullable=False, comment="规则名称")
|
||||
dimension = Column(String(20), nullable=False, comment="维度: department/person/expense_type")
|
||||
dimension_value = Column(String(100), nullable=True, comment="维度值: 部门名/人员名/费用类型(空=全局)")
|
||||
expense_type = Column(String(30), nullable=False, comment="费用类型: entertainment/travel/office/management")
|
||||
limit_type = Column(String(20), nullable=False, comment="限额类型: single/monthly/yearly 单笔/月度累计/年度累计")
|
||||
limit_amount = Column(Float, nullable=False, comment="限额金额")
|
||||
cycle = Column(String(20), default="monthly", comment="周期: single/monthly/yearly")
|
||||
status = Column(String(20), default="active", comment="active/inactive")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ExpenseReimbursement(Base):
|
||||
"""费用报销单 — 提交后自动校验规则,超限自动打回"""
|
||||
__tablename__ = "expense_reimbursements"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
reimb_no = Column(String(50), unique=True, nullable=False, comment="报销单号")
|
||||
applicant = Column(String(100), nullable=False, comment="申请人")
|
||||
department = Column(String(100), nullable=True, comment="部门")
|
||||
expense_type = Column(String(30), nullable=False, comment="费用类型: entertainment/travel/office/management")
|
||||
title = Column(String(200), nullable=False, comment="事由/摘要")
|
||||
amount = Column(Float, nullable=False, comment="报销金额")
|
||||
expense_date = Column(DateTime, nullable=True, comment="费用发生日期")
|
||||
attachment = Column(String(500), nullable=True, comment="附件文件名")
|
||||
status = Column(String(20), default="pending", comment="pending待审批/approved已通过/rejected已拒绝/returned已打回")
|
||||
check_result = Column(String(20), default="pass", comment="自动校验结果: pass/fail")
|
||||
check_reason = Column(String(1000), nullable=True, comment="超限原因")
|
||||
check_detail = Column(JSON, nullable=True, comment="校验明细: [{rule_name, limit, actual, passed}]")
|
||||
checked_at = Column(DateTime, nullable=True, comment="自动校验时间")
|
||||
approver = Column(String(100), nullable=True, comment="审批人")
|
||||
approve_comment = Column(String(500), nullable=True, comment="审批意见")
|
||||
approved_at = Column(DateTime, nullable=True, comment="审批时间")
|
||||
created_by = Column(String(100), nullable=True, comment="提交人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 税务合规智能体 (2026-08)
|
||||
# ① 税负监控 ② 发票校验 ③ 社保比对
|
||||
# ============================================================
|
||||
|
||||
class TaxRecord(Base):
|
||||
"""税务记录 — 税负监控:应纳税额/实缴额/税负率 vs 行业基准"""
|
||||
__tablename__ = "tax_records"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID")
|
||||
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||
tax_type = Column(String(30), nullable=False, comment="税种: vat增值税/income所得税/surtax附加税")
|
||||
tax_payable = Column(Float, default=0, comment="应纳税额")
|
||||
tax_paid = Column(Float, default=0, comment="实缴税额")
|
||||
tax_rate = Column(Float, nullable=True, comment="适用税率 %")
|
||||
income = Column(Float, default=0, comment="当期收入/计税收入(税负率分母)")
|
||||
tax_burden_rate = Column(Float, nullable=True, comment="税负率 % = 实缴税额/收入×100")
|
||||
burden_status = Column(String(20), default="normal", comment="normal正常/warning超基准±20%内/alert超基准±20%")
|
||||
warning_msg = Column(String(500), nullable=True, comment="预警信息")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class InvoiceCheck(Base):
|
||||
"""发票校验 — 录入后按规则自动校验:号码格式/金额匹配报销单/供应商匹配合同"""
|
||||
__tablename__ = "invoice_check"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID")
|
||||
invoice_no = Column(String(50), nullable=False, comment="发票号码")
|
||||
amount = Column(Float, nullable=False, comment="发票金额")
|
||||
invoice_type = Column(String(30), default="vat", comment="发票类型: vat专用/vat普通/electronic电子/other其他")
|
||||
invoice_date = Column(DateTime, nullable=True, comment="开票日期")
|
||||
supplier = Column(String(200), nullable=True, comment="供应商名称")
|
||||
reimb_no = Column(String(50), nullable=True, comment="关联报销单号")
|
||||
contract_no = Column(String(50), nullable=True, comment="关联合同编号")
|
||||
check_status = Column(String(20), default="pending", comment="校验状态: pending待校验/valid通过/invalid异常/warning提醒")
|
||||
check_result = Column(JSON, nullable=True, comment="校验明细: [{rule, passed, message}]")
|
||||
check_reason = Column(String(1000), nullable=True, comment="异常原因汇总")
|
||||
checked_at = Column(DateTime, nullable=True, comment="校验时间")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SocialSecurity(Base):
|
||||
"""社保缴费记录 — 社保比对:基数与工资匹配/单位缴纳比例/漏缴提醒"""
|
||||
__tablename__ = "social_security"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID")
|
||||
employee = Column(String(100), nullable=False, comment="人员姓名")
|
||||
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||
base_amount = Column(Float, default=0, comment="缴费基数")
|
||||
salary = Column(Float, nullable=True, comment="申报工资")
|
||||
company_amount = Column(Float, default=0, comment="单位缴纳金额")
|
||||
personal_amount = Column(Float, default=0, comment="个人缴纳金额")
|
||||
company_rate = Column(Float, nullable=True, comment="单位缴纳比例 % (养老16%+医疗8%+失业0.5%≈24.5%)")
|
||||
check_status = Column(String(20), default="normal", comment="normal正常/warning基数或比例异常/alert漏缴")
|
||||
warning_msg = Column(String(500), nullable=True, comment="异常提醒: 漏缴/基数不符/比例异常")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 本体三支柱: 科目↔KPI↔OKR 三层互联 (2026-08-19)
|
||||
# 追溯链: 目标(O) → 指标(KPI) → 科目(数据)
|
||||
# ============================================================
|
||||
|
||||
class KPISubjectMap(Base):
|
||||
"""科目↔KPI映射 — 指标计算依赖的底层会计科目"""
|
||||
__tablename__ = "kpi_subject_map"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
|
||||
subject_code = Column(String(20), nullable=False, comment="科目编码")
|
||||
calc_type = Column(String(20), default="sum", comment="sum/avg/ratio/other")
|
||||
weight = Column(Numeric(5, 2), default=1.00, comment="权重(负=扣减项)")
|
||||
remark = Column(String(200), nullable=True, comment="备注")
|
||||
__table_args__ = (UniqueConstraint("kpi_id", "subject_code", name="uk_kpi_subject"),)
|
||||
|
||||
|
||||
class ObjectiveKPI(Base):
|
||||
"""KPI↔O支撑 — 目标由哪些KPI度量"""
|
||||
__tablename__ = "objective_kpi"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
|
||||
weight = Column(Numeric(5, 2), default=1.00, comment="支撑权重")
|
||||
__table_args__ = (UniqueConstraint("objective_id", "kpi_id", name="uk_obj_kpi"),)
|
||||
|
||||
|
||||
class KR(Base):
|
||||
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
|
||||
__tablename__ = "krs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
||||
title = Column(String(200), nullable=False, comment="KR标题")
|
||||
metric_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=True, comment="度量KPI ID")
|
||||
operator = Column(String(5), default=">=", nullable=False, comment="方向符号: >=/<=/>/</= (KR完整修复 2026-08-27)")
|
||||
tolerance = Column(Numeric(10, 4), nullable=True, comment="容差(仅=时使用)")
|
||||
weight = Column(Numeric(5, 2), nullable=True, comment="KR权重%")
|
||||
target_value = Column(Numeric(15, 2), nullable=True, comment="目标值")
|
||||
current_value = Column(Numeric(15, 2), nullable=True, comment="当前值")
|
||||
progress = Column(Integer, default=0, comment="完成进度 0-100")
|
||||
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled/achieved")
|
||||
due_date = Column(Date, nullable=True, comment="截止日期")
|
||||
monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 预算系统技术改进 (2026-08-28 yanxue-budget-tech-improve)
|
||||
# ① kpi_value_sources/kpi_value_collect_logs: 实际值自动归集 P1-④
|
||||
# ② budget_zero_based_items: 真零基逐项论证 P2-①
|
||||
# ③ budget_derivation_rules: 派生规则可配置 P2-②
|
||||
# ④ cash_plan_classify_rules/cash_plan_unclassified: 现金流分类规则 P2-⑥
|
||||
# ============================================================
|
||||
|
||||
class KPIValueSource(Base):
|
||||
"""KPI实际值取数映射 — 自动归集源头 (P1-④ 2026-08-28)"""
|
||||
__tablename__ = "kpi_value_sources"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI")
|
||||
source_table = Column(String(50), nullable=False, comment="源头表: voucher_details/product_inventory/product_inventory_detail/cash_plans")
|
||||
source_field = Column(String(50), nullable=False, comment="金额字段: credit_amount/debit_amount/amount/qty")
|
||||
aggregate = Column(String(10), default="sum", comment="sum/avg/count/max/min")
|
||||
filter_rule = Column(JSON, nullable=True, comment="过滤: {\"subject_code\":\"6601\",\"direction\":\"credit\"}")
|
||||
period_field = Column(String(50), default="period", comment="期间字段: period/voucher_date")
|
||||
unit_conversion = Column(Float, default=1, comment="单位倍率(元→万元/10000)")
|
||||
status = Column(String(20), default="active", comment="active/inactive")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
__table_args__ = (UniqueConstraint("entity_id", "kpi_id", "source_table", name="uk_source"),)
|
||||
|
||||
|
||||
class KPIValueCollectLog(Base):
|
||||
"""实际值采集日志 — 每次自动归集记录 (P1-④ 2026-08-28)"""
|
||||
__tablename__ = "kpi_value_collect_logs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, nullable=False, comment="企业ID")
|
||||
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
|
||||
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||
source_table = Column(String(50), nullable=False, comment="源头表")
|
||||
collected_value = Column(Float, nullable=True, comment="采集到的值")
|
||||
status = Column(String(20), default="success", comment="success/failed")
|
||||
message = Column(String(500), nullable=True, comment="说明/错误信息")
|
||||
collected_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class BudgetZeroBasedItem(Base):
|
||||
"""零基预算逐项论证项 (P2-① 2026-08-28)"""
|
||||
__tablename__ = "budget_zero_based_items"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||
item_name = Column(String(200), nullable=False, comment="费用科目名")
|
||||
item_category = Column(String(20), default="discretionary", comment="fixed/variable/discretionary")
|
||||
base_value = Column(Float, nullable=False, comment="基准值(上年/上月实际)")
|
||||
justification = Column(Text, nullable=True, comment="逐项论证理由(为何保留/削减/取消)")
|
||||
proposed_value = Column(Float, nullable=False, comment="论证后金额")
|
||||
status = Column(String(20), default="draft", comment="draft/approved")
|
||||
created_by = Column(String(100), nullable=True, comment="创建人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class BudgetDerivationRule(Base):
|
||||
"""KPI派生规则 — apply-method 可配置派生 (P2-② 2026-08-28)"""
|
||||
__tablename__ = "budget_derivation_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI")
|
||||
rule_type = Column(String(30), nullable=False, comment="incremental/percentage_of/formula")
|
||||
base_kpi_id = Column(Integer, nullable=True, comment="来源KPI(percentage_of用)")
|
||||
params = Column(JSON, nullable=True, comment="{\"rate\":0.02,\"field\":\"net_profit\"}")
|
||||
formula_text = Column(String(500), nullable=True, comment="可读公式说明")
|
||||
status = Column(String(20), default="active", comment="active/inactive")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
__table_args__ = (UniqueConstraint("entity_id", "kpi_id", "rule_type", name="uk_rule"),)
|
||||
|
||||
|
||||
class CashPlanClassifyRule(Base):
|
||||
"""现金流收付分类规则 — KPI→receive/pay 可维护 (P2-⑥ 2026-08-28)"""
|
||||
__tablename__ = "cash_plan_classify_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||
kpi_id = Column(Integer, nullable=True, comment="精确匹配KPI,优先")
|
||||
kpi_code_pattern = Column(String(200), nullable=True, comment="关键词/编码模式匹配,兜底")
|
||||
plan_type = Column(String(10), nullable=False, comment="receive/pay")
|
||||
priority = Column(Integer, default=10, comment="匹配顺序,小优先")
|
||||
status = Column(String(20), default="active", comment="active/inactive")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class CashPlanUnclassified(Base):
|
||||
"""现金流待分类KPI队列 — 无法判别的KPI不静默跳过 (P2-⑥ 2026-08-28)"""
|
||||
__tablename__ = "cash_plan_unclassified"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
|
||||
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
|
||||
kpi_name = Column(String(200), nullable=True, comment="KPI名称")
|
||||
period = Column(String(20), nullable=True, comment="期间")
|
||||
budget_value = Column(Float, nullable=True, comment="预算值")
|
||||
reason = Column(String(200), nullable=True, comment="无法分类原因")
|
||||
status = Column(String(20), default="pending", comment="pending/classified/ignored")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,8 @@ class BudgetPlan(Base):
|
||||
__tablename__ = "budget_plans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
map_id = Column(Integer, nullable=True, comment="归属战略地图ID (预算按地图隔离 2026-08-27)")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
period = Column(String(20), nullable=False, comment="预算期间 2026-05")
|
||||
budget_value = Column(Float, nullable=False, comment="预算值")
|
||||
@@ -15,6 +17,9 @@ class BudgetPlan(Base):
|
||||
budget_month = Column(Integer, nullable=False, comment="预算月份 1-12")
|
||||
version = Column(String(20), default="v1.0", comment="版本号 v1.0/v2.0")
|
||||
status = Column(String(20), default="active", comment="active/archived")
|
||||
source_type = Column(String(50), nullable=True, comment="来源类型: manual/kpi_generated/roll_forward等")
|
||||
source_kpi_id = Column(Integer, nullable=True, comment="来源KPI ID(KPI推算生成时记录)")
|
||||
calc_logic = Column(Text, nullable=True, comment="预算计算逻辑说明")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_by = Column(String(100), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
@@ -10,6 +10,7 @@ class StandardCost(Base):
|
||||
__tablename__ = "standard_costs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
|
||||
cost_type = Column(String(20), nullable=False, comment="成本类型: material/labor/overhead")
|
||||
@@ -30,6 +31,7 @@ class ActualCost(Base):
|
||||
__tablename__ = "actual_costs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
|
||||
@@ -47,6 +49,7 @@ class AbcActivity(Base):
|
||||
__tablename__ = "abc_activities"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
activity_code = Column(String(50), unique=True, nullable=False, comment="作业编码")
|
||||
activity_name = Column(String(200), nullable=False, comment="作业名称")
|
||||
activity_desc = Column(Text, nullable=True, comment="作业描述")
|
||||
@@ -65,6 +68,7 @@ class AbcAllocation(Base):
|
||||
__tablename__ = "abc_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
activity_id = Column(Integer, ForeignKey("abc_activities.id"), nullable=False)
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""驱动因子预算模型 — 业务驱动因子 vs 科目模式"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, JSON, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DriverFactorTemplate(Base):
|
||||
"""驱动因子模板 — 通用/行业版本"""
|
||||
__tablename__ = "driver_factor_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False, comment="模板名称")
|
||||
industry = Column(String(50), default="general", comment="行业标签: general/trade/it")
|
||||
category = Column(String(50), default="revenue", comment="类别: revenue/expense")
|
||||
formula_desc = Column(String(500), nullable=True, comment="公式说明")
|
||||
formula_text = Column(String(500), nullable=False, comment="公式文本, 如: 客户数×客单价")
|
||||
factors = Column(JSON, nullable=False, comment="驱动因子列表")
|
||||
is_active = Column(Integer, default=1, comment="是否启用")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class DriverFactorBudget(Base):
|
||||
"""驱动因子预算计算结果"""
|
||||
__tablename__ = "driver_factor_budgets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
|
||||
name = Column(String(200), nullable=False, comment="预算项名称")
|
||||
industry = Column(String(50), default="general", comment="行业标签")
|
||||
template_id = Column(Integer, nullable=True, comment="关联模板ID")
|
||||
factors = Column(JSON, nullable=False, comment="驱动因子键值对")
|
||||
calculated_value = Column(Float, nullable=False, comment="计算结果")
|
||||
formula_text = Column(String(500), nullable=True, comment="公式文本")
|
||||
period = Column(String(20), nullable=True, comment="期间")
|
||||
sensitivity = Column(JSON, nullable=True, comment="敏感性分析结果")
|
||||
created_by = Column(String(100), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,24 @@
|
||||
"""商品销售数据模型 — 波士顿产品矩阵
|
||||
从《商品销售排行榜》Excel导入,支撑四象限分析
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProductSales(Base):
|
||||
"""商品销售月度数据"""
|
||||
__tablename__ = "product_sales"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, comment="企业ID: 1=酣客 2=博海")
|
||||
product_code = Column(String(50), nullable=False, comment="商品编码")
|
||||
product_name = Column(String(100), nullable=False, comment="商品名称")
|
||||
period_month = Column(String(10), nullable=False, comment="期间 YYYY-MM")
|
||||
sales_amount = Column(Float, default=0, comment="销售金额")
|
||||
cost_amount = Column(Float, default=0, comment="成本金额")
|
||||
gross_profit = Column(Float, default=0, comment="毛利")
|
||||
gross_margin_rate = Column(Float, default=0, comment="毛利率(%)")
|
||||
sales_qty = Column(Integer, default=0, comment="销售数量")
|
||||
unit = Column(String(30), default="", comment="单位")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
CMA Bot API 风险分级(L1-L4)与路由标注装饰器
|
||||
=============================================
|
||||
|
||||
分级定义(方案文档 nexa-permission-autonomy-plan-20260828 第二节):
|
||||
L1 只读查询 — X-BOT-KEY验证后直接放行
|
||||
L2 业务写(单条) — 放行 + 写前校验(entity归属/字段校验)
|
||||
L3 批量写/创建 — 放行 + 限制批量 + source_batch审计
|
||||
L4 危险 — 不向Bot API开放(DROP/TRUNCATE/批量DELETE/生产结构修改)
|
||||
|
||||
安全底线: Bot API面不存在L4端点(0项开放 = 天然隔离)。
|
||||
"""
|
||||
import functools
|
||||
|
||||
RISK_LEVELS = {
|
||||
"L1": "只读",
|
||||
"L2": "业务写",
|
||||
"L3": "批量写",
|
||||
"L4": "危险",
|
||||
}
|
||||
|
||||
RISK_HANDLING = {
|
||||
"L1": "X-BOT-KEY验证后直接放行",
|
||||
"L2": "放行 + 写前校验(entity归属/字段校验)",
|
||||
"L3": "放行 + 限制批量 + source_batch审计",
|
||||
"L4": "不向Bot API开放(终端层拦截 + 人工审批)",
|
||||
}
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# API → 风险级别 清单(Bot API面全量路由)
|
||||
# 键格式: "METHOD path"(path 与 FastAPI route.path 一致,含 {param} 占位符)
|
||||
# ────────────────────────────────────────────────
|
||||
API_RISK_MAP = {
|
||||
# ── L1 只读(20项方案清单 + risk-levels查询端点) ──
|
||||
"GET /api/cma/bot/ping": "L1",
|
||||
"GET /api/cma/bot/overview": "L1",
|
||||
"GET /api/cma/bot/kpis": "L1",
|
||||
"GET /api/cma/bot/kpis/{kpi_id}/history": "L1",
|
||||
"GET /api/cma/bot/strategic-maps": "L1",
|
||||
"GET /api/cma/bot/alerts": "L1",
|
||||
"GET /api/cma/bot/budget/plans": "L1",
|
||||
"GET /api/cma/bot/cost/standard": "L1",
|
||||
"GET /api/cma/bot/cost/actual": "L1",
|
||||
"GET /api/cma/bot/actions": "L1",
|
||||
"GET /api/cma/bot/organization": "L1",
|
||||
"GET /api/cma/bot/data-sources": "L1",
|
||||
"GET /api/cma/bot/users": "L1",
|
||||
"GET /api/cma/bot/query": "L1",
|
||||
"GET /api/cma/bot/okr/list": "L1",
|
||||
"GET /api/cma/bot/nlp": "L1",
|
||||
"GET /api/cma/bot/iron-law": "L1",
|
||||
"GET /api/cma/bot/iron-law/bots": "L1",
|
||||
"GET /api/cma/bot-bridge/verify/{action_plan_id}/history": "L1",
|
||||
"GET /api/cma/bot-kpis": "L1",
|
||||
# 本任务新增的只读端点
|
||||
"GET /api/cma/bot/risk-levels": "L1",
|
||||
# ── L2 业务写(4项方案清单 + okr/create单条业务写) ──
|
||||
"POST /api/cma/bot/kpi-value-with-check": "L2",
|
||||
"POST /api/cma/bot-bridge/kpi-result": "L2",
|
||||
"POST /api/cma/bot-kpis/{kpi_id}/value": "L2",
|
||||
"POST /api/cma/bot-bridge/verify/{action_plan_id}": "L2",
|
||||
"POST /api/cma/bot/okr/create": "L2", # 单条OKR创建(方案清单未列出,按单条业务写归类)
|
||||
# ── L3 批量写/创建(3项) ──
|
||||
"POST /api/cma/bot/import": "L3",
|
||||
"POST /api/cma/bot/kpis/create-with-links": "L3",
|
||||
"POST /api/cma/bot-bridge/mpm-result": "L3",
|
||||
# ── L4 危险:Bot API面不存在(安全底线,不添加) ──
|
||||
}
|
||||
|
||||
|
||||
def risk_level(level: str):
|
||||
"""路由标注装饰器: @risk_level('L1') 挂在路由函数上(router.get/post 之下)。
|
||||
|
||||
同时把级别属性写到原函数与包装函数上,保证 route.endpoint 无论取到哪个
|
||||
都能通过 getattr(endpoint, 'risk_level') 解析。
|
||||
"""
|
||||
def decorator(func):
|
||||
func.risk_level = level
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper.risk_level = level
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_risk_level(method: str, path: str):
|
||||
"""按 METHOD + path(FastAPI模板路径)查级别,未标注返回 None"""
|
||||
return API_RISK_MAP.get(f"{method.upper()} {path}")
|
||||
|
||||
|
||||
def get_handling(level: str) -> str:
|
||||
"""级别 → 处理方式说明"""
|
||||
return RISK_HANDLING.get(level, "")
|
||||
|
||||
|
||||
def list_api_risk_map() -> list:
|
||||
"""返回 API→级别→处理方式 清单(供 GET /api/cma/bot/risk-levels 使用)"""
|
||||
items = []
|
||||
for key, level in API_RISK_MAP.items():
|
||||
method, path = key.split(" ", 1)
|
||||
items.append({
|
||||
"method": method,
|
||||
"path": path,
|
||||
"risk_level": level,
|
||||
"handling": get_handling(level),
|
||||
})
|
||||
items.sort(key=lambda x: (x["risk_level"], x["method"], x["path"]))
|
||||
return items
|
||||
|
||||
|
||||
def risk_summary() -> dict:
|
||||
"""各级别端点数量统计"""
|
||||
summary = {lv: 0 for lv in RISK_LEVELS}
|
||||
for level in API_RISK_MAP.values():
|
||||
summary[level] = summary.get(level, 0) + 1
|
||||
return summary
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user