From 3dddd368669901e49f6bd11661c73da4b3f42a7d Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Thu, 28 May 2026 17:32:22 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E7=AE=A1=E7=90=86=E4=BC=9A=E8=AE=A1OS?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0 --- .gitignore | 6 + ARCHITECTURE.md | 41 + README.md | 117 + TICKETS.md | 239 ++ architecture/strategic-map-enhancement.md | 158 + architecture/task-assignment.md | 76 + backend/.env.example | 11 + backend/app/__init__.py | 0 .../app/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 141 bytes .../auth_middleware.cpython-312.pyc | Bin 0 -> 8577 bytes .../app/__pycache__/database.cpython-312.pyc | Bin 0 -> 5453 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 0 -> 6873 bytes backend/app/api/__init__.py | 0 .../api/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 145 bytes .../__pycache__/action_plans.cpython-312.pyc | Bin 0 -> 7037 bytes .../__pycache__/ai_analysis.cpython-312.pyc | Bin 0 -> 21596 bytes .../__pycache__/alert_rules.cpython-312.pyc | Bin 0 -> 4204 bytes .../api/__pycache__/alerts.cpython-312.pyc | Bin 0 -> 2902 bytes .../api/__pycache__/alignment.cpython-312.pyc | Bin 0 -> 13702 bytes .../app/api/__pycache__/auth.cpython-312.pyc | Bin 0 -> 4096 bytes .../api/__pycache__/budget.cpython-312.pyc | Bin 0 -> 15076 bytes .../app/api/__pycache__/cost.cpython-312.pyc | Bin 0 -> 13038 bytes .../api/__pycache__/dashboard.cpython-312.pyc | Bin 0 -> 28080 bytes .../app/api/__pycache__/data.cpython-312.pyc | Bin 0 -> 7263 bytes .../app/api/__pycache__/kpis.cpython-312.pyc | Bin 0 -> 11002 bytes .../app/api/__pycache__/maps.cpython-312.pyc | Bin 0 -> 15928 bytes .../__pycache__/notifications.cpython-312.pyc | Bin 0 -> 6528 bytes .../__pycache__/objectives.cpython-312.pyc | Bin 0 -> 5350 bytes .../app/api/__pycache__/org.cpython-312.pyc | Bin 0 -> 6489 bytes .../__pycache__/permissions.cpython-312.pyc | Bin 0 -> 4688 bytes .../api/__pycache__/predict.cpython-312.pyc | Bin 0 -> 5115 bytes .../__pycache__/thresholds.cpython-312.pyc | Bin 0 -> 5834 bytes .../app/api/__pycache__/users.cpython-312.pyc | Bin 0 -> 4645 bytes .../api/__pycache__/versions.cpython-312.pyc | Bin 0 -> 5139 bytes backend/app/api/action_plans.py | 145 + backend/app/api/ai_analysis.py | 325 ++ backend/app/api/alert_rules.py | 76 + backend/app/api/alerts.py | 30 + backend/app/api/alignment.py | 309 ++ backend/app/api/auth.py | 70 + backend/app/api/budget.py | 336 ++ backend/app/api/cost.py | 237 ++ backend/app/api/dashboard.py | 495 +++ backend/app/api/data.py | 100 + backend/app/api/kpis.py | 169 + backend/app/api/maps.py | 403 ++ backend/app/api/notifications.py | 113 + backend/app/api/objectives.py | 83 + backend/app/api/org.py | 101 + backend/app/api/permissions.py | 121 + backend/app/api/predict.py | 98 + backend/app/api/thresholds.py | 97 + backend/app/api/users.py | 66 + backend/app/api/versions.py | 86 + backend/app/auth_middleware.py | 189 + backend/app/database.py | 122 + backend/app/main.py | 115 + backend/app/models/__init__.py | 214 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 15244 bytes .../__pycache__/budget_plan.cpython-312.pyc | Bin 0 -> 1805 bytes .../__pycache__/cost_model.cpython-312.pyc | Bin 0 -> 5347 bytes backend/app/models/budget_plan.py | 21 + backend/app/models/cost_model.py | 74 + backend/app/utils/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 147 bytes .../utils/__pycache__/cache.cpython-312.pyc | Bin 0 -> 4289 bytes .../__pycache__/calc_engine.cpython-312.pyc | Bin 0 -> 12611 bytes .../__pycache__/cost_engine.cpython-312.pyc | Bin 0 -> 13624 bytes .../deviation_engine.cpython-312.pyc | Bin 0 -> 12407 bytes .../__pycache__/notifier.cpython-312.pyc | Bin 0 -> 12061 bytes .../predict_engine.cpython-312.pyc | Bin 0 -> 8363 bytes backend/app/utils/cache.py | 100 + backend/app/utils/calc_engine.py | 93 + backend/app/utils/cost_engine.py | 282 ++ backend/app/utils/deviation_engine.py | 311 ++ backend/app/utils/notifier.py | 251 ++ backend/app/utils/predict_engine.py | 260 ++ backend/check_db.py | 13 + backend/cma.db | 0 backend/deploy.sh | 25 + backend/dev.sh | 25 + backend/requirements.txt | 12 + .../__pycache__/ai_brief.cpython-312.pyc | Bin 0 -> 10972 bytes .../alert_generator.cpython-312.pyc | Bin 0 -> 7588 bytes .../__pycache__/erp_sync.cpython-312.pyc | Bin 0 -> 17530 bytes backend/scripts/ai_brief.py | 201 + backend/scripts/alert_generator.py | 178 + backend/scripts/daily_sync.py | 72 + backend/scripts/erp_sync.py | 374 ++ backend/test_sync.py | 11 + backend/test_sync_profit.py | 10 + check.sh | 60 + check_cma.py | 52 + check_cma_detail.py | 140 + check_raw.py | 82 + deploy.sh | 39 + frontend/.env.example | 3 + frontend/.prettierrc | 10 + frontend/auto-imports.d.ts | 10 + frontend/components.d.ts | 63 + frontend/deploy.sh | 19 + frontend/dev.sh | 15 + frontend/index.html | 33 + frontend/package.json | 52 + frontend/pnpm-lock.yaml | 3377 +++++++++++++++++ frontend/pnpm-workspace.yaml | 3 + frontend/src/App.vue | 6 + frontend/src/api/index.ts | 128 + frontend/src/api/orgApi.ts | 10 + frontend/src/components/MyDialog.vue | 91 + frontend/src/env.d.ts | 10 + frontend/src/layouts/MainLayout.vue | 166 + frontend/src/main.ts | 26 + frontend/src/permission.ts | 87 + frontend/src/router/index.ts | 95 + frontend/src/style.css | 356 ++ frontend/src/views/AlertList.vue | 292 ++ frontend/src/views/BudgetManagement.vue | 311 ++ frontend/src/views/CMAKnowledge.vue | 357 ++ frontend/src/views/CostDashboard.vue | 523 +++ frontend/src/views/Dashboard.vue | 575 +++ frontend/src/views/DataManage.vue | 169 + frontend/src/views/DeviationDashboard.vue | 317 ++ frontend/src/views/KPIDetail.vue | 164 + frontend/src/views/KPIList.vue | 565 +++ frontend/src/views/Login.vue | 48 + frontend/src/views/MapCanvas.vue | 732 ++++ frontend/src/views/MapList.vue | 103 + frontend/src/views/MapReview.vue | 343 ++ frontend/src/views/MyDashboard.vue | 205 + frontend/src/views/NewUserGuide.vue | 244 ++ frontend/src/views/NotificationManage.vue | 201 + frontend/src/views/OrgManage.vue | 324 ++ frontend/src/views/PredictDashboard.vue | 287 ++ frontend/src/views/RolePermissions.vue | 328 ++ frontend/src/views/UserManage.vue | 131 + frontend/tsconfig.app.json | 17 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 16 + frontend/vite.config.ts | 59 + gen_report.py | 183 + start-dev.sh | 38 + 142 files changed, 18533 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100644 TICKETS.md create mode 100644 architecture/strategic-map-enhancement.md create mode 100644 architecture/task-assignment.md create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/__pycache__/auth_middleware.cpython-312.pyc create mode 100644 backend/app/__pycache__/database.cpython-312.pyc create mode 100644 backend/app/__pycache__/main.cpython-312.pyc create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/action_plans.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/ai_analysis.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/alert_rules.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/alerts.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/alignment.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/auth.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/budget.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/cost.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/dashboard.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/data.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/kpis.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/maps.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/notifications.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/objectives.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/org.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/permissions.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/predict.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/thresholds.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/users.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/versions.cpython-312.pyc create mode 100644 backend/app/api/action_plans.py create mode 100644 backend/app/api/ai_analysis.py create mode 100644 backend/app/api/alert_rules.py create mode 100644 backend/app/api/alerts.py create mode 100644 backend/app/api/alignment.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/budget.py create mode 100644 backend/app/api/cost.py create mode 100644 backend/app/api/dashboard.py create mode 100644 backend/app/api/data.py create mode 100644 backend/app/api/kpis.py create mode 100644 backend/app/api/maps.py create mode 100644 backend/app/api/notifications.py create mode 100644 backend/app/api/objectives.py create mode 100644 backend/app/api/org.py create mode 100644 backend/app/api/permissions.py create mode 100644 backend/app/api/predict.py create mode 100644 backend/app/api/thresholds.py create mode 100644 backend/app/api/users.py create mode 100644 backend/app/api/versions.py create mode 100644 backend/app/auth_middleware.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/budget_plan.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/cost_model.cpython-312.pyc create mode 100644 backend/app/models/budget_plan.py create mode 100644 backend/app/models/cost_model.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/utils/__pycache__/cache.cpython-312.pyc create mode 100644 backend/app/utils/__pycache__/calc_engine.cpython-312.pyc create mode 100644 backend/app/utils/__pycache__/cost_engine.cpython-312.pyc create mode 100644 backend/app/utils/__pycache__/deviation_engine.cpython-312.pyc create mode 100644 backend/app/utils/__pycache__/notifier.cpython-312.pyc create mode 100644 backend/app/utils/__pycache__/predict_engine.cpython-312.pyc create mode 100644 backend/app/utils/cache.py create mode 100644 backend/app/utils/calc_engine.py create mode 100644 backend/app/utils/cost_engine.py create mode 100644 backend/app/utils/deviation_engine.py create mode 100644 backend/app/utils/notifier.py create mode 100644 backend/app/utils/predict_engine.py create mode 100644 backend/check_db.py create mode 100644 backend/cma.db create mode 100755 backend/deploy.sh create mode 100755 backend/dev.sh create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/__pycache__/ai_brief.cpython-312.pyc create mode 100644 backend/scripts/__pycache__/alert_generator.cpython-312.pyc create mode 100644 backend/scripts/__pycache__/erp_sync.cpython-312.pyc create mode 100644 backend/scripts/ai_brief.py create mode 100644 backend/scripts/alert_generator.py create mode 100755 backend/scripts/daily_sync.py create mode 100644 backend/scripts/erp_sync.py create mode 100644 backend/test_sync.py create mode 100644 backend/test_sync_profit.py create mode 100755 check.sh create mode 100644 check_cma.py create mode 100644 check_cma_detail.py create mode 100644 check_raw.py create mode 100755 deploy.sh create mode 100644 frontend/.env.example create mode 100644 frontend/.prettierrc create mode 100644 frontend/auto-imports.d.ts create mode 100644 frontend/components.d.ts create mode 100755 frontend/deploy.sh create mode 100755 frontend/dev.sh create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/pnpm-workspace.yaml create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/orgApi.ts create mode 100644 frontend/src/components/MyDialog.vue create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/layouts/MainLayout.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/permission.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/AlertList.vue create mode 100644 frontend/src/views/BudgetManagement.vue create mode 100644 frontend/src/views/CMAKnowledge.vue create mode 100644 frontend/src/views/CostDashboard.vue create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/DataManage.vue create mode 100644 frontend/src/views/DeviationDashboard.vue create mode 100644 frontend/src/views/KPIDetail.vue create mode 100644 frontend/src/views/KPIList.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/MapCanvas.vue create mode 100644 frontend/src/views/MapList.vue create mode 100644 frontend/src/views/MapReview.vue create mode 100644 frontend/src/views/MyDashboard.vue create mode 100644 frontend/src/views/NewUserGuide.vue create mode 100644 frontend/src/views/NotificationManage.vue create mode 100644 frontend/src/views/OrgManage.vue create mode 100644 frontend/src/views/PredictDashboard.vue create mode 100644 frontend/src/views/RolePermissions.vue create mode 100644 frontend/src/views/UserManage.vue create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 gen_report.py create mode 100755 start-dev.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7da3c23e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +*.local +.env +.DS_Store +*.tsbuildinfo diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..5ab6dd64 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,41 @@ +# 管理会计OS — 技术方案(定稿) + +## 架构 + +cma.sxbh.ltd → Nginx → FastAPI:8010 → MariaDB(cma) + Redis(db1) + +## 数据库(6张核心表) + +strategic_maps — 战略地图 +kpi_definitions — KPI字典 +kpi_values — KPI实际值 +data_source_config — 数据源配置 +kpi_alerts — 预警记录 +operation_logs — 操作日志(新增) + +## MVP范围(Sprint 1-3:9天) + +Epic 1: 战略地图配置器(精简版) +Epic 2: KPI字典引擎 +Epic 3: 数据接入(ERP + Excel) +Epic 4: KPI计算与调度 +Epic 5: 管理驾驶舱(4角色) + +## 优化点已采纳 + +1. MVP先做KPI字典+驾驶舱,再做战略地图画布 +2. 数据源区分实时/离线 +3. 增加数据对账页面 +4. 预警增加处理流程闭环 +5. Excel固定导入模板 +6. 增加操作日志表 +7. MVP只做Epic 1-5 +8. User Story关联Epic + +## 数字员工分工 + +- dev-backend: 数据库建表 + 全部后端API +- dev-frontend: 前端脚手架 + 登录 + KPI字典页面 + 驾驶舱 +- dev-fullstack: ERP对接 + Excel导入 + 计算引擎 +- dev-qa: 全流程验收 +- dev-ops: 域名 + SSL + Nginx + systemd diff --git a/README.md b/README.md new file mode 100644 index 00000000..c13cb1c6 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# 管理会计OS + +管理会计操作系统,基于 FastAPI + MariaDB + Vue 3 + Element Plus。 + +## 快速开始 + +### 开发环境 + +```bash +# 一键启动(前后端同时) +./start-dev.sh + +# 或分步启动: +# 后端 +cd backend && ./dev.sh + +# 前端(新终端) +cd frontend && ./dev.sh +``` + +### 生产部署 + +```bash +# 一键构建+部署 +./deploy.sh +``` + +### 访问地址 + +| 环境 | 地址 | +|------|------| +| 开发前端 | http://localhost:5173 | +| 开发后端API | http://127.0.0.1:8010 | +| 开发API文档 | http://127.0.0.1:8010/docs | +| 生产环境 | https://cma.sxbh.ltd | + +## 项目结构 + +``` +cma-management/ +├── start-dev.sh # 一键启动开发环境 +├── deploy.sh # 一键部署生产 +├── .gitignore +│ +├── backend/ +│ ├── .env / .env.example +│ ├── dev.sh # 后端开发启动 +│ ├── deploy.sh # 后端部署 +│ ├── requirements.txt +│ ├── app/ +│ │ ├── main.py # 入口 + 全局异常处理 +│ │ ├── database.py # 数据库连接池 +│ │ ├── models/ # SQLAlchemy 模型 +│ │ ├── api/ # 路由 +│ │ │ ├── auth.py +│ │ │ ├── kpis.py +│ │ │ ├── maps.py +│ │ │ ├── dashboard.py +│ │ │ ├── alerts.py +│ │ │ ├── data.py +│ │ │ ├── users.py +│ │ │ ├── ai_analysis.py +│ │ │ └── alert_rules.py +│ │ └── utils/ +│ └── tests/ +│ +├── frontend/ +│ ├── .env / .env.example +│ ├── dev.sh # 前端开发启动 +│ ├── deploy.sh # 前端构建部署 +│ ├── .prettierrc +│ ├── vite.config.ts +│ ├── index.html +│ └── src/ +│ ├── main.ts +│ ├── App.vue +│ ├── router/index.ts +│ ├── layouts/MainLayout.vue +│ ├── views/ +│ │ ├── Login.vue +│ │ ├── Dashboard.vue +│ │ ├── KPIList.vue +│ │ ├── KPIDetail.vue +│ │ ├── MapList.vue +│ │ ├── MapCanvas.vue +│ │ ├── AlertList.vue +│ │ ├── DataManage.vue +│ │ └── UserManage.vue +│ └── api/index.ts +│ +└── deploy/ # 部署配置 +``` + +## 系统架构 + +``` +用户 → https://cma.sxbh.ltd → Nginx (Brotli+缓存) + ├── / → 前端 SPA (Vue 3) + └── /api/cma/ → FastAPI:8010 → MariaDB +``` + +## 运维命令 + +```bash +# 后端 +systemctl status cma-backend # 查看状态 +journalctl -u cma-backend -f # 查看日志 +systemctl restart cma-backend # 重启 + +# Nginx +nginx -t # 检查配置 +nginx -s reload # 重载配置 + +# 前端 +cd frontend && pnpm dev # 开发 +cd frontend && pnpm build # 构建 +``` diff --git a/TICKETS.md b/TICKETS.md new file mode 100644 index 00000000..5ca13f73 --- /dev/null +++ b/TICKETS.md @@ -0,0 +1,239 @@ +# 管理会计OS — 工单列表 + +生成日期: 2026-05-26 +排查依据: 架构全景扫描 + API实际验证 + 数据库探查 + +--- + +## P0 — 紧急 + +### Ticket 1: ERP数据自动同步 +**负责人**: dev-fullstack +**预计工时**: 3-5天 +**优先级**: P0 + +**现状**: +- `erp_schema` 表已有11条ERP表结构记录(MasterBill 15003行、ListOrder、ListInvoice、Units、Product等) +- `data_source_config` 表为空(0条记录) +- `kpi_values` 有54条手动导入数据 +- ERP API网关 `erp-api.sxbh.ltd` 已部署 + +**要求**: +1. 新建同步脚本 `/root/cma-management/backend/scripts/erp_sync.py`,支持: + - 从 `data_source_config` 读取ERP连接配置 + - 通过ERP API网关拉取数据 + - 按 `kpi_definitions.formula` 和 `data_source_config.api_endpoint` 进行字段映射计算 + - 计算结果写入 `kpi_values` +2. 更新 `data_source_config` 表结构:增加 `sync_interval`(分钟) 和 `last_sync_at` 字段 +3. 创建 systemd timer 或 cron 实现定时同步(最快每10分钟一次) +4. 配置 `data_source_config` 至少1条数据源记录用于测试验证 +5. 在 `/api/cma/data/sync/{source_id}` 增加手动触发同步的端点 + +**验收标准**: +- 执行同步脚本后,`kpi_values` 新增对应的ERP数据记录 +- 定时任务按配置间隔自动执行 +- 手动触发同步接口返回成功 +- 同步失败时有日志和状态标记 + +--- + +### Ticket 2: 阈值建议前端集成 +**负责人**: dev-fullstack → dev-frontend +**预计工时**: 1天 +**优先级**: P0 + +**现状**: +- 后端阈值建议接口 `GET /api/cma/thresholds/suggest/{kpi_id}` 已实现 +- 但前端 KPI 编辑/详情页没有任何调用 + +**要求**: +1. 修改 `KPIList.vue` 或 `KPIDetail.vue`,在KPI编辑表单中增加"自动建议阈值"按钮 +2. 点击后调 `api.get('/thresholds/suggest/' + kpiId)` +3. 返回的 threshold_green / threshold_yellow / threshold_red 自动填入表单 +4. 已有阈值时可一键覆盖 + +**验收标准**: +- 打开任意KPI编辑页能看到"自动建议阈值"按钮 +- 点击后自动填入阈值建议值 +- 保存后数据库里 `kpi_definitions.threshold_*` 字段更新 + +--- + +## P1 — 重要 + +### Ticket 3: Redis缓存接入 +**负责人**: dev-backend +**预计工时**: 2天 +**优先级**: P1 + +**现状**: +- Redis容器已运行(db1分配CMA使用),内存仅1.48M使用 +- 后端代码无任何Redis引用 +- AI分析每次调用DeepSeek API,耗时1-3秒 + +**要求**: +1. 在 `app/utils/cache.py` 中封装Redis工具类 +2. 对以下场景启用缓存: + - AI分析结果: 相同角色+相同KPI数据状态下,缓存有效期10分钟 + - KPI计算中间结果: 缓存5分钟 + - 驾驶舱 dashboard summary: 缓存30秒 +3. 缓存key命名规范: `cma:cache:{module}:{key}` +4. 增加 `POST /api/cma/admin/cache/clear` 清空缓存接口 + +**验收标准**: +- 首次调用AI分析后,二次调用在10分钟内走缓存、不调DeepSeek +- 驾驶舱数据30秒内不走数据库 +- 清空缓存后立即重新计算 + +--- + +### Ticket 4: 数据源配置UI +**负责人**: dev-frontend +**预计工时**: 2天 +**优先级**: P1 + +**现状**: +- `DataManage.vue` 存在但仅有Excel导入功能 +- 后端 `GET /api/cma/data/sources` 和 `POST /api/cma/data/import-excel` 已就绪 +- 缺少 `data_source_config` 的增删改查UI + +**要求**: +1. 在 `DataManage.vue` 中增加"数据源管理"Tab页 +2. 数据源列表: 显示名称、类型、API地址、上次同步时间、状态 +3. 新增/编辑数据源: 名称、类型(erp/business/excel)、API endpoint、同步频率 +4. 删除数据源: 二次确认 +5. 数据源列表调 `api.get('/data/sources')` +6. 增删改调 `api.post/put/delete('/data/sources')`(后端对应接口需要补) + +**验收标准**: +- 在数据管理页能看到数据源Tab +- 可新增、编辑、删除数据源 +- 操作后数据库 `data_source_config` 表对应更新 + +--- + +### Ticket 5: AI分析流式输出 +**负责人**: dev-backend + dev-frontend +**预计工时**: 1-2天 +**优先级**: P1 + +**现状**: +- AI分析请求需要等DeepSeek完整响应才返回,耗时3-10秒 +- 前端显示Loading状态,用户等待时间长 + +**要求**: +1. 后端: AI分析接口改为SSE(Server-Sent Events)流式输出,边生成边推送 + - 保留原有非流式接口作为fallback + - 新接口路径: `GET /api/cma/ai/dashboard-analysis-stream` +2. 前端: Dashboard.vue 用 EventSource 或 fetch + ReadableStream 消费SSE + - 实时展示已生成的分析内容 + - 支持手动中断 + +**验收标准**: +- 流式模式下用户能看到分析内容逐段出现 +- 非流式模式仍然可用(向后兼容) + +--- + +## P2 — 功能增强 + +### Ticket 6: 多用户角色权限 +**负责人**: dev-backend + dev-frontend +**预计工时**: 3天 +**优先级**: P2 + +**现状**: +- 数据库 `users.role` 已设计4种角色: ceo / finance / business / it +- 后端API无任何权限校验(只有一个认证token检查) +- 前端菜单和页面也无角色区分 + +**要求**: +1. 后端: 增加角色中间件 `app/utils/authz.py`,在router依赖中注入权限校验 + - ceo: 所有权限 + - finance: KPI管理、数据管理、预警 + - business: KPI查看、驾驶舱、预警 + - it: 用户管理、数据源配置 +2. 前端: `api/index.ts` 响应拦截器增加403处理 +3. 侧边栏菜单根据角色动态显示/隐藏 +4. 页面级路由守卫检查角色 + +**验收标准**: +- 不同角色登录后看到不同的菜单和页面 +- 越权访问API返回403 +- 前端捕获403后提示无权限 +- seeder脚本已有4个角色各一个用户 + +--- + +### Ticket 7: 预警通知推送 +**负责人**: dev-fullstack +**预计工时**: 2天 +**优先级**: P2 + +**现状**: +- `kpi_alerts` 表有6条预警记录,但只有系统内列表展示 +- 无任何外部通知(企微/邮件) + +**要求**: +1. 在 `app/utils/notifier.py` 中实现通知推送: + - 企微机器人Webhook推送(优先) + - 邮件推送(备选) +2. 在预警生成时(`alert_rules.py` 的 check 流程中)自动触发通知 +3. 通知内容: KPI名称、触发维度、当前值 vs 阈值、严重级别 +4. 在 `operation_logs` 中记录通知发送历史 + +**验收标准**: +- 新增一条预警时,企微群收到对应的预警消息 +- 通知内容包含KPI名称、当前值、阈值、严重级别 +- 通知发送记录写入 `operation_logs` + +--- + +### Ticket 8: 移动端适配 +**负责人**: dev-frontend +**预计工时**: 3天 +**优先级**: P2 + +**现状**: +- 全部页面基于Element Plus桌面端组件 +- 手机浏览器访问时布局错乱 + +**要求**: +1. Dashboard.vue: ECharts图表在手机端自适应宽度,表格改为卡片排列 +2. KPIList.vue 和 AlertList.vue: 表格增加响应式,窄屏时转为列表布局 +3. MainLayout.vue: 侧边栏在窄屏自动折叠为底部导航或汉堡菜单 +4. Login.vue: 手机端居中显示,输入框适配小屏 + +**验收标准**: +- iPhone SE / Android 主流分辨率下所有页面可正常浏览和操作 +- 图表可缩放查看 +- 表单在小屏下不溢出 + +--- + +## 项目依赖图 + +``` +Ticket 1 (ERP同步) ────→ Ticket 4 (数据源UI) — 需要后端先补data_source CRUD + │ + ├──→ Ticket 3 (Redis缓存) — 独立,可并行 + ├──→ Ticket 7 (预警推送) — 依赖Ticket 1完成后预警才会自动生成 + │ +Ticket 2 (阈值建议) ──→ 独立,仅前端修改 + +Ticket 5 (流式输出) ──→ 后端改SSE,前端改消费 + +Ticket 6 (权限) ──────→ 独立,改动面较大 + +Ticket 8 (移动端) ────→ 独立,仅前端CSS/布局调整 +``` + +## 授权 + +以上工单由任总(任富海)签发,授权数字员工团队按优先级执行。 +数字员工分工: +- dev-backend: 后端API、数据库、缓存 +- dev-frontend: 前端Vue页面、交互 +- dev-fullstack: ERP对接、计算引擎、通知 +- dev-qa: 验收测试 +- dev-ops: 部署、定时任务、监控 diff --git a/architecture/strategic-map-enhancement.md b/architecture/strategic-map-enhancement.md new file mode 100644 index 00000000..f47b4984 --- /dev/null +++ b/architecture/strategic-map-enhancement.md @@ -0,0 +1,158 @@ +# 战略地图配置器增强 — 技术方案 v1.0 + +## 1. 概述 + +在现有 StrategicMap 模型的基础上,补全 Epic 1 战略地图配置器的 6 个 Feature 缺口。 +本次开发覆盖 P0(因果连线 + 组织层级)+ P1(目标优化 + 版本管理)+ P2(模板加载 + 拖拽关联)。 + +## 2. 现有系统基础(不改动) + +- `strategic_maps` 表:id, title, version, status, dimensions(JSON), canvas_data(JSON), created_at, updated_at +- `kpi_definitions` 表:id, map_id(FK), kpi_code, kpi_name, dimension, objective, target_value ... +- `MapList.vue` / `MapCanvas.vue` + +## 3. 各 Feature 详细方案 + +--- + +### Feature 1.3 因果连线 (P0 RED) + +#### 后端变更 + +**现状**:canvas_data.connections 字段存 JSON,但无独立 API + +**方案**:不新增表,沿用 canvas_data.connections,但新增独立 CRUD API 来操作连线 + +``` +POST /api/cma/maps/{map_id}/connections body: {from_objective_id, to_objective_id} +DELETE /api/cma/maps/{map_id}/connections/{idx} +``` + +**连线数据模型**: +```json +{ + "connections": [ + { "from": "learning-0", "to": "process-0", "style": "solid" }, + { "from": "process-0", "to": "customer-1", "style": "solid" } + ] +} +``` + +#### 前端变更 + +1. **集成 @vue-flow/core**(推荐) + - 每个目标卡片作为 Flow 节点 + - 拖拽手柄生成连线 + - 支持 hover 高亮、右键删除 + +2. **备选:自建 SVG 交互层** + - 点击目标A 进入连线模式 - 点击目标B 生成连线 + - hover 连线上出现删除按钮 + +--- + +### Feature 1.5 组织层级配置 (P0 RED) + +#### 后端 — 新表 + +```sql +CREATE TABLE org_nodes ( + id INT AUTO_INCREMENT PRIMARY KEY, + parent_id INT DEFAULT NULL, + name VARCHAR(100) NOT NULL, + code VARCHAR(50) UNIQUE, + level TINYINT NOT NULL, -- 1=集团 2=事业部 3=区域 4=部门 5=班组 + sort_order INT DEFAULT 0, + enabled TINYINT(1) DEFAULT 1, + path VARCHAR(500), + remark VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES org_nodes(id) +); +``` + +#### API + +``` +GET /api/cma/org/tree # 全量树结构 +GET /api/cma/org/nodes # 平铺列表 +POST /api/cma/org/nodes # 新增节点 +PUT /api/cma/org/nodes/{id} # 修改节点 +DELETE /api/cma/org/nodes/{id} # 删除节点 +PUT /api/cma/org/nodes/{id}/toggle # 切换启用/禁用 +``` + +#### 前端 + +- Element Plus `` 组件展示树 +- 右键菜单:添加子节点 / 编辑 / 启用禁用 / 删除 +- 拖拽排序 + +--- + +### Feature 1.2 战略目标管理增强 (P1 YELLOW) + +新建 map_objectives 表: + +```sql +CREATE TABLE map_objectives ( + id INT AUTO_INCREMENT PRIMARY KEY, + map_id INT NOT NULL, + dimension_key VARCHAR(50) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + icon VARCHAR(50) DEFAULT 'target', + sort_order INT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (map_id) REFERENCES strategic_maps(id) ON DELETE CASCADE +); +``` + +API:GET/POST/PUT/DELETE /api/cma/maps/{map_id}/objectives +前端:弹窗增加描述字段 + 图标选择器 + 拖拽排序 + +--- + +### Feature 1.6 版本管理 (P1 YELLOW) + +```sql +CREATE TABLE strategic_map_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + map_id INT NOT NULL, + version VARCHAR(20) NOT NULL, + dimensions JSON NOT NULL, + canvas_data JSON NOT NULL, + comment VARCHAR(500), + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (map_id) REFERENCES strategic_maps(id) ON DELETE CASCADE +); +``` + +API:版本列表 + 创建快照 + 回滚 +前端:MapList 页增加版本历史按钮 + VersionListDialog + +--- + +## 4. 开发顺序与依赖关系 + +``` +P0a: 因果连线 —— 无依赖 +P0b: 组织层级 —— 完全独立 + => 两个P0可并行 +P1a: 目标管理增强 —— 依赖新 objectives 表 +P1b: 版本管理 —— 依赖 StrategicMap 已有结构 +P2: KPI拖拽关联 —— 依赖 objectives 模型 +``` + +推荐执行:P0a+P0b(并行) → P1a+P1b(并行) → P2 + +## 5. 风险 + +| 风险 | 应对 | +|------|------| +| @vue-flow/core 样式冲突 | 隔离CSS / 使用自建SVG | +| 组织树 >500节点性能 | 后端懒加载 + el-tree lazy | +| 版本快照膨胀 | 保留最近50版本 | +| 回滚后画布状态不一致 | 回滚后强制刷新页面 | diff --git a/architecture/task-assignment.md b/architecture/task-assignment.md new file mode 100644 index 00000000..5f58d048 --- /dev/null +++ b/architecture/task-assignment.md @@ -0,0 +1,76 @@ +# Epic 1 战略地图配置器 — 任务分配清单 + +> 状态: 🚀 已批准执行 | 日期: 2026-05-27 +> 技术方案: /root/cma-management/architecture/strategic-map-enhancement.md +> 执行模式: 全部并行 + +--- + +## P0a: 因果连线 (Feature 1.3) ← dev-backend + dev-frontend + +| 角色 | 任务ID | 内容 | 工作量 | +|------|--------|------|--------| +| dev-backend | A-1 | POST/DELETE /maps/{id}/connections API | 0.5天 | +| dev-frontend | A-2 | @vue-flow/core 集成 + 拖拽连线交互 | 1.5天 | +| dev-qa | A-3 | 连线创建/删除/持久/隔离/校验测试 | 0.5天 | + +## P0b: 组织层级 (Feature 1.5) ← dev-backend + dev-frontend + +| 角色 | 任务ID | 内容 | 工作量 | +|------|--------|------|--------| +| dev-backend | B-1 | OrgNode模型 + 示例数据 | 0.5天 | +| dev-backend | B-2 | 组织 CRUD API 6个端点 | 1天 | +| dev-frontend | B-3 | OrgManage.vue el-tree + 路由 | 1.5天 | +| dev-qa | B-4 | 增删改/权限/树结构测试 | 0.5天 | + +## P1a: 目标管理增强 (Feature 1.2+1.4) ← dev-backend + dev-frontend + +| 角色 | 任务ID | 内容 | 工作量 | +|------|--------|------|--------| +| dev-backend | C-1 | MapObjective模型 | 0.5天 | +| dev-backend | C-2 | objectives CRUD + 排序API | 0.5天 | +| dev-backend | C-3 | KPI反向关联端点 | 0.5天 | +| dev-frontend | C-4 | 弹窗增强(描述/图标/限制3) | 0.5天 | +| dev-frontend | C-5 | KPI详情页反向关联 | 0.5天 | +| dev-qa | C-6 | CRUD/数量限制/级联测试 | 0.5天 | + +## P1b: 版本管理 (Feature 1.6) ← dev-backend + dev-frontend + +| 角色 | 任务ID | 内容 | 工作量 | +|------|--------|------|--------| +| dev-backend | D-1 | StrategicMapVersion模型 + 快照/回滚API + 发布自动触发 | 1天 | +| dev-frontend | D-2 | VersionDialog + 发布按钮 | 1天 | +| dev-qa | D-3 | 快照/回滚/自动触发测试 | 0.5天 | + +## P2: 模板加载 (Feature 1.1) ← dev-backend + dev-frontend + +| 角色 | 任务ID | 内容 | 工作量 | +|------|--------|------|--------| +| dev-backend | E-1 | create-with-template API | 0.5天 | +| dev-frontend | E-2 | 新建对话框radio选择模板 | 0.5天 | +| dev-qa | E-3 | 模板加载完整性测试 | 0.25天 | + +--- + +## 📊 总计预估 + +| 角色 | 总任务量 | +|------|----------| +| dev-backend | 约 4.5 天 | +| dev-frontend | 约 5.5 天 | +| dev-qa | 约 2.75 天 | + +## 🎯 依赖关系(启动顺序) + +- C-1(MapObjective模型) → C-2(CRUD API) → C-4/C-5(前端) +- B-1(OrgNode模型) → B-2(CRUD API) → B-3(前端) +- A-1(连线API) ↔ A-2(前端连线) 可独立并行 +- D-1(版本API+自动触发) → D-2(前端) + +## 📋 评审节点 + +| 节点 | 时机 | 评审方式 | +|------|------|----------| +| 代码评审 | 每个任务PR提交 | dev-architect + dev-qa | +| 集成测试 | 所有任务完成 | dev-qa 出报告 | +| 上线评审 | 部署前 | dev-ops + dev-architect → 任总确认 | diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..f3e266fc --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,11 @@ +# 管理会计OS — 数据库配置 +CMA_DB_USER=cma_user +CMA_DB_PASS=cma_pass_2026 +CMA_DB_HOST=127.0.0.1 +CMA_DB_PORT=3306 +CMA_DB_NAME=cma + +# Redis 配置(可选) +CMA_REDIS_HOST=127.0.0.1 +CMA_REDIS_PORT=6379 +CMA_REDIS_DB=1 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34c847aa1c85aaa5ce0cf6504158c4cfeb993b97 GIT binary patch literal 141 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 literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b76598e48f2d3136735c7bfa23c94a3aeee1fbf0 GIT binary patch literal 5453 zcmbVQYfuzd7QQ{xJ<~HV4_?C~s6a3}52YE;k&ChNrPq?y|yI1lXZ5wRpY z$tEj4h?o~vVm2h3EwhDd*xjVGu(BbW{b5zMYKLOBzz(UkY8jA!s8w63WPj|r-P1EX zE6HBy)3?8S?z!jOKKGo{^OeP7M$p)6nQi~EBlK_bpfzKxbKgrL)Q2!~AxvSNm-6Xc zI*Ro5UcHZY(LTn-D9<#E8C(XX&v=b4Bk(X@*2U@&&$gv%f)jUa7y#L%xy-<0Y;&kj zb161rc0i{huHO7T(I+csowH z#GEjIO(~e+QsGJi8O%V;C|5d8eHpnja2iktP6wKaGk|7c2hi+J#+ezk0rS+GgHt_M zqeHLHywWrM+0_{`-$}_9kHB*h@8W8 znG<=KVr=z`?Yu{7ko6uPCnwd`SG%{?xc4{K?UPwR+(8j$YVh9b#zt5L;C4ldr2dBwN%QWvf9FR@zfq>U7ww~bSWFL3j9XQ4dhrEFf*`f@z3%t9%)!*XO z%f{H76Ugdv^4996>YD1tI`{s4yMK@FlQqA4xI_p9q!M@q>wTP`YvFyoUn*(lJV$sx zF5%kSOE4#KgZ5;p+3gT4)+aZ_%@lrw6|9W|{C)y(SjOxn2Beullx zn$NSR*&g$^-8!}NUojmfy=31^y-u%1eJPu%k5a4jL;B6sJWn9+mza0I5JDUTe+nZn z>OxX1;33pTwr)Xd(^AG9Trz17+SY)l#5R~hl9GN1N1H|COWd(O!D&Jg35rTvEK}p_ zqA-1uYD>{L6L)R(V@Nn8rD?c^cIkBJq%QuXhu5rc%h0$IcX**oq^P#61c%nsG-`W5 zLlXD+I`l~zQ%BZC^^(qlVHh2RqEs)OWR{x=ZKq8uKBj*MJrk8h<{2<0o+0h^`bjpU9 zVB;R4r2*D}(-#Q(CD{sbhR1HADOpHx5=o}I;~u_Uf@~`rJ2=5lLb^fLgMP9pmSh8h zY>Wjn*%%8|kas~M#fwCpJf*288ZvXMa->OiDu+u90|}w8>5K`QJyri={TQ7)Lg#+C z?zazz>D&liGC`Y9?Rs%n*uE}87tI=yna3y6AGoyho!z79&Ti9qlH;OtET?=Vr+hT0 zVkD_z!m(!5;q2ai%dz71*6!`II>wSW?pV?1yWs1uy}a}NonwWSBZZZtj*VlE%_EM@ zqmC`T`tdZ!#r)SEok%a}-xx_Rx^2(wvs|#eX`4+!$(7&E+EGfD3Vc72lztCUpaW|? zZ#``dXIBm$9rT1VtH#((BkZQ{XZ0}nomdJ+eW9 zWxk&D3r$;X`Y+dKZ8huvW@e!8iHBjBO`?MmkA4kQiAOpw@=;w7h~eZyDT-;#U<2IY z?}~9C7KyraaAM=R3S`6@ob`AN0*)o(R^p7s0!lqR8%DqI`7>77@f>G1*a0}bLA^KnQMJTEIG%HGjZ0! zIW2S-y^OZ>Pu#(5oP!_0xl3r9jaT56FtS+NY`hBR!N|NFR4mNL1^B@wrRC#7yn0D# z58*XBZNTOD zk%iJ)m@dOIcEmrjl%gdm;c;3o;pNjocpBsWc+e=YjT?<%5y zd}(^@-8<(apUzj=*l65sZ zoJLvKilLb11gLo3tymz%icEWyxa{SR@m|?1LZJ^~8AFUW@_rK4vHHDqCNy>Fr^nd#PtwSPuEzg zaBAzGQ^7KU6c|AgAEx`?h@QWo!27n&ka|mjqgQ`3_10$u)<0RlSAp()-aCEc69w9{ zw{eDqCsm8nJ<|gpt8!%BwmKE;eRU34Q#ZpBcH$Phs-sswnCiZ!EV-+ChFGUAsmi(W z@(JU%y7f(U)%7Z@N+NL09C+%}vv)pz6I|hh@u_NsdxnH>b#X=c$?G8)mTAym-2~O9 z$)R%^Tox>ZDXKfgTx*U~%`GOJtWXTB0&y_N#2Cwr`fXu^z8lH{*`R)R2xWwgBw27X zGj7(tBh;Bxi_Bn1?V@ZH_!da;(1B-80ECfv&L@f_OQ^|Pt;dDOh(JP!Pl+|)Y9SP* z3+@__$=+MqPlt=P4CC;=#_;o8_=q>c`a;#?wDnZOiw(V{5jq3wt3b_TMVJP zSur{U-uqJ@wfZ$US4wEW%32d`;W;+?dKoTQGW0t9rEjrGNk;RLlE*KjzCIuA(xPc2q_^XpO7_# z6cIwoSM@l||6u=)eH_Z`pqGC_I1Vf%{)_(r0(Bup-9j1Ppu&Hl$G$-;zec97k@dDI zr#o}Zlrv(=xmYo3$`2WCo71`r&O1*#d+YnFM$N?`-7Vr7k?|f}GBq0f~kSxg}$?_G&@MZ8oQH)B0!k}(Vb|%S&ncdhk3ql1i zC@Oj&q0|a`S_kLh1wr6EODT`4lfUnuO=T(KoRoUUhVa(~(bB_z?sd=3ZV*swZ>sk7 z>+bhncfWr9>(~2_!a_HKkNvo!H&l$!ze&aV92w?XC2&JXLIRQ)iB%YZfs#{Ll@mCX z7kGxW^NLNi3wFxe6o={*oRqgKF4Zl#Deq7`YJpHdd8bmS770aau~4j*2qmgl@T#Rk zsp=DaYMD@`P7|i7I`9qI#Zab&Jt#+e!;KK z7G_hsE@h5dE7Yk0VJ@w^m3p;72r`I8vio>uNwmiFc34Zzl`_-&SgvMql1C~yQFxp) z>ly7$R&TD9ijFg8O=yJiIH_1Fk-R5LElr_GZt4x^jEh(@cuCYn!!9Z^)-}T;M#ExERCZ_}>!FQdoKR%Vuq8AZYld6z z!Lrs9S0v3SjK%e6S2QB(QJ90_>65V<)ihcu5F?~D+^2}pcTAOIy1~V9x8aU= z_R1iz4GcTCfnBO&?@UPDvTpE^xTYJ9J}gVohz<^MMHE@YVNp>GyCf^JPS)$hiC9?M z5sU0x7-$eRu%^mE&A%nkpvc=~B_@*Ulm0-1$+ZBd%9Doi<|iiy&jcMrDcx@}?8f~EyKZ6PTh(TEK_sw=YL z$SlfmlJ;-{D+V*KBgh+tu!#8fa2!Xwqu>~?S&hI_!8uV;0k4`(s;u|KiIdH0k1R^W z{|;=1gu%(rL?SE+naoELf#>0O?Fk?cA_h`1J=29yZzd(lHUJ|r{mk#tJFLW>;Erc3 zF-XrO6wuF0Hpw1kPB^UIcJu+aoe4VI5?;_BfBXIM{clXX`bqlibLlf@TKpYkS72J< zXsj!KTuxhDXyyJ)wmoaS0q3 ziN_#JGObUX%3BJKZs`KE=0u;i))$@9Bo*^7iDyj+AF(lHd4WjR&pg=FNvg2TQ?v zqAvms*^juM{I0DA0-KV_{7@Dh!RgtJ?9IAAs}CDxp$da`fDhUde(1arO45U`UOs** zJ$Nep%5eJRzVW?-K@K}X4BrXF;K`O8L)U;ucqt(*7Fh<2RtAJD+Oucf7qIdNcR!dc zS#+LXj8{U(&I)j2lz2EKN@_F~l5t-nIeCT)Foo`$7Rjy(6kPD5EB+XmA=Hnsw;%OR zm4J5gbg><4vKZ)yy*TM;#l@mjjZig1f+iW|kMLO|0{pt`7>|%B66@nzE zoj{5x>y#Gl_w zA2_&X!z1HwoF3nQGCjC&{DY&{nfYyNJC1RH6L<%JWDKtY1;YV283uPH;F*L##T3qL z31E8$gD`ndVG?e55s*X?kc{Y`{xb2=(H1{m3{A&uhE3~JqPkH);0_#5A z3xm^ie2g<^;3S)(L;*q>StIWB`~7s!jUu{zSu3~=I$PnVbpaVbSM8`_;ocnwe*5xo z4{535+SAg&{gW(jdxH7GS$1gSaL;G_dFPt3>iMU8Mv7M@ovVKM!c+DQVu(O-^)Pq3 z=d-5NqaDeOkBvV1c=C}=$$K^@n}t;Imh;XnKTdK)?z&b5Lk*SAL+`s?30I|A{m11V1ItDD3@0lsQP8{Y;d(e^r!u5;I}y z6Q|yo`1|0*k$wL;yYJGO{plCqgp42)YoES`$riPBU;-a_4GUcc4Y4d1@kZj+QyyuBz^OLFKr_MW{q7f3dBE%a+$6T!y+=o@IrQEp! z2H10@HlRDJe-a^P<`RnZ$dRoPm}mCOqEHne5Bx7d{*yo|vkR_9>wk$|40)ro5II)P zX_dQJ7inj5=&_5F_na~U$y9$bh1e2NmRl_rXt;yKkk|M;Jxv82gd(!6qBn7 zE_N?uW?>ByD3aAF@rbCHjWHQRngmIs6z)rCJv2|6s&645ja z)KF~;I*kIjyqidR+9^Wrxd8-?3N3=8eY)0Url#F89UiiScG7E$C~=Tn2RwyE`~Yx4 z8&x$WSg8|tknV?oXymR1aJ1mD)I0_>k|5b62{GObB99ZQ1THA7mgbTcI4YTooE{^#ADP!` z`;@EOIz;9{i!$vf$S26W$IrZQ`PiqI&%T!a;1FQl_`a9YC*Lw>kly=d`oQ}jFmdYW z`0$bRJ3}Vc5i~Ou(w}^2>bLl5KAC=D7}Cg}^ucSPzAg4CTDVlAZ1tdo*N)3>)8S@Z9lpx0}XsC$u~bKaIf40Q#!D>hSgvPbkTUXyz7V+JdVLM;M-kk#N{>kc>JS>oy!+ z@WO;71(OM39*9wqX=+llkIbe>9BY^y62oit!uh2SPR-0a?B;tDlk5v`B!uLghJ&13 zVxQoond*25N0yDrR53aBm|!3-A%yHQCQmHvBZQ=*n7kQqIUyB<5Rk&t2_Y#2CfOM# zPimu>43^NNiq>Rq;0)6BBq8K-#pFF?lvu!FrBHYl>ARowc>rIck$dB@u4uQxuWMWT zu;BuLK1mp7*2psc%Q~} znt>a9AG~!bPSI@4;9y`(?p=yJG#2S5n^BMrfa}iX=CN6cw}UVVXzdQzOvthr=5sXX zpQz|dRCf_Ax`^goL^CfU|Mx6nHgMmA?5*fE+WsY4e-Sl*jXZk_0dknK0mlX88$rG? zxA(yOm*$S%^t9SZ7)ogHJq;h?7ozJ{Q&nB z;x3@l5mY+HmyY?$2Lof}Rc1*llODwQzD0I0Re~7alpI_#wCv!r6q+?=>HeygyD#`^ zM|`y@RQGdwcMh#MxFUsS=VX>$@C8PEffSni^KH#T%MUJ3A%9M)<$|wn#8;O>fn3{) z3%>dhUwsNS8>4R>aMa5<_YFdDQSE8Skn4F!Om!h9oPyoc4`tk9Zd6^~g@p=W7zc_4i^HWN5QtgUZfd()F RaWRPTk(rT^v4|PS0sz$AA;|y$ literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/action_plans.cpython-312.pyc b/backend/app/api/__pycache__/action_plans.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae96038bf11fdc445023e15c4f905c80666e703b GIT binary patch literal 7037 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 sr>~>?Yp-6yga<; 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/alert_rules.cpython-312.pyc b/backend/app/api/__pycache__/alert_rules.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f035924480f328d71c0758000712c50eb9e17e3b GIT binary patch literal 4204 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/alerts.cpython-312.pyc b/backend/app/api/__pycache__/alerts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac37da5f120b35e2d988398bbf9ce37a5ce7d364 GIT binary patch literal 2902 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 W`xof-G|Y9g=qZSQ0<#s)>;DC*U{jO; literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/auth.cpython-312.pyc b/backend/app/api/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4452d508e3ab2ac490c29cda57e5c8129505c77d GIT binary patch literal 4096 zcmbtXeQXrR6`%dQJKvtq=a2IjHa1|)HGmT`=?TS3KEkZ=U8owO1}O+^3iKU}xc*&9$u%YpM(&bU(5{M9#m-aE%S zx0O25&U-ua=FRNP`~BwapB;`O1SRo@ir_^nLVqVOX5rVARh2?$5{bx*L`tMXl$Qd@ zgy=BiWx}kN4RcrqU&3Jip@7zPpM?@rNy#!KHQu5C^tum6_-$M<&_s` zk2@y7WKXWlJs!Qi{JZPR^CMr8$-6v^Zaa3W^VMifm9TC(Aq_|oQPCY8-QB0!2LsZ8 zDn}z8O1GSr6eX+jeUj=Ed!Uj1fg)k>AOx`FS0$esJu5|Y0ZZp%GM0S)nA)#%ufFnf z`{|307XN_U5(xWSNNbD)M0Ob-TIUC_)GH6_tm^MmF7hp*XrCO>S<$chbz1BRteqrT zFS5bIz_Z#9t&=E@f@`NpI?Y02%RK)fdJ`LFoedMGg2d~FqOxX#xvxQs*^-xet02Vv z#uBH`?m{Rx-oCLP`qr9;*f@)i#@Qe_sSQPSnhn7cQ_IUZ6D-TWnVRW0H+O~Elb5E= z+3z8Q1+~)b5m}KlckmaKT5Yz&>S|0qFU=W-xHwnX0$L004IA8&_hM>bTW{)lsWzH= z&{WpR!eeA)enY`1;R}y4$B34zoM^pDpGF>Amu`(gtVjG|Nk_VMz^^Fhqga$DAo^le zu#haW(usE;kG_|lfAh)A$X8dUm*?komW=UmIxUOX1%|jBDxJmAkfhs4?K2E$oy($! zCG&V(Is+)vxpOfIU%+JRbiP*(0Y(icI@c>>Mb#bVLVW#xrC+y@Iwbe#ywdM~w(Wq< zOOZfSgo*m4K~e6L6cux14FpA)goCjf>?RtCOb?HXiB6&|yIK-gm>fSQU#Sq>HW=lb z-7Pp8RSBf`g#8hJpA?oNYD~M^pBOq`l#K`Hi~Sx`kcI#^*Hq;gtPY(tb>{ zKRR{tIa#ktJ6DFLiD5b)2~hbA_AO@uJ1FxMjV$M)*VILdcP!`Mn6m z8FSAla$Y%F%v+d*3JHo$ERrc84||sZB{@O3q7+PXT#>&0ar(DY>8o?tfEi&11B<$~ zC#J{|xN$vf7KVfn^H|j_Vdw+M8JGIw-_NDreCP3KBK_7ij|0~dQ^FXSaBFrr;cQ$- zDg*voiPk_WLaKNhR664qMV${s!(my4S#va?bJ8HhuE7Bc*&c#^OcE;HYKAr54X?zN z@)=YBYoU0&W31y!_uE}5p)n~mUf*$J&+MLs*4qa^I(X+r?Yl1|x4obV9VwwJDRgPV zsp0k|i?D{wHm&%;J?nw{cITq2A`wiw8t=R7CL9KdSf&A$1?BkJnBRb*!Fm9FHMVGx=2$FPepg$UsJeGnMWUbhU z(iYG>6y5_uA$z_SYev!;mhe7k+E4m^3e_;mSdp`KJUSL#EN@7ady?gz#gZ+llE!37 zV@|nWQ8W4a#OqV>)Yg5;t@{?XX%%g$ibKhYLw9!mX?N=Q>E!X#T1EGwt1ji*o^)+r zaaag_TR1)&3_M(PAxR0qgt?MSLse*k9~z4M*kntgQ#V=R1R6)T8H)nU5H*AdlLTmL?d?4BaIqa=wtV+W`u*Q5-}u$? z*vIM7$>rI(uRk4q^2cd%I5+3ve7^p4EQ@6$?XV7DIf`ZV0wnD$$qcfb@+^4r&_)KJ zbFwOh70Ak|vpV&LLPFTYdr=68z-Md*hI|nng-2h5VsFzZAZ(H;TiUTz$F#na5u8>(Ju|*phTz zFCtB0_}Y+?O{L061IbK$VObPI(s@6YvN;jKS#}?Ioc$$Y2cnQ@v7#JMjqJtXx@@SD zSTr``Lu5pX!HVBlkHA#1kaQFuhkgc5nQ0eEW_zTpQL~Reywg+hI162D3 zI+jGozOZ@OqFTdp-~kpv?bf=HZqfg literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/budget.cpython-312.pyc b/backend/app/api/__pycache__/budget.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5710f6b3bb2bd8ce986849f0b43fd102948d5bb8 GIT binary patch literal 15076 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 E0FWsX@Bjb+ literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/kpis.cpython-312.pyc b/backend/app/api/__pycache__/kpis.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7bcc125500237d3c73ea0eedb97abcd62942844 GIT binary patch literal 11002 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/notifications.cpython-312.pyc b/backend/app/api/__pycache__/notifications.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c12cedb4fd8c527c8d34938e45a612eafa2c5db3 GIT binary patch literal 6528 zcmd5=Yitu&7M>Z;jNfr^-VSL(c%+8B2pb+^)m@$iT4*T|RnsEN@k|n%acpMBKvG9? zR@pY$K9Z=k36zS^eJCg`RA?*Ec2~RGUzPSxT(cW90=26)f&5uH(5}?}+H+<+wsE~w zRrkkUY3{vq?zv~~+;h%%&$)lI+bskXsk=Pzd=Wu>fiHS7r91c6Qv@+da6~7;ksKwE zL8_Aq8afR@x|1f6W)PSl+sUeZS}+DpohG%<2QF3d}HcM*nFxSAF^tW!3v-4~>#T5-R zv*`()PLR=%J6r2NM23SeRproJagLlakkeE^&I3ui$i9ELV~vs}f{w`oXgB z-)OEhM~>^i{9D;W*QCiQ&yiCNatiHpg)WDy%=HSOi*KUJihW<05h|G(MaW^04`#4Fl@9XGzde3Pe zAC~>0J~yctkMNSDb=e+X_HbQpN+~%MlKtI&pBI^S^?LjIctLUG5}yq9z+{`qpNRNH z-Xn$t9^OrySLS7ZkRK>-@rL~^zM!|IFGrLVv1L}RtrtGVhDE;He_EksZ;#|l8y)@v zVT8v3&;3uJnk1q`0NVTl)U-k8jsTU;eb@RxWeZYuH_RCXUe#@LNYE8m7N!U65Axxg z?j9U@o;?Dtt+j6IkegK~Kc~=r-XO2oeA@nc4 z_sK0?Uf*%B2vAd4Rn-G(Y7Y0qhkHG8$iw-4vRDa%P;=5zsA9zDMUF9UluH(`m}Do| zWM$QbqO(OwXUXKF6OSfKN+-8XY)iIny4HBLaojs8ObGMkjq&ovM0xYF-DqP|gppxW zHewICpUe{OgJfdB+>+i6Nuju|!p871FsjCoYbrP!ikvY8-KzqOHj-OsxeA#>W~MoJ zj@H*zI!JP4l-x~>Q9i1N@KMJ={ezTj)n`O0UCIRYs)GiOLCQ}aftFPJrwBI{!Ck%k z`n83zw{rb>VPx{|#TkVLSMq|PFef6s*e{}iDyERg@uH`zU$q0p;1vW_HEx6GQpfep z6A>IFbe+1oI)f%D6<0#nmr)43;D)OHya=61Le3~K3cgVVG-xTU4Ip1$;S7F4KfJ&T3vtjKqjS8+ z&ZZGykcyIp%pj{Ik^l}wo4lf{TOBW84!IHvDgOxJHb&4%7e?M*_~;M0YA)o(9hc}r zWH+m+-vIawO6NF4iD1w#D@H(0k(YXPE5IGsOo8(CKB)g94M%hf5Z%D~2s{!8RgCzG zB`h^b`-<^(qc0@Os-~LbW$vV-1WjVfG+m!?tW6e|PMRi6Q&rQu6UFYY%#4MN?F2Vw zj4$sU+B?!Vd>~1)FYh1PKl}uC4h$U_5r+@mvX+f^z4F)`T_vJo2UcVpyyZmz7(X|t zFly_|RVNyeYA69P5%6^i+x(LCV%^sYDLKnk$k(I9@karu)6xS4RIHo8pdo6IY*{5^ zl%|{7F8~W3b^z=qj;$ex7r+$;=|M(D5LZjo5T$h)68UOLl;#X4N$6F|2=5u5C^MVh z9g=u;9gX{epr2Rtm4dNSfwH_oc#RtL|0D?+eP1nEDi|xR8tdZQzh8XogIr@>Is-fO zHLhEWHNL$5+QR!&3+HFtly)TH@#D6MHPC3b%69t&2s|{PV!HjJB#Vf|3geggprjak zy^>d!Au^G889U-SoQpxEX3VU(9{U-K7C}#hLLOJhlPbmluw}5VC=$lNBV*g`z=~m{ zbPg&N+$^@S%F)Uj&b0~ix_NU`+}w1ddB=S7?s)U=PndY~!G!tHytyN8?ns!A#&+Jc z+HVzAPwkpp)s`sQG-uj`wr`oRykWaXP|PEv^awe!@s`y&-Zp;Xwa2Esa}C=+ePsJB zTgmv&DROG#-)%K12KWF}!8yLiBOU-q8rG}Y{lB0Cc2E8v*gX}ceyH8&!Xo@b#@^wl z(j{xFoPne9WOOrWpqySh`jbS8a|kXOIz9a zi*wf1bM$HrB+I`8k}WbX$q$$c1_Y|9h$=*?Nh1*?2O+?KEiV-GAgHQEhk-(HCug_{ zMZbA)`H+}54VM|!qndT71GqE?zPat7oAh%b3XH=#fV%CZbx zCSB#soQ1QVX9pPyU@^+Tmoeb!yKFx%2%%FEw5Bg&>FW zaJD5YS0yV~LyqHQV+WRt2}jkGD_QKEG)@?2oL6cu)h3;l(;drvR6<;N4HahBMcZI(FjHQWD$2(yDye>5 z=HNIeJ`W?u;E}$CN)0RkRrZbUJL5^%*3R2D#BCcAwx-ygq}4vQb#&{QrxMmR^VX)g zwdt0rV%}6AH`ULZ8sesggvov1K-t(ktZk@!gd0CR+B;Q0>PfIQ7x=UM^v<{aR}7a7 zvrLL17&9P?vm#Yalve(`%(9{?RZ2LDQzZmk*{qpZgZDU=vzBB<lVjRrB}>WSp;9AFo({qoVP;oZP(Sns8N^T6LlEY~#GE zHSTJ?;cEMYzF%T-TJ90>#bw5h7eID~{pKDG?<#(OT)lJc9)>;Su2GC04;S)zJc_Z~ z3yKc=MT~JojEl8cu1Go|cm-cCAM9_2n=TQ<6*#xxD8l=aF0aIkh)0SANx=|=bCQ~n zh^hsYAk`a@dp$uv#|iu?ugHslD1>OmNvx>emZ}MexCY6qkW7a|k_;CzcoXJEf*~A% zMBERygqeo86)TJs)#FB0?Il9RZB^~xntloki3t2-VlOZuFsav^DFaE8H;I*>5$$oJ z{Wej5o2dPa*nXR6yiL^n)9jpcwOwzX+qVCP`9O@l$C^kMG)I|Ub`7~ko=A|DH|rW^ z*k3!A2?M!hyyqS^_YDM9m7<^ri8-)V4^@vmpCGGlHvQy-qqCl6!b-ZPN>kWOZ@GsJ z&TyqD=%rSsh0ob)6J*`ZZ9o4@`A4L*{1`O&u{9|QdSH3l^5*Po6Xd#^8`?hDJo~d{qJ*rQGNiDXuDFK{ qPH9L{&`UL@*EjNbf-FtCsxNKP=dk0ua1N`@VR25`GR$FBCHx2PTv{st literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/objectives.cpython-312.pyc b/backend/app/api/__pycache__/objectives.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d87d1f531d3614afeaa8719f0231518449d4c049 GIT binary patch literal 5350 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/org.cpython-312.pyc b/backend/app/api/__pycache__/org.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8eb0b10bacb9d711b5078718812433a015ae85da GIT binary patch literal 6489 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 Nt@<;0h}#Em_CFSDySD%U literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/thresholds.cpython-312.pyc b/backend/app/api/__pycache__/thresholds.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..858dce97405079e13beacb3acfdecbeee0827334 GIT binary patch literal 5834 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py new file mode 100644 index 00000000..486f1eb5 --- /dev/null +++ b/backend/app/api/action_plans.py @@ -0,0 +1,145 @@ +"""改善行动计划 API — 管理会计OS""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime +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 logging + +logger = logging.getLogger("cma.action_plans") + +router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"], + dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], +) + + +def plan_to_dict(p: ActionPlan) -> dict: + return { + "id": p.id, + "alert_id": p.alert_id, + "kpi_id": p.kpi_id, + "title": p.title, + "description": p.description, + "assignee": p.assignee, + "priority": p.priority, + "due_date": p.due_date.isoformat() if p.due_date else None, + "status": p.status, + "progress": p.progress or 0, + "result": p.result, + "created_by": p.created_by, + "created_at": p.created_at.isoformat() if p.created_at else None, + "updated_at": p.updated_at.isoformat() if p.updated_at else None, + } + + +@router.get("") +def list_plans( + status: Optional[str] = None, + kpi_id: Optional[int] = None, + alert_id: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(require_auth), +): + """获取行动计划列表""" + query = db.query(ActionPlan).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) + + # 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: + item = plan_to_dict(p) + # 附带KPI名称 + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first() + item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI" + result.append(item) + + return {"data": result} + + +@router.post("") +def create_plan( + data: dict, + db: Session = Depends(get_db), + current_user: User = Depends(require_auth), +): + """创建改善行动计划""" + required = ["title", "kpi_id"] + for field in required: + if field not in data: + raise HTTPException(400, f"缺少必填字段: {field}") + + plan = ActionPlan( + alert_id=data.get("alert_id"), + kpi_id=data["kpi_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, + status="pending", + progress=0, + created_by=current_user.name or current_user.username, + ) + db.add(plan) + db.commit() + db.refresh(plan) + return plan_to_dict(plan) + + +@router.put("/{plan_id}") +def update_plan( + plan_id: int, + data: dict, + db: Session = Depends(get_db), +): + """更新行动计划""" + 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: + plan.description = data["description"] + if "assignee" in data: + plan.assignee = data["assignee"] + if "priority" in data: + plan.priority = data["priority"] + if "due_date" in data: + plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None + if "status" in data: + plan.status = data["status"] + if "progress" in data: + 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) + + +@router.delete("/{plan_id}") +def delete_plan(plan_id: int, db: Session = Depends(get_db)): + """删除行动计划""" + plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first() + if not plan: + raise HTTPException(404, "计划不存在") + db.delete(plan) + db.commit() + return {"message": "已删除"} diff --git a/backend/app/api/ai_analysis.py b/backend/app/api/ai_analysis.py new file mode 100644 index 00000000..daa958e8 --- /dev/null +++ b/backend/app/api/ai_analysis.py @@ -0,0 +1,325 @@ +"""AI分析引擎 — 侧边栏智能分析""" +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from sqlalchemy import func, text as sa_text +from app.database import get_db +from app.auth_middleware import require_auth, require_role +from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan +from app.utils.cache import get as cache_get, set as cache_set +import json, hashlib, httpx, os +from datetime import datetime +router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"], + dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], +) + +async def _call_deepseek(prompt: str) -> str: + """调用DeepSeek API""" + api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8") + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + "https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={ + "model": "deepseek-chat", + "messages": [ + {"role": "system", "content": "你是一名CMA管理会计师,擅长用数据驱动的方式分析企业经营状况,给出专业的财务分析和管理建议。回答要简洁、专业、有数据支撑。"}, + {"role": "user", "content": prompt} + ], + "stream": False, + "temperature": 0.3, + } + ) + data = resp.json() + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + +@router.get("/dashboard-analysis") +async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db)): + """AI分析驾驶舱数据""" + # 尝试缓存 + cache_key = f"dashboard_analysis:{role}" + cached = cache_get("ai", cache_key) + if cached: + return cached + # 获取当前KPI数据 + kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() + kpi_summary = [] + for k in kpis: + latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first() + kpi_summary.append({ + "name": k.kpi_name, + "code": k.kpi_code, + "dimension": k.dimension, + "target": k.target_value, + "actual": latest.actual_value if latest else None, + "period": latest.period if latest else None, + "unit": k.unit, + }) + + # 获取预警 + alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count() + + # 构建分析prompt + kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None]) + + prompt = f"""我是一家公司的管理层,以下是当前管理会计系统的KPI数据和系统状态,请给出专业的分析和管理建议: + +当前KPI数据: +{kpi_text} + +待处理预警数:{alerts} + +请从以下三个方面分析: +1. **核心发现**:当前数据反映的最关键问题是什么? +2. **深入解读**:从CMA管理会计角度,这些数据意味着什么? +3. **行动建议**:基于数据,财务和业务部门应该采取什么具体行动? + +注意:角色视角为{"CEO(总经理)" if role == "ceo" else "财务部" if role == "finance" else "业务部"}。""" + + try: + analysis = await _call_deepseek(prompt) + except Exception as e: + analysis = f"AI分析暂时不可用: {str(e)}" + + result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts} + # 缓存10分钟 + cache_set("ai", cache_key, result, ttl_seconds=600) + return result + + +@router.get("/kpi-analysis/{kpi_id}") +async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db)): + """AI分析单个KPI""" + # 尝试缓存 + cache_key = f"kpi_analysis:{kpi_id}" + cached = cache_get("ai", cache_key) + if cached: + return cached + + 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.asc()).all() + + trend_data = [] + for v in values: + trend_data.append({"period": v.period, "value": v.actual_value}) + + prompt = f"""请分析以下KPI指标: + +KPI名称:{kpi.kpi_name} +维度:{kpi.dimension} +计算公式:{kpi.formula} +目标值:{kpi.target_value} +单位:{kpi.unit} +负责部门:{kpi.responsible_dept} + +历史数据趋势: +{json.dumps(trend_data, ensure_ascii=False, indent=2)} + +请分析: +1. 当前表现如何,是否达到目标 +2. 趋势走势是否健康(上升/下降/波动) +3. 存在什么风险 +4. 建议采取什么管理行动""" + + try: + analysis = await _call_deepseek(prompt) + except Exception as e: + analysis = f"分析暂时不可用: {str(e)}" + + result = {"kpi_name": kpi.kpi_name, "analysis": analysis} + cache_set("ai", cache_key, result, ttl_seconds=600) + return result + + +async def _stream_analysis(prompt: str): + """流式调用DeepSeek并生成SSE事件""" + async with httpx.AsyncClient(timeout=60) as client: + async with client.stream( + "POST", + "https://api.deepseek.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY', 'sk-8e24e6eb87f2475e96ea0980002dc2e8')}", + "Content-Type": "application/json", + }, + json={ + "model": "deepseek-chat", + "messages": [ + {"role": "system", "content": "你是一名CMA管理会计师,擅长用数据驱动的方式分析企业经营状况,给出专业的财务分析和管理建议。"}, + {"role": "user", "content": prompt}, + ], + "stream": True, + "temperature": 0.3, + } + ) as response: + async for line in response.aiter_lines(): + if not line or line.startswith(":"): + continue + if line.startswith("data: "): + data_str = line[6:] + if data_str.strip() == "[DONE]": + break + try: + chunk = json.loads(data_str) + delta = chunk.get("choices", [{}])[0].get("delta", {}).get("content", "") + if delta: + yield f"data: {json.dumps({'text': delta})}\n\n" + except json.JSONDecodeError: + continue + yield "data: {\"text\": \"[DONE]\"}\n\n" + + +@router.get("/dashboard-analysis-stream") +async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db)): + """AI分析驾驶舱数据 — SSE流式输出""" + cache_key = f"dashboard_analysis:{role}" + cached = cache_get("ai", cache_key) + if cached: + # 缓存存在,直接以流的形式一次性返回 + full_text = cached.get("analysis", "") + async def cached_stream(): + yield f"data: {json.dumps({'text': full_text})}\n\n" + yield "data: {\"text\": \"[DONE]\"}\n\n" + return StreamingResponse(cached_stream(), media_type="text/event-stream") + + kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() + kpi_summary = [] + for k in kpis: + latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first() + kpi_summary.append({ + "name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension, + "target": k.target_value, "actual": latest.actual_value if latest else None, + "period": latest.period if latest else None, "unit": k.unit, + }) + alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count() + kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None]) + role_label = {"ceo": "CEO(总经理)", "finance": "财务部", "business": "业务部"}.get(role, "管理层") + prompt = f"""我是一家公司的管理层,以下是当前管理会计系统的KPI数据和系统状态,请给出专业的分析和管理建议: + +当前KPI数据: +{kpi_text} + +待处理预警数:{alerts} + +请从以下三个方面分析: +1. **核心发现**:当前数据反映的最关键问题是什么? +2. **深入解读**:从CMA管理会计角度,这些数据意味着什么? +3. **行动建议**:基于数据,财务和业务部门应该采取什么具体行动? + +注意:角色视角为{role_label}。""" + + return StreamingResponse(_stream_analysis(prompt), media_type="text/event-stream") + + +@router.post("/ask") +async def ask_question( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_auth), +): + """自然语言查询 — CEO问企业经营问题""" + body = await request.json() + question = body.get("question", "").strip() + + if not question: + raise HTTPException(400, "请输入问题") + + # 收集系统数据作为上下文 + kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() + kpi_context = [] + for k in kpis: + latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first() + alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == k.id, KPIAlert.status == "pending").first() + kpi_context.append( + f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else '无'}" + f"{' ⚠️' + alert.alert_level if alert else ''}" + ) + + # 获取改善计划 + plans = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(10).all() + plan_context = [f"- {p.title}({p.assignee}, {p.status}, {p.progress}%)" for p in plans] + + # 获取预警 + red_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "red").count() + yellow_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "yellow").count() + + system_context = f"""你是管理会计OS的AI助手,基于以下企业数据回答管理层问题。 + +时间:{datetime.now().strftime('%Y-%m-%d %H:%M')} +当前用户:{current_user.name} ({current_user.role}) + +## KPI数据 +{chr(10).join(kpi_context)} + +## 预警概况 +红色(紧急): {red_alerts}条 | 黄色(预警): {yellow_alerts}条 + +## 改善计划 +{chr(10).join(plan_context) if plan_context else '暂无'} + +请基于以上数据回答问题。如果问题需要具体数据但上下文中没有,可以根据KPI编码名称推断。回答要简洁、有数据支撑。""" + + prompt = f"{system_context}\n\n用户问题:{question}" + + try: + analysis = await _call_deepseek(prompt) + except Exception as e: + analysis = f"查询失败: {str(e)}" + + return {"question": question, "answer": analysis, "timestamp": datetime.now().isoformat()} + + +@router.post("/review-plans") +async def review_plans( + db: Session = Depends(get_db), + current_user: User = Depends(require_auth), +): + """AI复盘改善行动计划执行效果""" + plans = db.query(ActionPlan).order_by(ActionPlan.created_at.asc()).all() + + if not plans: + return {"analysis": "暂无改善行动计划,无法复盘"} + + plan_text = [] + for p in plans: + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first() + kpi_name = kpi.kpi_name if kpi else "未知" + latest = db.query(KPIValue).filter(KPIValue.kpi_id == p.kpi_id).order_by(KPIValue.period.desc()).first() + plan_text.append( + f"- {p.title}\n" + f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n" + f" 负责人: {p.assignee} | 状态: {p.status} | 进度: {p.progress}%\n" + f" 描述: {p.description}\n" + f" 截止日: {p.due_date.strftime('%Y-%m-%d') if p.due_date else '无'}" + ) + + completed = sum(1 for p in plans if p.status == "completed") + in_progress = sum(1 for p in plans if p.status == "in_progress") + pending = sum(1 for p in plans if p.status == "pending") + + prompt = f"""请复盘以下改善行动计划的执行情况: + +## 改善计划概览 +总数: {len(plans)} | 已完成: {completed} | 进行中: {in_progress} | 待开始: {pending} + +## 各计划详情 +{chr(10).join(plan_text)} + +请分析: +1. **执行概况**:整体执行到位吗?哪些计划需要重点关注? +2. **效果评估**:已完成的计划是否真正改善了关联KPI? +3. **风险提示**:哪些计划存在延期或执行不力的风险? +4. **改进建议**:接下来应该调整或优先推进哪些计划?""" + + try: + analysis = await _call_deepseek(prompt) + except Exception as e: + analysis = f"复盘失败: {str(e)}" + + return { + "analysis": analysis, + "stats": {"total": len(plans), "completed": completed, "in_progress": in_progress, "pending": pending}, + "timestamp": datetime.now().isoformat(), + } diff --git a/backend/app/api/alert_rules.py b/backend/app/api/alert_rules.py new file mode 100644 index 00000000..a7dccb67 --- /dev/null +++ b/backend/app/api/alert_rules.py @@ -0,0 +1,76 @@ +"""预警规则配置""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from app.database import get_db +from app.auth_middleware import require_auth, require_role +from app.models import KPIAlert, KPIDefinition, KPIValue + +router = APIRouter(prefix="/api/cma/alert-rules", tags=["预警规则"], + dependencies=[Depends(require_role("ceo", "finance"))], +) + +@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是否需要触发预警""" + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + + 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": "无数据"} + + val = latest.actual_value + level = "green" + + # 简单阈值判定 + 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) + db.commit() + return {"alert": True, "level": level, "message": alert.alert_message} + + return {"alert": False, "level": "green", "message": "正常"} diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py new file mode 100644 index 00000000..167310cf --- /dev/null +++ b/backend/app/api/alerts.py @@ -0,0 +1,30 @@ +"""预警 API""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from app.database import get_db +from app.auth_middleware import require_auth, require_role +from app.models import KPIAlert, OperationLog + +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) + if status: + query = query.filter(KPIAlert.status == status) + total = query.count() + alerts = query.order_by(KPIAlert.created_at.desc()).offset((page-1)*20).limit(20).all() + return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]} + +@router.post("/{alert_id}/resolve") +def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): + alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() + 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() + db.commit() + return {"message": "已处理", "assignee": alert.assignee} diff --git a/backend/app/api/alignment.py b/backend/app/api/alignment.py new file mode 100644 index 00000000..f95d46bc --- /dev/null +++ b/backend/app/api/alignment.py @@ -0,0 +1,309 @@ +"""KPI目标对齐管理 API — 管理会计OS +支持三种对齐模式:纵向分解 / 横向支撑 / BSC瀑布链 +管理员可初始化选择,后续按模式运作""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import Optional, List +from datetime import datetime +import json + +from app.database import get_db +from app.auth_middleware import require_auth, require_role +from app.models import KPIDefinition, OperationLog, RolePermission + +router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"], + # 不设全局权限,每个接口单独控制 +) + +# 三种对齐模式定义 +ALIGNMENT_MODES = [ + { + "key": "vertical_decomposition", + "name": "纵向分解", + "description": "上级KPI直接拆分为多个下级KPI,目标值汇总等于上级目标。适用于营收、成本等可量化指标。", + "example": "公司销售总额2000万 → 区域A 800万 + 区域B 700万 + 区域C 500万", + }, + { + "key": "horizontal_support", + "name": "横向支撑", + "description": "下级KPI是上级KPI的驱动因子,下级目标达成支撑上级结果。适用于复合型指标。", + "example": "销售毛利率30% ← 销售总额↑ + 成本控制↓ + 高毛利产品占比↑", + }, + { + "key": "bsc_chain", + "name": "BSC瀑布链", + "description": "按平衡计分卡因果链层层传导:学习成长→内部流程→客户→财务。", + "example": "培训完成率↑ → 订单交付及时率↑ → 客户满意度↑ → 销售总额↑", + }, +] + + +@router.get("/modes") +def list_modes(): + """返回三种对齐模式的定义(公开接口)""" + return {"modes": ALIGNMENT_MODES} + + +@router.get("/config") +def get_alignment_config(db: Session = Depends(get_db)): + """获取当前系统对齐模式配置(公开接口,无需认证)""" + perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() + if not perm: + return { + "mode": None, + "configured": False, + "modes": ALIGNMENT_MODES, + } + return { + "mode": perm.value, + "configured": True, + "modes": ALIGNMENT_MODES, + } + + +@router.post("/config") +def set_alignment_config( + data: dict, + db: Session = Depends(get_db), + user = Depends(require_role("ceo", "it")), +): + """初始化/修改系统对齐模式(CEO/IT权限)""" + mode_key = data.get("mode") + if mode_key not in [m["key"] for m in ALIGNMENT_MODES]: + raise HTTPException(400, f"无效的对齐模式: {mode_key}") + + perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() + if perm: + perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat()} + else: + perm = RolePermission(key="alignment_config", value={"mode": mode_key, "set_at": datetime.now().isoformat()}) + db.add(perm) + db.commit() + + return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key} + + +@router.get("/tree") +def get_alignment_tree( + kpi_id: Optional[int] = Query(None), + db: Session = Depends(get_db), + user = Depends(require_auth), +): + """获取KPI对齐关系树 + + 根据当前系统配置的对齐模式,返回KPI的父子层级关系。 + 如果指定kpi_id,返回该KPI及其下级树; + 如果不指定,返回整个对齐树。 + """ + # 获取当前模式 + config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() + mode = config_perm.value.get("mode") if config_perm else None + if not mode: + raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化") + + # 获取所有KPI + kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all() + kpi_map = {k.id: k for k in kpis} + + # 构建父子关系 + if mode == "vertical_decomposition": + # 纵向分解:BSC编码前缀相同=同一系列 + return _build_vertical_tree(kpis, kpi_id) + elif mode == "horizontal_support": + # 横向支撑:按BSC维度+类别的因果关系 + return _build_horizontal_tree(kpis, kpi_id) + elif mode == "bsc_chain": + # BSC瀑布链:按维度层级传导 + return _build_bsc_chain(kpis, kpi_id) + else: + raise HTTPException(400, f"未知的对齐模式: {mode}") + + +def _build_vertical_tree(kpis, kpi_id=None): + """纵向分解树:按编码前缀分组,同一前缀=同一系列""" + from collections import defaultdict + + # 提取前缀(如 F_REVENUE_001 → F_REVENUE) + groups = defaultdict(list) + for k in kpis: + parts = k.kpi_code.rsplit("_", 1) + prefix = parts[0] if len(parts) > 1 else k.kpi_code + groups[prefix].append(k) + + def make_node(kpi): + return { + "id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, + "dimension": kpi.dimension, "category": kpi.category, + "target_value": kpi.target_value, "unit": kpi.unit, + "children": [], + } + + trees = [] + # 每个前缀组中,按序号升序,第一个为父级 + for prefix, group in sorted(groups.items()): + sorted_group = sorted(group, key=lambda k: k.kpi_code) + if len(sorted_group) > 1: + parent = make_node(sorted_group[0]) + parent["children"] = [make_node(c) for c in sorted_group[1:]] + for c in parent["children"]: + c["alignment_type"] = "vertical_split" + c["parent_code"] = parent["kpi_code"] + parent["child_count"] = len(parent["children"]) + trees.append(parent) + else: + trees.append(make_node(sorted_group[0])) + + if kpi_id: + # 只返回指定KPI的子树 + return _filter_tree(trees, kpi_id) + + return {"mode": "vertical_decomposition", "mode_name": "纵向分解", "tree": trees, "total": len(kpis)} + + +def _build_horizontal_tree(kpis, kpi_id=None): + """横向支撑树:按BSC维度因果关联""" + # 因果顺序:learning → process → customer → finance + dim_order = {"learning": 0, "process": 1, "customer": 2, "finance": 3} + dim_name = {"finance": "财务", "customer": "客户", "process": "内部流程", "learning": "学习成长"} + + # 按维度分组 + groups = {"finance": [], "customer": [], "process": [], "learning": []} + for k in kpis: + if k.dimension in groups: + groups[k.dimension].append(k) + + def make_node(kpi): + return { + "id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, + "dimension": kpi.dimension, "category": kpi.category, + "target_value": kpi.target_value, "unit": kpi.unit, + "children": [], + } + + # 构建层级:一个维度节点包含该维度所有KPI + trees = [] + for dim, ks in sorted(groups.items(), key=lambda x: dim_order.get(x[0], 9)): + if not ks: + continue + dim_node = { + "id": None, + "dimension": dim, + "kpi_name": dim_name.get(dim, dim), + "is_dimension_group": True, + "children": [make_node(k) for k in sorted(ks, key=lambda x: x.kpi_code)], + "child_count": len(ks), + } + # 建立因果关联说明 + if dim == "learning": + dim_node["description"] = "驱动因素:人才培养与创新" + for c in dim_node["children"]: + c["drives"] = "internal_process" + elif dim == "process": + dim_node["description"] = "过程保障:效率与质量提升" + for c in dim_node["children"]: + c["drives"] = "customer" + elif dim == "customer": + dim_node["description"] = "市场反馈:客户规模与满意度" + for c in dim_node["children"]: + c["drives"] = "finance" + elif dim == "finance": + dim_node["description"] = "结果指标:收入与盈利" + for c in dim_node["children"]: + c["drives"] = None + trees.append(dim_node) + + if kpi_id: + return _filter_tree(trees, kpi_id) + + return { + "mode": "horizontal_support", + "mode_name": "横向支撑", + "tree": trees, + "total": len(kpis), + "causal_chain": [ + {"from": "学习成长", "to": "内部流程", "logic": "培训与创新→流程效率提升"}, + {"from": "内部流程", "to": "客户", "logic": "流程效率→客户满意度提升"}, + {"from": "客户", "to": "财务", "logic": "客户规模→财务结果达成"}, + ], + } + + +def _build_bsc_chain(kpis, kpi_id=None): + """BSC瀑布链:按category类别间的因果传导""" + from collections import defaultdict + + # 每个维度的KPI按category分组 + cat_kpis = defaultdict(list) + for k in kpis: + if k.category: + cat_kpis[k.category].append(k) + + # BSC瀑布链的传导关系 + chain = [ + {"cat": "talent_pipeline", "label": "人才梯队", "dim": "learning", "feeds": ["supply_chain", "delivery_quality"]}, + {"cat": "employee_engagement", "label": "员工敬业", "dim": "learning", "feeds": ["supply_chain"]}, + {"cat": "innovation", "label": "创新改善", "dim": "learning", "feeds": ["delivery_quality"]}, + {"cat": "supply_chain", "label": "供应链效率", "dim": "process", "feeds": ["delivery_quality"]}, + {"cat": "delivery_quality", "label": "交付质量", "dim": "process", "feeds": ["customer_scale", "customer_satisfaction"]}, + {"cat": "customer_scale", "label": "客户规模", "dim": "customer", "feeds": ["revenue_growth"]}, + {"cat": "customer_concentration", "label": "客户集中度", "dim": "customer", "feeds": ["profitability"]}, + {"cat": "customer_satisfaction", "label": "客户满意", "dim": "customer", "feeds": ["revenue_growth", "profitability"]}, + {"cat": "revenue_growth", "label": "收入增长", "dim": "finance", "feeds": ["profitability"]}, + {"cat": "profitability", "label": "盈利水平", "dim": "finance", "feeds": None}, + {"cat": "cost_control", "label": "成本费用", "dim": "finance", "feeds": ["profitability"]}, + {"cat": "asset_efficiency", "label": "资产效率", "dim": "finance", "feeds": ["profitability"]}, + {"cat": "cash_risk", "label": "现金流风控", "dim": "finance", "feeds": ["profitability"]}, + ] + + def make_node(kpi): + return { + "id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, + "dimension": kpi.dimension, "category": kpi.category, + "target_value": kpi.target_value, "unit": kpi.unit, + } + + # 构建瀑布链 + trees = [] + for link in chain: + cat = link["cat"] + if cat not in cat_kpis: + continue + cat_node = { + "id": None, + "category": cat, + "category_label": link["label"], + "dimension": link["dim"], + "is_category_group": True, + "feeds": link["feeds"], + "children": [make_node(k) for k in sorted(cat_kpis[cat], key=lambda x: x.kpi_code)], + "child_count": len(cat_kpis[cat]), + } + trees.append(cat_node) + + if kpi_id: + return _filter_tree(trees, kpi_id) + + return { + "mode": "bsc_chain", + "mode_name": "BSC瀑布链", + "tree": trees, + "total": len(kpis), + "chain": chain, + } + + +def _filter_tree(nodes, target_id): + """在树中查找包含指定KPI的子树""" + for node in nodes: + if node.get("id") == target_id: + return node + if node.get("children"): + for child in node["children"]: + if child.get("id") == target_id: + return child + # 递归查找 + found = _filter_tree(node["children"], target_id) + if found: + return found + return None diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 00000000..c3e2ff35 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,70 @@ +"""用户认证""" +import hashlib +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.models import User +from app.auth_middleware import create_token, require_auth, ROLES + +router = APIRouter(prefix="/api/cma/auth", tags=["认证"]) + + +@router.post("/login") +def login(data: dict, db: Session = Depends(get_db)): + username = data.get("username", "") + password = data.get("password", "") + user = db.query(User).filter(User.username == username).first() + if not user or user.password_hash != hashlib.sha256(password.encode()).hexdigest(): + raise HTTPException(401, "用户名或密码错误") + + token = create_token(user.id) + return { + "token": token, + "user": { + "id": user.id, + "username": user.username, + "name": user.name, + "role": user.role, + "role_name": ROLES.get(user.role, {}).get("name", user.role), + } + } + + +@router.post("/register") +def register(data: dict, db: Session = Depends(get_db)): + exist = db.query(User).filter(User.username == data.get("username")).first() + if exist: + raise HTTPException(400, "用户名已存在") + user = User( + username=data["username"], + password_hash=hashlib.sha256(data["password"].encode()).hexdigest(), + name=data.get("name", data["username"]), + role=data.get("role", "business"), + ) + db.add(user) + db.commit() + return {"message": "注册成功"} + + +@router.get("/me") +def get_me(current_user: User = Depends(require_auth)): + """获取当前用户信息""" + return { + "id": current_user.id, + "username": current_user.username, + "name": current_user.name, + "role": current_user.role, + "role_name": ROLES.get(current_user.role, {}).get("name", current_user.role), + "phone": current_user.phone, + } + + +@router.get("/roles") +def list_roles(): + """返回角色列表(给前端用)""" + return { + "data": [ + {"code": k, "name": v["name"], "priority": v["priority"]} + for k, v in ROLES.items() + ] + } diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py new file mode 100644 index 00000000..68a99eb6 --- /dev/null +++ b/backend/app/api/budget.py @@ -0,0 +1,336 @@ +"""预算管理 API — 管理会计OS +预算值的CRUD、自动分解、版本管理 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import Optional +from datetime import datetime + +from app.database import get_db +from app.auth_middleware import require_auth, require_role +from app.models import BudgetPlan, KPIDefinition, OperationLog + +router = APIRouter(prefix="/api/cma/budget", tags=["预算管理"], + dependencies=[Depends(require_role("ceo", "finance", "it"))], +) + + +@router.get("/plans") +def list_budget_plans( + kpi_id: Optional[int] = Query(None), + period: Optional[str] = Query(None), + year: Optional[int] = Query(None), + version: Optional[str] = Query(None), + db: Session = Depends(get_db), +): + """查询预算计划列表""" + query = db.query(BudgetPlan).join( + KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id + ) + + if kpi_id: + query = query.filter(BudgetPlan.kpi_id == kpi_id) + if period: + query = query.filter(BudgetPlan.period == period) + if year: + query = query.filter(BudgetPlan.budget_year == year) + if version: + query = query.filter(BudgetPlan.version == version) + + plans = query.order_by(BudgetPlan.budget_year.desc(), BudgetPlan.budget_month.asc()).all() + + result = [] + for p in plans: + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first() + result.append({ + "id": p.id, + "kpi_id": p.kpi_id, + "kpi_code": kpi.kpi_code if kpi else "", + "kpi_name": kpi.kpi_name if kpi else "", + "period": p.period, + "budget_value": p.budget_value, + "budget_year": p.budget_year, + "budget_month": p.budget_month, + "version": p.version, + "status": p.status, + "remark": p.remark, + "created_at": p.created_at.isoformat() if p.created_at else None, + }) + return {"data": result, "total": len(result)} + + +@router.post("/plans") +def create_budget_plan( + data: dict, + db: Session = Depends(get_db), + current_user=Depends(require_auth), +): + """创建或更新单条预算计划""" + kpi_id = data.get("kpi_id") + period = data.get("period") + budget_value = data.get("budget_value") + + if not all([kpi_id, period, budget_value is not None]): + raise HTTPException(400, "缺少必要参数: kpi_id, period, budget_value") + + # 检查KPI是否存在 + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + + year, month = period.split("-") + version = data.get("version", "v1.0") + + # 检查是否已有记录(去重) + 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_value + existing.remark = data.get("remark", existing.remark) + db.commit() + db.refresh(existing) + return {"message": "预算已更新", "id": existing.id} + else: + plan = BudgetPlan( + kpi_id=kpi_id, + period=period, + budget_value=budget_value, + budget_year=int(year), + budget_month=int(month), + version=version, + status="active", + remark=data.get("remark", ""), + created_by=current_user.name if hasattr(current_user, "name") else "", + ) + db.add(plan) + db.commit() + db.refresh(plan) + + # 记录操作日志 + log = OperationLog( + action="create", + target_type="budget", + target_id=plan.id, + detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value}, ensure_ascii=False), + ) + db.add(log) + db.commit() + + return {"message": "预算已创建", "id": plan.id} + + +@router.put("/plans/{plan_id}") +def update_budget_plan( + plan_id: int, + data: dict, + db: Session = Depends(get_db), +): + """更新预算计划""" + plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first() + if not plan: + raise HTTPException(404, "预算计划不存在") + + if "budget_value" in data: + plan.budget_value = data["budget_value"] + if "remark" in data: + plan.remark = data["remark"] + if "version" in data: + plan.version = data["version"] + if "status" in data: + plan.status = data["status"] + + db.commit() + return {"message": "预算已更新"} + + +@router.delete("/plans/{plan_id}") +def delete_budget_plan( + plan_id: int, + db: Session = Depends(get_db), +): + """删除预算计划""" + plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first() + if not plan: + raise HTTPException(404, "预算计划不存在") + db.delete(plan) + db.commit() + return {"message": "预算已删除"} + + +@router.post("/auto-decompose") +def auto_decompose_budget( + data: dict, + db: Session = Depends(get_db), + current_user=Depends(require_auth), +): + """自动分解年度预算到月度(均分或按历史权重)""" + kpi_id = data.get("kpi_id") + year = data.get("year", datetime.now().year) + annual_budget = data.get("annual_budget") + method = data.get("method", "equal") # equal / weighted + version = data.get("version", "v1.0") + + if not kpi_id or annual_budget is None: + raise HTTPException(400, "缺少必要参数: kpi_id, annual_budget") + + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + + # 计算各月权重 + if method == "weighted": + # 按去年各月实际值的比例分配 + last_year = year - 1 + values = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi_id, + KPIValue.period.like(f"{last_year}-%"), + KPIValue.actual_value.isnot(None), + ).order_by(KPIValue.period.asc()).all() + + total = sum(v.actual_value for v in values) + if total > 0: + weights = {v.period: v.actual_value / total for v in values} + else: + method = "equal" + created = [] + for m in range(1, 13): + period = f"{year}-{m:02d}" + weight = weights.get(period, 1 / 12) if method == "weighted" else 1 / 12 + monthly_value = round(annual_budget * weight, 2) + + 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 = monthly_value + else: + bp = BudgetPlan( + kpi_id=kpi_id, period=period, + budget_value=monthly_value, budget_year=year, + budget_month=m, version=version, status="active", + created_by=current_user.name if hasattr(current_user, "name") else "", + ) + db.add(bp) + created.append({"period": period, "value": monthly_value}) + else: + # 均分 + monthly = round(annual_budget / 12, 2) + created = [] + for m in range(1, 13): + period = f"{year}-{m:02d}" + 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 = monthly + else: + bp = BudgetPlan( + kpi_id=kpi_id, period=period, + budget_value=monthly, budget_year=year, + budget_month=m, version=version, status="active", + created_by=current_user.name if hasattr(current_user, "name") else "", + ) + db.add(bp) + created.append({"period": period, "value": monthly}) + + db.commit() + return { + "message": f"年度预算已分解为{len(created)}个月度预算", + "kpi_id": kpi_id, + "kpi_name": kpi.kpi_name, + "year": year, + "annual_budget": annual_budget, + "method": method, + "monthly_budgets": created, + } + + +@router.get("/deviation-report") +def get_deviation_report( + kpi_id: Optional[int] = Query(None), + period: Optional[str] = Query(None), + year: Optional[int] = Query(None), + month: Optional[int] = Query(None), + dimension: Optional[str] = Query(None), + alert_level: Optional[str] = Query(None), + db: Session = Depends(get_db), +): + """获取差异分析报告(汇总多个KPI的实际vs预算差异)""" + if period is None: + if year and month: + period = f"{year}-{month:02d}" + elif year: + period = f"{year}-{datetime.now().month:02d}" + else: + period = datetime.now().strftime("%Y-%m") + + query = db.query(KPIDefinition).filter(KPIDefinition.status == "active") + if kpi_id: + query = query.filter(KPIDefinition.id == kpi_id) + if dimension: + query = query.filter(KPIDefinition.dimension == dimension) + + kpis = query.all() + from app.utils.deviation_engine import calc_period_deviation, calc_period_diff + + items = [] + summary = { + "total_kpis": 0, + "has_budget": 0, + "over_budget": 0, + "under_budget": 0, + "avg_deviation_rate": 0, + } + + rates = [] + for kpi in kpis: + item = calc_period_deviation(db, kpi.id, period) + items.append(item) + summary["total_kpis"] += 1 + + if item.get("budget_value") is not None: + summary["has_budget"] += 1 + if item.get("is_over_budget"): + summary["over_budget"] += 1 + elif item.get("deviation_rate") is not None and item["deviation_rate"] < 0: + summary["under_budget"] += 1 + if item.get("deviation_rate") is not None: + rates.append(abs(item["deviation_rate"])) + + # 补充同比/环比 + if item.get("actual_value") is not None: + item["yoy"] = calc_period_diff(db, kpi.id, period, "yoy") + item["mom"] = calc_period_diff(db, kpi.id, period, "mom") + + summary["avg_deviation_rate"] = round(sum(rates) / len(rates), 2) if rates else 0 + + # 前端 alert_level 过滤 + if alert_level: + def get_level(rate): + if rate is None: + return None + if rate > 20: + return "red" + if rate > 10: + return "yellow" + return "normal" + items = [i for i in items if get_level(i.get("deviation_rate")) == alert_level] + + return { + "period": period, + "summary": summary, + "items": items, + } diff --git a/backend/app/api/cost.py b/backend/app/api/cost.py new file mode 100644 index 00000000..0611c30d --- /dev/null +++ b/backend/app/api/cost.py @@ -0,0 +1,237 @@ +"""成本分析API — 管理会计OS""" +import logging +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation +from app.utils.cost_engine import ( + calc_product_variance, get_cost_overview, get_cost_breakdown, + calc_driver_rate, allocate_cost +) + +logger = logging.getLogger("cma.cost") +router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"]) + + +# ============================================================ +# 标准成本卡片 CRUD +# ============================================================ + +@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)): + """查询标准成本卡片""" + query = db.query(StandardCost).filter(StandardCost.status == "active") + if product_code: + query = query.filter(StandardCost.product_code == product_code) + if cost_type: + query = query.filter(StandardCost.cost_type == cost_type) + items = query.order_by(StandardCost.product_code, StandardCost.cost_type).all() + return {"data": items} + + +@router.post("/standard-costs") +def create_standard_cost(data: dict, db: Session = Depends(get_db)): + """创建标准成本卡片""" + sc = StandardCost( + product_code=data["product_code"], + product_name=data.get("product_name", ""), + cost_type=data["cost_type"], + item_name=data["item_name"], + standard_quantity=data["standard_quantity"], + unit=data.get("unit", ""), + standard_price=data["standard_price"], + standard_cost=round(data["standard_quantity"] * data["standard_price"], 2), + version=data.get("version", "v1.0"), + remark=data.get("remark"), + ) + db.add(sc) + db.commit() + return {"message": "标准成本已创建", "id": sc.id} + + +@router.put("/standard-costs/{cost_id}") +def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db)): + """修改标准成本卡片""" + sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first() + if not sc: + raise HTTPException(404, "标准成本记录不存在") + for k in ("product_code", "product_name", "cost_type", "item_name", + "standard_quantity", "unit", "standard_price", "version", "remark"): + if k in data: + setattr(sc, k, data[k]) + sc.standard_cost = round(sc.standard_quantity * sc.standard_price, 2) + db.commit() + return {"message": "已更新"} + + +@router.delete("/standard-costs/{cost_id}") +def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)): + """删除标准成本卡片""" + sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first() + if not sc: + raise HTTPException(404, "标准成本记录不存在") + sc.status = "archived" + db.commit() + return {"message": "已归档"} + + +# ============================================================ +# 实际成本 CRUD +# ============================================================ + +@router.get("/actual-costs") +def list_actual_costs(period: Optional[str] = Query(None), + product_code: Optional[str] = Query(None), + db: Session = Depends(get_db)): + """查询实际成本""" + query = db.query(ActualCost) + if period: + query = query.filter(ActualCost.period == period) + if product_code: + query = query.filter(ActualCost.product_code == product_code) + items = query.order_by(ActualCost.period.desc(), ActualCost.product_code).all() + return {"data": items} + + +@router.post("/actual-costs") +def create_actual_cost(data: dict, db: Session = Depends(get_db)): + """录入实际成本""" + ac = ActualCost( + period=data["period"], + product_code=data["product_code"], + product_name=data.get("product_name", ""), + cost_type=data["cost_type"], + item_name=data.get("item_name", ""), + actual_quantity=data["actual_quantity"], + actual_price=data["actual_price"], + actual_cost=round(data["actual_quantity"] * data["actual_price"], 2), + source=data.get("source", "manual"), + ) + db.add(ac) + db.commit() + return {"message": "实际成本已录入", "id": ac.id} + + +# ============================================================ +# ABC 作业成本 +# ============================================================ + +@router.get("/abc/activities") +def list_abc_activities(db: Session = Depends(get_db)): + """查询ABC作业中心列表""" + items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all() + return {"data": items} + + +@router.post("/abc/activities") +def create_abc_activity(data: dict, db: Session = Depends(get_db)): + """创建ABC作业中心""" + act = AbcActivity( + activity_code=data["activity_code"], + activity_name=data["activity_name"], + activity_desc=data.get("activity_desc"), + cost_driver=data["cost_driver"], + driver_unit=data.get("driver_unit"), + total_cost=data.get("total_cost", 0), + driver_volume=data.get("driver_volume", 0), + ) + act.driver_rate = round(act.total_cost / act.driver_volume, 4) if act.driver_volume > 0 else 0 + db.add(act) + db.commit() + return {"message": "作业中心已创建", "id": act.id} + + +@router.post("/abc/allocate") +def do_allocate(data: dict, db: Session = Depends(get_db)): + """执行ABC成本分配""" + result = allocate_cost( + activity_id=data["activity_id"], + period=data.get("period", datetime.now().strftime("%Y-%m")), + product_code=data["product_code"], + product_name=data.get("product_name", ""), + driver_consumed=data["driver_consumed"], + ) + return result + + +@router.get("/abc/allocations") +def list_allocations(period: Optional[str] = Query(None), + product_code: Optional[str] = Query(None), + db: Session = Depends(get_db)): + """查询ABC分配记录""" + query = db.query(AbcAllocation) + if period: + query = query.filter(AbcAllocation.period == period) + if product_code: + query = query.filter(AbcAllocation.product_code == product_code) + items = query.order_by(AbcAllocation.period.desc()).all() + return {"data": items} + + +# ============================================================ +# 分析看板 +# ============================================================ + +@router.get("/overview") +def cost_overview(period: Optional[str] = Query(None)): + """成本总览(总成本、结构占比、趋势)""" + if period is None: + period = datetime.now().strftime("%Y-%m") + return get_cost_overview(period) + + +@router.get("/variance") +def cost_variance(product_code: str = Query(...), + period: Optional[str] = Query(None)): + """量差价差分析""" + if period is None: + period = datetime.now().strftime("%Y-%m") + return calc_product_variance(product_code, period) + + +@router.get("/breakdown") +def cost_breakdown(product_code: str = Query(...), + period: Optional[str] = Query(None)): + """成本构成(料/工/费占比)""" + if period is None: + period = datetime.now().strftime("%Y-%m") + return get_cost_breakdown(product_code, period) + + +@router.get("/dashboard") +def cost_dashboard(period: Optional[str] = Query(None)): + """成本分析首页—汇总数据""" + if period is None: + period = datetime.now().strftime("%Y-%m") + overview = get_cost_overview(period) + + # 获取所有产品列表 + db = get_db().__next__() + try: + products = db.query(ActualCost.product_code, ActualCost.product_name).filter( + ActualCost.period == period + ).distinct().all() + product_list = [{"code": p[0], "name": p[1]} for p in products] + + # 各产品成本 + product_costs = [] + for code, name in products: + costs = db.query(ActualCost).filter( + ActualCost.product_code == code, + ActualCost.period == period, + ).all() + total = round(sum(c.actual_cost for c in costs), 2) + product_costs.append({"product_code": code, "product_name": name, "total_cost": total}) + finally: + db.close() + + return { + "period": period, + "overview": overview, + "products": product_list, + "total_cost": round(sum(p["total_cost"] for p in product_list) + overview.get("erp_cost", 0), 2) if product_list else 0, + } diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 00000000..66cc89b5 --- /dev/null +++ b/backend/app/api/dashboard.py @@ -0,0 +1,495 @@ +"""驾驶舱 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: + op = k.threshold_red[:2] if k.threshold_red[1] in "=<>" else k.threshold_red[0] + val = float(k.threshold_red.replace(op, "").strip()) + if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val): + alert_level = "red" + if alert_level == "none" and k.threshold_yellow: + op = k.threshold_yellow[:2] if k.threshold_yellow[1] in "=<>" else k.threshold_yellow[0] + val = float(k.threshold_yellow.replace(op, "").strip()) + if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val): + alert_level = "yellow" + + 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_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没有固定预设 + } + preset_codes = ROLE_PRESET_KPIS.get(role, []) + + # 1. 我的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} + + # 补充角色预设KPI(去重) + preset_kpis = [] + if preset_codes: + preset_kpis = db.query(KPIDefinition).filter( + KPIDefinition.kpi_code.in_(preset_codes), + KPIDefinition.status == "active", + ~KPIDefinition.id.in_(assigned_ids) if assigned_ids else True, + ).all() + + all_kpis = assigned_kpis + preset_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, + } diff --git a/backend/app/api/data.py b/backend/app/api/data.py new file mode 100644 index 00000000..a10988e3 --- /dev/null +++ b/backend/app/api/data.py @@ -0,0 +1,100 @@ +"""数据对接 API""" +import pandas as pd +import io, json, hashlib +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import Optional +from app.database import get_db +from app.auth_middleware import require_auth, require_role +from app.models import KPIValue, DataSourceConfig, OperationLog + +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)): + content = await file.read() + df = pd.read_excel(io.BytesIO(content)) + + required = ["kpi_code", "period", "actual_value"] + if not all(c in df.columns for c in required): + raise HTTPException(400, f"Excel必须包含列: {required}") + + 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") + if not kpi_code or not period or pd.isna(value): + continue + + from app.models import KPIDefinition + kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() + if not kpi: + continue + + kv = KPIValue( + kpi_id=kpi.id, + period=period, + actual_value=float(value), + source_type="excel", + source_batch=batch, + data_status="pending", + ) + db.add(kv) + count += 1 + + db.commit() + return {"message": f"导入成功 {count} 条数据", "batch": batch} + +@router.get("/sources") +def list_sources(db: Session = Depends(get_db)): + sources = db.query(DataSourceConfig).all() + return {"data": [{c.name: getattr(s, c.name) for c in DataSourceConfig.__table__.columns} for s in sources]} + +@router.post("/sources") +def create_source(data: dict, db: Session = Depends(get_db)): + source = DataSourceConfig( + name=data.get("name", ""), + source_type=data.get("source_type", "manual"), + api_endpoint=data.get("api_endpoint"), + api_key=data.get("api_key"), + query_sql=data.get("query_sql"), + sync_type=data.get("sync_type", "manual"), + status="active", + ) + db.add(source) + db.commit() + db.refresh(source) + # 操作日志 + db.add(OperationLog(action="create_source", target_type="source", detail=source.name)) + db.commit() + return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}} + +@router.put("/sources/{source_id}") +def update_source(source_id: int, data: dict, db: Session = Depends(get_db)): + source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first() + if not source: + raise HTTPException(404, "数据源不存在") + for key in ["name", "source_type", "api_endpoint", "api_key", "query_sql", "sync_type", "status"]: + if key in data: + setattr(source, key, data[key]) + db.commit() + db.refresh(source) + db.add(OperationLog(action="update_source", target_type="source", detail=source.name)) + db.commit() + return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}} + +@router.delete("/sources/{source_id}") +def delete_source(source_id: int, db: Session = Depends(get_db)): + source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first() + if not source: + raise HTTPException(404, "数据源不存在") + db.add(OperationLog(action="delete_source", target_type="source", detail=source.name)) + db.delete(source) + db.commit() + return {"message": "删除成功"} diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py new file mode 100644 index 00000000..30e6ba80 --- /dev/null +++ b/backend/app/api/kpis.py @@ -0,0 +1,169 @@ +"""KPI字典 API""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import Optional, List +from datetime import datetime +import json + +from app.database import get_db +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 + +router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"], + dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], +) + +# 写操作只允许 ceo/finance/it +WRITE_ROLES = Depends(require_role("ceo", "finance", "it")) + + +@router.get("") +def list_kpis( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + dimension: Optional[str] = None, + keyword: Optional[str] = None, + epic: Optional[str] = None, + category: Optional[str] = None, + db: Session = Depends(get_db), + current_user = Depends(require_auth), +): + query = db.query(KPIDefinition).filter(KPIDefinition.status == "active") + # 角色权限过滤 + dims = kpi_visible_dims(current_user.role, db) + if dims: + query = query.filter(KPIDefinition.dimension.in_(dims)) + if dimension: + query = query.filter(KPIDefinition.dimension == dimension) + 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) + 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]} + + +@router.get("/categories") +def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depends(get_db)): + """获取BSC分类结构(带可见性过滤)""" + from sqlalchemy import func as sa_func + dims = kpi_visible_dims(current_user.role, db) + query = db.query( + KPIDefinition.dimension, + KPIDefinition.category, + sa_func.count(KPIDefinition.id) + ).filter(KPIDefinition.status == "active") + if dims: + query = query.filter(KPIDefinition.dimension.in_(dims)) + rows = query.group_by(KPIDefinition.dimension, KPIDefinition.category).all() + + # 构建树形结构 + dim_map = {"finance": "财务", "customer": "客户", "process": "内部流程", "learning": "学习成长"} + cat_map = { + "revenue_growth": "收入增长", "profitability": "盈利水平", "cost_control": "成本费用", + "asset_efficiency": "资产效率", "cash_risk": "现金流风控", + "customer_scale": "客户规模", "customer_concentration": "客户集中度", "customer_satisfaction": "客户满意", + "supply_chain": "供应链效率", "delivery_quality": "交付质量", + "talent_pipeline": "人才梯队", "employee_engagement": "员工敬业", "innovation": "创新改善", + } + tree = [] + for dim, cat, cnt in rows: + # 找或创建维度节点 + dim_node = next((n for n in tree if n["key"] == dim), None) + if not dim_node: + dim_node = {"key": dim, "label": dim_map.get(dim, dim), "children": []} + tree.append(dim_node) + dim_node["children"].append({ + "key": cat, + "label": cat_map.get(cat, cat), + "count": cnt, + }) + dim_counts = {} + for d in tree: + dim_counts[d["key"]] = sum(c["count"] for c in d["children"]) + d["count"] = dim_counts[d["key"]] + return {"tree": tree, "total": sum(dim_counts.values())} + + +@router.get("/{kpi_id}") +def get_kpi(kpi_id: int, db: Session = Depends(get_db)): + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + return kpi_to_dict(kpi) + + +@router.post("") +def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): + kpi = KPIDefinition(**data) + db.add(kpi) + db.commit() + db.refresh(kpi) + _log(db, 1, "create", "kpi", kpi.id, data) + return kpi_to_dict(kpi) + + +@router.put("/{kpi_id}") +def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + for k, v in data.items(): + if hasattr(kpi, k) and v is not None: + setattr(kpi, k, v) + db.commit() + _log(db, 1, "update", "kpi", kpi_id, data) + return kpi_to_dict(kpi) + + +@router.delete("/{kpi_id}") +def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES): + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if kpi: + kpi.status = "disabled" + db.commit() + return {"message": "已删除"} + + +def kpi_to_dict(k): + return {c.name: getattr(k, c.name) for c in k.__table__.columns} + + +def _log(db, user_id, action, target_type, target_id, detail): + 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() + + +@router.get("/{kpi_id}/objectives") +def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)): + """查看KPI所属的目标和战略地图""" + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + + # 通过 kpi_definitions.objective 字段关联目标 + # 也通过 map_id 关联地图 + result = { + "kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name}, + "objectives": [], + "map": None, + } + + if kpi.map_id: + m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first() + if m: + result["map"] = {"id": m.id, "title": m.title, "status": m.status} + + if kpi.objective: + objs = db.query(MapObjective).filter( + MapObjective.map_id == kpi.map_id, + MapObjective.name == kpi.objective, + ).all() + result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs] + + return result diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py new file mode 100644 index 00000000..742cd13e --- /dev/null +++ b/backend/app/api/maps.py @@ -0,0 +1,403 @@ +"""战略地图 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_auth, require_role +from app.models import StrategicMap, OperationLog +import json + +router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"], + dependencies=[Depends(require_role("ceo", "finance"))], +) + +# ── 四维度模板 ────────────────────────────── +STRATEGIC_MAP_TEMPLATE = [ + { + "key": "finance", + "name": "财务维度", + "icon": "💰", + "color": "#409eff", + "objectives": [ + {"name": "提升销售总额", "kpis": ["F_REVENUE_001"]}, + {"name": "优化利润结构", "kpis": ["F_PROFIT_001"]}, + {"name": "降低运营成本", "kpis": ["F_COST_001"]}, + ], + }, + { + "key": "customer", + "name": "客户维度", + "icon": "🤝", + "color": "#67c23a", + "objectives": [ + {"name": "扩大客户规模", "kpis": ["C_CUST_001"]}, + {"name": "提升客户满意度", "kpis": ["C_CUST_003"]}, + {"name": "优化客户结构", "kpis": ["C_CUST_002"]}, + ], + }, + { + "key": "process", + "name": "内部流程", + "icon": "⚙️", + "color": "#e6a23c", + "objectives": [ + {"name": "提升运营效率", "kpis": ["P_INV_001"]}, + {"name": "优化供应链管理", "kpis": ["P_INV_002"]}, + {"name": "确保交付质量", "kpis": ["P_SERVICE_001"]}, + ], + }, + { + "key": "learning", + "name": "学习成长", + "icon": "📚", + "color": "#f56c6c", + "objectives": [ + {"name": "提升员工技能", "kpis": ["L_TALENT_001"]}, + {"name": "推进数字化转型", "kpis": []}, + {"name": "建设人才梯队", "kpis": ["L_TALENT_004", "L_TALENT_003"]}, + ], + }, +] + +# ── 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]} + +@router.post("") +def create_map(data: dict, db: Session = Depends(get_db)): + m = StrategicMap(**data) + db.add(m) + db.commit() + db.refresh(m) + return m_to_dict(m) + + +@router.post("/create-with-template") +def create_map_with_template(data: dict, db: Session = Depends(get_db)): + """一键创建带四维度模板的战略地图""" + m = StrategicMap( + title=data.get("title", "新建战略地图"), + version=data.get("version", "v1.0"), + status="draft", + dimensions=STRATEGIC_MAP_TEMPLATE, + canvas_data={"connections": []}, + ) + db.add(m) + db.commit() + db.refresh(m) + return m_to_dict(m) + + +@router.put("/{map_id}") +def update_map(map_id: int, data: dict, db: Session = Depends(get_db)): + m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() + if not m: + raise HTTPException(404, "战略地图不存在") + + old_status = m.status + for k, v in data.items(): + if hasattr(m, k) and v is not None: + setattr(m, k, v) + + db.commit() + + # ├─ 版本管理: draft → published 时自动创建快照 + if old_status == "draft" and m.status == "published": + _auto_snapshot(m, db) + + return m_to_dict(m) + + +# ── 连线管理 ───────────────────────────────── + +def _get_connections(m: StrategicMap) -> list: + if not m.canvas_data: + m.canvas_data = {"connections": []} + if isinstance(m.canvas_data, str): + try: + m.canvas_data = json.loads(m.canvas_data) + except: + m.canvas_data = {"connections": []} + if "connections" not in m.canvas_data: + m.canvas_data["connections"] = [] + return m.canvas_data["connections"] + + +@router.post("/{map_id}/connections") +def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)): + """新增因果连线: {"from": "learning-0", "to": "process-0"}""" + m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() + if not m: + raise HTTPException(404, "战略地图不存在") + + from_id = data.get("from", "") + to_id = data.get("to", "") + + if not from_id or not to_id: + raise HTTPException(400, "请提供 from 和 to") + + # 校验: 不能自连 + 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) + + # 校验: 不能重复 + for c in conns: + 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"}) + 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)): + """根据 from/to 删除连线""" + m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() + if not m: + raise HTTPException(404, "战略地图不存在") + + from_id = data.get("from", "") + to_id = data.get("to", "") + + conns = _get_connections(m) + new_conns = [c for c in conns if not (c.get("from") == from_id and c.get("to") == to_id)] + + if len(new_conns) == len(conns): + raise HTTPException(404, "连线不存在") + + m.canvas_data["connections"] = new_conns + db.commit() + return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}} + + +# ── 版本管理 ───────────────────────────────── + +def _auto_snapshot(m: StrategicMap, db: Session): + """发布时自动创建版本快照""" + from app.models import StrategicMapVersion + import re + + # 自动递增版本号: 找到最大次版本号 + existing = db.query(StrategicMapVersion).filter( + StrategicMapVersion.map_id == m.id + ).order_by(StrategicMapVersion.id.desc()).first() + + if existing: + match = re.search(r"v(\d+)\.(\d+)", existing.version) + if match: + major = int(match.group(1)) + minor = int(match.group(2)) + 1 + new_ver = f"v{major}.{minor}" + else: + new_ver = "v1.0" + else: + new_ver = "v1.0" + + # 确保 JSON 序列化 + dims = m.dimensions + canvas = m.canvas_data + if isinstance(dims, str): + try: + dims = json.loads(dims) + except: + dims = [] + if isinstance(canvas, str): + try: + canvas = json.loads(canvas) + except: + canvas = {"connections": []} + + snapshot = StrategicMapVersion( + map_id=m.id, + version=new_ver, + dimensions=dims, + canvas_data=canvas, + comment=f"发布 {new_ver}", + ) + db.add(snapshot) + db.commit() + + +# ── 工具函数 ───────────────────────────────── + +def m_to_dict(m): + return {c.name: getattr(m, c.name) for c in m.__table__.columns} + + +# ── 战略回顾会 聚合接口 ────────────────────── + + +@router.get("/{map_id}/review") +def get_map_review(map_id: int, db: Session = Depends(get_db)): + """战略回顾会:返回目标状态、KPI值、改善行动""" + from app.models import KPIDefinition, KPIValue, ActionPlan + m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() + if not m: + raise HTTPException(404, "战略地图不存在") + + dims = m.dimensions + if isinstance(dims, str): + dims = json.loads(dims) + + # 收集所有KPI code + all_kpi_codes = set() + for dim in dims: + for obj in dim.get("objectives", []): + for code in obj.get("kpis", []): + all_kpi_codes.add(code) + + # 查询KPI定义 + kpi_defs = db.query(KPIDefinition).filter( + KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False + ).all() if all_kpi_codes else [] + kpi_map = {k.kpi_code: k for k in kpi_defs} + + # 查询最新KPI实际值 + kpi_ids = [k.id for k in kpi_defs] + latest_values = {} + if kpi_ids: + # 取每个KPI的最新一条 + for kid in kpi_ids: + v = db.query(KPIValue).filter( + KPIValue.kpi_id == kid + ).order_by(KPIValue.calculated_at.desc()).first() + if v: + latest_values[kid] = { + "actual_value": v.actual_value, + "period": v.period, + "source_type": v.source_type, + } + + # 查询改善行动(按KPI_id关联) + action_plans_data = [] + if kpi_ids: + plans = db.query(ActionPlan).filter( + ActionPlan.kpi_id.in_(kpi_ids) + ).order_by(ActionPlan.created_at.desc()).all() + for p in plans: + action_plans_data.append({ + "id": p.id, + "kpi_id": p.kpi_id, + "title": p.title, + "assignee": p.assignee, + "priority": p.priority, + "due_date": p.due_date.isoformat() if p.due_date else None, + "status": p.status, + "progress": p.progress or 0, + "created_at": p.created_at.isoformat() if p.created_at else None, + }) + + # 构建维度目标状态 + dim_results = [] + total_ok = 0 + total_warn = 0 + total_err = 0 + total_obj_count = 0 + focus_items = [] + + for dim in dims: + dim_key = dim.get("key", "") + dim_name = dim.get("name", "") + dim_icon = dim.get("icon", "") + dim_color = dim.get("color", "") + objectives = [] + for obj in dim.get("objectives", []): + total_obj_count += 1 + obj_kpis = [] + worst_level = "green" + for code in obj.get("kpis", []): + kpi_def = kpi_map.get(code) + if not kpi_def: + continue + lv = latest_values.get(kpi_def.id, {}) + actual = lv.get("actual_value") + target = kpi_def.target_value + # 判断红黄绿灯 + level = "gray" + if actual is not None and target: + ratio = actual / target + if ratio >= 0.9: + level = "green" + elif ratio >= 0.7: + level = "yellow" + else: + level = "red" + else: + level = "gray" + + if level == "red": + worst_level = "red" + elif level == "yellow" and worst_level != "red": + worst_level = "yellow" + + obj_kpis.append({ + "kpi_id": kpi_def.id, + "kpi_code": code, + "kpi_name": kpi_def.kpi_name, + "target_value": target, + "actual_value": actual, + "unit": kpi_def.unit, + "level": level, + }) + + obj_item = { + "name": obj.get("name", ""), + "icon": obj.get("icon", ""), + "kpis": obj_kpis, + "level": worst_level, + "has_data": len(obj_kpis) > 0, + } + objectives.append(obj_item) + + if worst_level == "green": + total_ok += 1 + elif worst_level == "yellow": + total_warn += 1 + elif worst_level == "red": + total_err += 1 + + # 红色和黄色归入需重点关注 + if worst_level in ("red", "yellow"): + focus_items.append(obj_item) + + dim_results.append({ + "key": dim_key, + "name": dim_name, + "icon": dim_icon, + "color": dim_color, + "objectives": objectives, + }) + + # 排序:红色在前,黄色在后 + focus_items.sort(key=lambda x: (0 if x["level"] == "red" else 1, x["name"])) + + return { + "map_id": m.id, + "title": m.title, + "version": m.version, + "status": m.status, + "dimensions": dim_results, + "summary": { + "total": total_obj_count, + "green": total_ok, + "yellow": total_warn, + "red": total_err, + "health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0, + }, + "focus_items": focus_items, + "action_plans": action_plans_data, + } + diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 00000000..deec28ba --- /dev/null +++ b/backend/app/api/notifications.py @@ -0,0 +1,113 @@ +"""通知渠道配置 API""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.models import NotificationChannel, NotificationLog +from app.auth_middleware import require_role +from datetime import datetime + +router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"]) + + +def ch_to_dict(c): + return { + "id": c.id, + "name": c.name, + "channel_type": c.channel_type, + "config": c.config, + "enabled": c.enabled, + "created_at": c.created_at.isoformat() if c.created_at else None, + } + + +@router.get("/channels") +def list_channels(db: Session = Depends(get_db)): + """获取通知渠道列表""" + channels = db.query(NotificationChannel).order_by(NotificationChannel.id).all() + return {"data": [ch_to_dict(c) for c in channels]} + + +@router.post("/channels") +def create_channel(data: dict, db: Session = Depends(get_db)): + """创建通知渠道""" + ch = NotificationChannel( + name=data["name"], + channel_type=data["channel_type"], + config=data.get("config", {}), + enabled=data.get("enabled", True), + ) + db.add(ch) + db.commit() + db.refresh(ch) + return ch_to_dict(ch) + + +@router.put("/channels/{ch_id}") +def update_channel(ch_id: int, data: dict, db: Session = Depends(get_db)): + """更新通知渠道""" + ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first() + if not ch: + raise HTTPException(404, "渠道不存在") + for k, v in data.items(): + if hasattr(ch, k) and k not in ("id", "created_at"): + setattr(ch, k, v) + db.commit() + db.refresh(ch) + return ch_to_dict(ch) + + +@router.delete("/channels/{ch_id}") +def delete_channel(ch_id: int, db: Session = Depends(get_db)): + """删除通知渠道""" + ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first() + if not ch: + raise HTTPException(404, "渠道不存在") + db.delete(ch) + db.commit() + return {"message": "已删除"} + + +@router.post("/channels/{ch_id}/test") +def test_channel(ch_id: int, db: Session = Depends(get_db)): + """测试推送""" + from app.utils.notifier import push_alert + ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first() + if not ch: + raise HTTPException(404, "渠道不存在") + config = ch.config or {} + test_alert = { + "alert_level": "yellow", + "alert_message": "【测试通知】这是一条管理会计OS的测试预警", + "kpi_name": "销售总额", + "period": datetime.now().strftime("%Y-%m"), + "actual_value": "800,000", + "target_value": "1,000,000", + } + results = push_alert(test_alert, [{ + "name": ch.name, "channel_type": ch.channel_type, + "config": config, "enabled": True + }]) + return {"results": results} + + +@router.get("/logs") +def list_logs(page: int = 1, db: Session = Depends(get_db)): + """通知历史""" + total = db.query(NotificationLog).count() + logs = db.query(NotificationLog).order_by( + NotificationLog.created_at.desc() + ).offset((page - 1) * 20).limit(20).all() + return { + "total": total, + "data": [{ + "id": l.id, + "alert_id": l.alert_id, + "channel": l.channel, + "recipient": l.recipient, + "title": l.title, + "status": l.status, + "error_msg": l.error_msg, + "sent_at": l.sent_at.isoformat() if l.sent_at else None, + "created_at": l.created_at.isoformat() if l.created_at else None, + } for l in logs] + } diff --git a/backend/app/api/objectives.py b/backend/app/api/objectives.py new file mode 100644 index 00000000..a6000c5b --- /dev/null +++ b/backend/app/api/objectives.py @@ -0,0 +1,83 @@ +"""战略地图目标 API""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.auth_middleware import require_role +from app.models import MapObjective, StrategicMap, KPIDefinition + +router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"], + dependencies=[Depends(require_role("ceo", "finance"))], +) + + +@router.get("/{map_id}/objectives") +def list_objectives(map_id: int, db: Session = Depends(get_db)): + """获取某地图下的所有目标""" + objs = db.query(MapObjective).filter( + MapObjective.map_id == map_id + ).order_by(MapObjective.sort_order).all() + return {"data": [_obj_to_dict(o) for o in objs]} + + +@router.post("/{map_id}/objectives") +def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)): + """新增目标""" + m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() + if not m: + raise HTTPException(404, "战略地图不存在") + obj = MapObjective( + map_id=map_id, + dimension_key=data["dimension_key"], + name=data["name"], + description=data.get("description"), + icon=data.get("icon", "target"), + sort_order=data.get("sort_order", 0), + ) + db.add(obj) + db.commit() + db.refresh(obj) + return _obj_to_dict(obj) + + +@router.put("/{map_id}/objectives/{obj_id}") +def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db)): + """修改目标""" + obj = db.query(MapObjective).filter( + MapObjective.id == obj_id, MapObjective.map_id == map_id + ).first() + if not obj: + raise HTTPException(404, "目标不存在") + for k, v in data.items(): + if 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)): + """删除目标""" + obj = db.query(MapObjective).filter( + MapObjective.id == obj_id, MapObjective.map_id == map_id + ).first() + if not obj: + raise HTTPException(404, "目标不存在") + db.delete(obj) + db.commit() + return {"message": "已删除"} + + +@router.put("/{map_id}/objectives/sort") +def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db)): + """批量排序: {"ids": [3, 1, 2]}""" + ids = data.get("ids", []) + for idx, obj_id in enumerate(ids): + db.query(MapObjective).filter( + MapObjective.id == obj_id, MapObjective.map_id == map_id + ).update({"sort_order": idx}) + db.commit() + return {"message": "排序已更新"} + + +def _obj_to_dict(o): + return {c.name: getattr(o, c.name) for c in o.__table__.columns} diff --git a/backend/app/api/org.py b/backend/app/api/org.py new file mode 100644 index 00000000..d99f5ae3 --- /dev/null +++ b/backend/app/api/org.py @@ -0,0 +1,101 @@ +"""组织层级 API""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db, init_db +from app.auth_middleware import require_auth, require_role +from app.models import OrgNode, User + +router = APIRouter(prefix="/api/cma/org", tags=["组织管理"], + dependencies=[Depends(require_role("ceo", "it"))], +) + + +@router.get("/tree") +def get_org_tree(db: Session = Depends(get_db)): + """返回全量树结构: [{id, label, children}]""" + nodes = db.query(OrgNode).order_by(OrgNode.sort_order).all() + return {"data": _build_tree(nodes)} + + +@router.get("/nodes") +def list_org_nodes(db: Session = Depends(get_db)): + """平铺列表""" + nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all() + return {"data": [_node_to_dict(n) for n in nodes]} + + +@router.post("/nodes") +def create_org_node(data: dict, db: Session = Depends(get_db)): + """新增节点""" + node = OrgNode( + parent_id=data.get("parent_id"), + name=data["name"], + code=data.get("code"), + level=data["level"], + sort_order=data.get("sort_order", 0), + enabled=data.get("enabled", 1), + remark=data.get("remark"), + ) + db.add(node) + db.commit() + db.refresh(node) + return _node_to_dict(node) + + +@router.put("/nodes/{node_id}") +def update_org_node(node_id: int, data: dict, db: Session = Depends(get_db)): + """修改节点""" + node = db.query(OrgNode).filter(OrgNode.id == node_id).first() + if not node: + raise HTTPException(404, "节点不存在") + for k, v in data.items(): + if hasattr(node, k) and v is not None: + setattr(node, k, v) + db.commit() + return _node_to_dict(node) + + +@router.delete("/nodes/{node_id}") +def delete_org_node(node_id: int, db: Session = Depends(get_db)): + """删除节点(有子节点则阻止)""" + node = db.query(OrgNode).filter(OrgNode.id == node_id).first() + if not node: + raise HTTPException(404, "节点不存在") + # 检查是否有子节点 + children = db.query(OrgNode).filter(OrgNode.parent_id == node_id).count() + if children > 0: + raise HTTPException(400, f"该节点有 {children} 个子节点,请先删除子节点") + db.delete(node) + db.commit() + return {"message": "已删除"} + + +@router.put("/nodes/{node_id}/toggle") +def toggle_org_node(node_id: int, db: Session = Depends(get_db)): + """切换启用/禁用""" + node = db.query(OrgNode).filter(OrgNode.id == node_id).first() + if not node: + raise HTTPException(404, "节点不存在") + node.enabled = 0 if node.enabled else 1 + db.commit() + return _node_to_dict(node) + + +# ── 工具 ───────────────────────────────── + +def _build_tree(nodes: list) -> list: + """将平铺节点列表转为树结构""" + node_map = {n.id: {"id": n.id, "label": n.name, "level": n.level, "enabled": bool(n.enabled), "code": n.code, "children": []} for n in nodes} + tree = [] + for n in nodes: + item = node_map[n.id] + if n.parent_id and n.parent_id in node_map: + node_map[n.parent_id]["children"].append(item) + else: + tree.append(item) + return tree + + +def _node_to_dict(n): + return {c.name: getattr(n, c.name) for c in n.__table__.columns} + diff --git a/backend/app/api/permissions.py b/backend/app/api/permissions.py new file mode 100644 index 00000000..2a2c15d1 --- /dev/null +++ b/backend/app/api/permissions.py @@ -0,0 +1,121 @@ +""" +角色权限管理 API — 管理会计OS +支持在页面上配置角色可访问的模块和操作权限 +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.models import RolePermission +from app.auth_middleware import require_auth, require_role + +router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"]) + +# 模块定义(所有可配置的模块) +MODULES = [ + {"key": "dashboard", "name": "驾驶舱"}, + {"key": "kpis", "name": "KPI字典"}, + {"key": "kpi_detail", "name": "KPI详情"}, + {"key": "maps", "name": "战略地图"}, + {"key": "alerts", "name": "预警中心"}, + {"key": "ai_analysis", "name": "AI分析"}, + {"key": "data_source", "name": "数据管理"}, + {"key": "budget", "name": "预算管理"}, + {"key": "deviation", "name": "差异分析"}, + {"key": "cost", "name": "成本分析"}, + {"key": "predict", "name": "预测模拟"}, + {"key": "org", "name": "组织管理"}, + {"key": "user_manage", "name": "用户管理"}, + {"key": "system_config", "name": "通知配置"}, + {"key": "role_permissions", "name": "角色权限"}, + {"key": "action_plans", "name": "改善行动"}, + {"key": "alignment", "name": "KPI目标对齐"}, +] + +ACTIONS = [ + {"key": "read", "name": "读取"}, + {"key": "write", "name": "写入"}, + {"key": "import", "name": "导入"}, + {"key": "export", "name": "导出"}, + {"key": "delete", "name": "删除"}, + {"key": "approve", "name": "审批"}, + {"key": "admin", "name": "管理"}, +] + +ROLES = [ + {"code": "ceo", "name": "CEO"}, + {"code": "finance", "name": "财务"}, + {"code": "business", "name": "业务"}, + {"code": "it", "name": "IT运维"}, +] + +# 默认权限 +DEFAULT_ROUTE_PERMISSIONS = { + "ceo": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source", "budget", "deviation", "cost", "predict", "org", "user_manage", "system_config", "role_permissions", "action_plans", "alignment"], + "finance": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source", "budget", "deviation", "cost", "predict"], + "business": ["dashboard", "kpis", "kpi_detail", "alerts", "budget", "deviation"], + "it": ["dashboard", "kpis", "kpi_detail", "alerts", "data_source", "budget", "deviation", "cost", "predict", "org", "user_manage", "system_config"], +} + +DEFAULT_ACTION_PERMISSIONS = { + "ceo": ["read", "approve"], + "finance": ["read", "write", "import", "export"], + "business": ["read", "write"], + "it": ["read", "write", "delete", "admin"], +} + + +def _get_or_create_defaults(db: Session): + """获取配置,不存在则创建默认值""" + route_perm = db.query(RolePermission).filter(RolePermission.key == "route_permissions").first() + if not route_perm: + route_perm = RolePermission(key="route_permissions", value=DEFAULT_ROUTE_PERMISSIONS) + db.add(route_perm) + + action_perm = db.query(RolePermission).filter(RolePermission.key == "action_permissions").first() + if not action_perm: + action_perm = RolePermission(key="action_permissions", value=DEFAULT_ACTION_PERMISSIONS) + db.add(action_perm) + + db.commit() + db.refresh(route_perm) + db.refresh(action_perm) + return route_perm, action_perm + + +@router.get("/modules") +def list_modules(): + """返回模块和动作定义""" + return { + "modules": MODULES, + "actions": ACTIONS, + "roles": ROLES, + } + + +@router.get("/config") +def get_permissions(db: Session = Depends(get_db)): + """获取当前权限配置""" + route_perm, action_perm = _get_or_create_defaults(db) + return { + "route_permissions": route_perm.value, + "action_permissions": action_perm.value, + } + + +@router.put("/config") +def update_permissions( + data: dict, + db: Session = Depends(get_db), + _=Depends(require_role("ceo", "it")), +): + """更新权限配置""" + route_perm, action_perm = _get_or_create_defaults(db) + + if "route_permissions" in data: + route_perm.value = data["route_permissions"] + if "action_permissions" in data: + action_perm.value = data["action_permissions"] + + db.commit() + return {"message": "权限配置已更新"} diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py new file mode 100644 index 00000000..50c12f19 --- /dev/null +++ b/backend/app/api/predict.py @@ -0,0 +1,98 @@ +"""预测模拟API — 管理会计OS""" +import logging +from fastapi import APIRouter, HTTPException +from app.utils.predict_engine import ( + cvp_analysis, npv, irr, + sensitivity_analysis, scenario_analysis, +) + +logger = logging.getLogger("cma.predict") +router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"]) + + +@router.post("/cvp") +def api_cvp_analysis(data: dict): + """CVP本量利分析""" + try: + result = cvp_analysis( + unit_price=float(data.get("unit_price", 0)), + unit_variable_cost=float(data.get("unit_variable_cost", 0)), + fixed_cost=float(data.get("fixed_cost", 0)), + target_profit=float(data["target_profit"]) if data.get("target_profit") else None, + actual_volume=float(data["actual_volume"]) if data.get("actual_volume") else None, + ) + return result + except Exception as e: + raise HTTPException(400, f"CVP计算失败: {str(e)}") + + +@router.post("/investment") +def api_investment_analysis(data: dict): + """投资决策分析(NPV/IRR/回收期)""" + try: + initial = float(data.get("initial_investment", 0)) + rate = float(data.get("discount_rate", 10)) + cash_flows = [float(cf) for cf in data.get("cash_flows", [])] + + if not cash_flows: + raise HTTPException(400, "现金流列表不能为空") + + npv_result = npv(initial, cash_flows, rate) + irr_result = irr(initial, cash_flows) + + return { + "npv_analysis": npv_result, + "irr_analysis": irr_result, + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, f"投资决策计算失败: {str(e)}") + + +@router.post("/sensitivity") +def api_sensitivity_analysis(data: dict): + """敏感性分析""" + try: + result = sensitivity_analysis( + base_revenue=float(data.get("base_revenue", 0)), + base_cost=float(data.get("base_cost", 0)), + base_profit=float(data["base_profit"]) if data.get("base_profit") else None, + step=int(data.get("step", 5)), + max_step=int(data.get("max_step", 20)), + ) + return result + except Exception as e: + raise HTTPException(400, f"敏感性分析失败: {str(e)}") + + +@router.post("/scenario") +def api_scenario_analysis(data: dict): + """情景模拟""" + try: + optimistic = data.get("optimistic", {}) + pessimistic = data.get("pessimistic", {}) + base = data.get("base", {}) + + if not all([optimistic, pessimistic, base]): + raise HTTPException(400, "需要提供乐观/中性/悲观三个情景的参数") + + result = scenario_analysis( + optimistic={ + "revenue": float(optimistic.get("revenue", 0)), + "cost": float(optimistic.get("cost", 0)), + }, + pessimistic={ + "revenue": float(pessimistic.get("revenue", 0)), + "cost": float(pessimistic.get("cost", 0)), + }, + base={ + "revenue": float(base.get("revenue", 0)), + "cost": float(base.get("cost", 0)), + }, + ) + return result + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, f"情景模拟失败: {str(e)}") diff --git a/backend/app/api/thresholds.py b/backend/app/api/thresholds.py new file mode 100644 index 00000000..d4da110c --- /dev/null +++ b/backend/app/api/thresholds.py @@ -0,0 +1,97 @@ +"""阈值智能推荐 API""" +from fastapi import APIRouter, Depends, Query +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, KPIValue, OperationLog +import json + +router = APIRouter(prefix="/api/cma/thresholds", tags=["阈值分析"], + dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], +) + +KPI_TYPES = { + "higher_better": ["SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE", + "CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE", "TRAINING_COMPLETION", + "RECEIVABLE_TURNOVER", "TURNOVER_RATE"], + "lower_better": ["COST_CONTROL_RATE"], + "middle_best": ["TOP5_CUSTOMER_RATIO"], +} + +@router.get("/suggest/{kpi_id}") +def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)): + """根据历史数据自动推荐阈值""" + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + return {"error": "KPI不存在"} + + values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all() + + if not values: + # 无历史数据,按行业标准推荐 + return suggest_by_type(kpi) + + nums = [v.actual_value for v in values if v.actual_value is not None] + + if len(nums) < 2: + return suggest_by_type(kpi) + + avg = sum(nums) / len(nums) + # 计算标准差 + variance = sum((x - avg) ** 2 for x in nums) / len(nums) + std = variance ** 0.5 + + target = kpi.target_value or avg + + # 根据KPI类型生成推荐区间 + if kpi.kpi_code in KPI_TYPES["higher_better"]: + green_min = round(target * 0.8, 2) + yellow_min = round(target * 0.5, 2) + red_max = round(target * 0.5, 2) + suggestion = { + "type": "higher_better", + "description": "越高越好型", + "green": {"min": green_min, "max": None, "label": f">={green_min}"}, + "yellow": {"min": yellow_min, "max": green_min, "label": f"{yellow_min}~{green_min}"}, + "red": {"min": None, "max": red_max, "label": f"<{red_max}"}, + "current_avg": round(avg, 2), + "target": target, + } + elif kpi.kpi_code in KPI_TYPES["lower_better"]: + green_max = round(target * 1.2, 2) + yellow_max = round(target * 2.0, 2) + suggestion = { + "type": "lower_better", + "description": "越低越好型", + "green": {"min": None, "max": green_max, "label": f"<={green_max}"}, + "yellow": {"min": green_max, "max": yellow_max, "label": f"{green_max}~{yellow_max}"}, + "red": {"min": yellow_max, "max": None, "label": f">{yellow_max}"}, + "current_avg": round(avg, 2), + "target": target, + } + else: + tolerance = max(std * 1.5, target * 0.2) + suggestion = { + "type": "middle_best", + "description": "适中最好型", + "green": {"min": round(target - tolerance, 2), "max": round(target + tolerance, 2), "label": f"{round(target-tolerance,2)}~{round(target+tolerance,2)}"}, + "yellow": {"min": round(target - tolerance*2, 2), "max": round(target + tolerance*2, 2), "label": f"偏离{(tolerance*2):.0f}%"}, + "red": {"min": None, "max": round(target - tolerance*2, 2), "label": f"偏离>{tolerance*2:.0f}%"}, + "current_avg": round(avg, 2), + "target": target, + } + + return {"kpi_id": kpi_id, "kpi_name": kpi.kpi_name, "suggestion": suggestion} + +def suggest_by_type(kpi): + """无历史数据时按类型推荐""" + target = kpi.target_value or 100 + if kpi.kpi_code in KPI_TYPES["higher_better"]: + return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据", + "suggestion": {"type": "higher_better", "green": {"min": round(target*0.8,2)}, "yellow": {"min": round(target*0.5,2)}, "red": {"max": round(target*0.5,2)}}} + elif kpi.kpi_code in KPI_TYPES["lower_better"]: + return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据", + "suggestion": {"type": "lower_better", "green": {"max": round(target*1.2,2)}, "yellow": {"max": round(target*2,2)}, "red": {"min": round(target*2,2)}}} + else: + return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据", + "suggestion": {"type": "middle_best", "green": {"min": round(target*0.8,2), "max": round(target*1.2,2)}}} diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 00000000..b9d7e1f5 --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,66 @@ +"""用户管理 API""" +import hashlib +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.auth_middleware import require_role, require_auth +from app.models import User + +router = APIRouter(prefix="/api/cma/users", tags=["用户管理"], + dependencies=[Depends(require_role("ceo", "it"))], +) + +def user_to_dict(u): + return { + "id": u.id, + "username": u.username, + "name": u.name, + "role": u.role, + "phone": u.phone, + "created_at": u.created_at.isoformat() if u.created_at else None, + } + +@router.get("") +def list_users(db: Session = Depends(get_db)): + users = db.query(User).order_by(User.id).all() + return {"data": [user_to_dict(u) for u in users]} + +@router.post("") +def create_user(data: dict, db: Session = Depends(get_db)): + exist = db.query(User).filter(User.username == data.get("username")).first() + if exist: + raise HTTPException(400, "用户名已存在") + user = User( + username=data["username"], + password_hash=hashlib.sha256(data["password"].encode()).hexdigest(), + name=data.get("name", data["username"]), + role=data.get("role", "business"), + phone=data.get("phone", ""), + ) + db.add(user) + db.commit() + db.refresh(user) + return user_to_dict(user) + +@router.put("/{user_id}") +def update_user(user_id: int, data: dict, db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "用户不存在") + for k, v in data.items(): + if k == "password" and v: + setattr(user, "password_hash", hashlib.sha256(v.encode()).hexdigest()) + elif hasattr(user, k) and v is not None and k not in ("id", "username", "created_at"): + setattr(user, k, v) + db.commit() + db.refresh(user) + return user_to_dict(user) + +@router.delete("/{user_id}") +def delete_user(user_id: int, db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "用户不存在") + db.delete(user) + db.commit() + return {"message": "已删除"} diff --git a/backend/app/api/versions.py b/backend/app/api/versions.py new file mode 100644 index 00000000..a41524a7 --- /dev/null +++ b/backend/app/api/versions.py @@ -0,0 +1,86 @@ +"""战略地图版本管理 API""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.auth_middleware import require_role +from app.models import StrategicMap, StrategicMapVersion + +router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"], + dependencies=[Depends(require_role("ceo", "finance"))], +) + + +@router.get("/{map_id}/versions") +def list_versions(map_id: int, db: Session = Depends(get_db)): + """查看版本历史""" + versions = db.query(StrategicMapVersion).filter( + StrategicMapVersion.map_id == map_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() + if not m: + raise HTTPException(404, "战略地图不存在") + + import json + dims = m.dimensions + canvas = m.canvas_data + if isinstance(dims, str): + dims = json.loads(dims) + if isinstance(canvas, str): + canvas = json.loads(canvas) + + # 自动版本号 + existing = db.query(StrategicMapVersion).filter( + StrategicMapVersion.map_id == map_id + ).order_by(StrategicMapVersion.id.desc()).first() + if existing: + import re + match = re.search(r"v(\d+)\.(\d+)", existing.version) + major = int(match.group(1)) if match else 1 + minor = int(match.group(2)) + 1 if match else 0 + new_ver = f"v{major}.{minor}" + else: + new_ver = "v1.0" + + snapshot = StrategicMapVersion( + map_id=map_id, + version=new_ver, + dimensions=dims, + canvas_data=canvas, + comment=data.get("comment", f"手动快照 {new_ver}"), + ) + db.add(snapshot) + db.commit() + db.refresh(snapshot) + return v_to_dict(snapshot) + + +@router.post("/{map_id}/versions/{ver_id}/rollback") +def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db)): + """回滚到指定版本""" + m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() + if not m: + raise HTTPException(404, "战略地图不存在") + + v = db.query(StrategicMapVersion).filter( + StrategicMapVersion.id == ver_id, + StrategicMapVersion.map_id == map_id, + ).first() + if not v: + raise HTTPException(404, "版本不存在") + + m.dimensions = v.dimensions + m.canvas_data = v.canvas_data + m.version = f"rollback-{v.version}" + m.status = "draft" + db.commit() + return {"message": f"已回滚到 {v.version}", "version": m.version} + + +def v_to_dict(v): + return {c.name: getattr(v, c.name) for c in v.__table__.columns} diff --git a/backend/app/auth_middleware.py b/backend/app/auth_middleware.py new file mode 100644 index 00000000..6454f7d8 --- /dev/null +++ b/backend/app/auth_middleware.py @@ -0,0 +1,189 @@ +""" +角色权限中间件 — 管理会计OS +4角色: ceo(CEO/总览), finance(财务), business(业务), it(IT/运维) +权限配置支持从数据库动态加载 +""" + +from fastapi import Request, HTTPException, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from app.database import get_db +from app.models import User, RolePermission +import secrets +import json + +# 角色定义(固定) +ROLES = { + "ceo": {"name": "CEO", "priority": 1}, + "finance": {"name": "财务", "priority": 2}, + "business": {"name": "业务", "priority": 3}, + "it": {"name": "IT运维", "priority": 4}, +} + +# 默认权限(数据库没有时的 fallback) +DEFAULT_ROUTE_PERMISSIONS = { + "ceo": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source", "user_manage", "system_config"], + "finance": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source"], + "business": ["dashboard", "kpis", "kpi_detail", "alerts"], + "it": ["dashboard", "kpis", "kpi_detail", "alerts", "data_source", "user_manage", "system_config"], +} + +DEFAULT_KPI_VISIBILITY = { + "ceo": ["*"], # CEO看全部维度 + "finance": ["finance_*"], # 财务只看财务 + "business": ["customer_*", "process_*", "learning_*"], # 业务看客户/流程/学习 + "it": ["*"], # IT看全部(运维) +} + +DEFAULT_ACTION_PERMISSIONS = { + "ceo": ["read", "approve"], + "finance": ["read", "write", "import", "export"], + "business": ["read", "write"], + "it": ["read", "write", "delete", "admin"], +} + +import logging +logger = logging.getLogger("cma.auth") + +# Redis token 存储(跨 worker 共享) +try: + import redis as redis_lib + _redis = redis_lib.Redis( + host="127.0.0.1", port=6379, db=1, + decode_responses=True, socket_connect_timeout=2, socket_timeout=3 + ) + _redis.ping() + _redis_available = True +except Exception: + _redis = None + _redis_available = False + logger.warning("Redis不可用,token存储降级到内存(不支持多worker)") + +# 内存 fallback +_token_store: dict[str, int] = {} + +TOKEN_PREFIX = "cma:token:" +TOKEN_TTL = 86400 # 24小时 + +# 缓存权限配置(每5分钟刷新) +_permissions_cache = {"route": None, "action": None, "ts": 0} +_PERM_CACHE_TTL = 300 + + +def _load_permissions(db: Session = None): + """从数据库加载权限配置""" + import time + now = time.time() + if db is None: + if now - _permissions_cache["ts"] < _PERM_CACHE_TTL: + return _permissions_cache["route"] or DEFAULT_ROUTE_PERMISSIONS, _permissions_cache["action"] or DEFAULT_ACTION_PERMISSIONS + return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS + + try: + route_perm = db.query(RolePermission).filter(RolePermission.key == "route_permissions").first() + action_perm = db.query(RolePermission).filter(RolePermission.key == "action_permissions").first() + + routes = route_perm.value if route_perm else DEFAULT_ROUTE_PERMISSIONS + actions = action_perm.value if action_perm else DEFAULT_ACTION_PERMISSIONS + + _permissions_cache["route"] = routes + _permissions_cache["action"] = actions + _permissions_cache["ts"] = now + + return routes, actions + except Exception: + return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS + + +def create_token(user_id: int) -> str: + token = secrets.token_hex(32) + if _redis_available: + _redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, user_id) + else: + _token_store[token] = user_id + return token + + +def _resolve_user_id(token: str) -> int | None: + if _redis_available: + val = _redis.get(f"{TOKEN_PREFIX}{token}") + if val is not None: + return int(val) + return None + return _token_store.get(token) + + +def require_auth( + credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=True)), + db: Session = Depends(get_db), +) -> User: + token = credentials.credentials + user_id = _resolve_user_id(token) + if user_id is None: + raise HTTPException(401, "无效的token,请重新登录") + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(401, "用户不存在") + return user + + +def require_role(*roles: str): + async def role_checker( + current_user: User = Depends(require_auth), + ) -> User: + if current_user.role not in roles: + raise HTTPException(403, f"权限不足: 需要 {', '.join(roles)} 角色") + return current_user + return role_checker + + +def has_permission(user: User, module: str, db: Session = None) -> bool: + routes, _ = _load_permissions(db) + return module in routes.get(user.role, []) + + +def has_action(user: User, action: str, db: Session = None) -> bool: + _, actions = _load_permissions(db) + return action in actions.get(user.role, []) + + +# ─── KPI可见性(按维度/分类过滤) ─── + +def _load_kpi_visibility(db: Session = None): + """从RolePermission表加载kpi_visibility配置(独立缓存)""" + import time + if not hasattr(_load_kpi_visibility, "_cache"): + _load_kpi_visibility._cache = {"data": None, "ts": 0} + cache = _load_kpi_visibility._cache + now = time.time() + if db is None or (cache["data"] and now - cache["ts"] < _PERM_CACHE_TTL): + return cache["data"] or DEFAULT_KPI_VISIBILITY + try: + perm = db.query(RolePermission).filter(RolePermission.key == "kpi_visibility").first() + cache["data"] = perm.value if perm else DEFAULT_KPI_VISIBILITY + cache["ts"] = now + return cache["data"] + except Exception: + return DEFAULT_KPI_VISIBILITY + + +def kpi_visible_dims(role: str, db: Session = None) -> list[str]: + """返回角色可见的维度列表(空列表=全部可见)""" + vis = _load_kpi_visibility(db) + rules = vis.get(role, ["*"]) + if "*" in rules: + return [] # 空=全部可见 + # 提取维度前缀:finance_* -> finance + dims = set() + for r in rules: + if r.endswith("_*"): + dims.add(r[:-2]) + return list(dims) + + +def filter_kpis_by_role(kpis: list, role: str, db: Session = None) -> list: + """按角色可见性过滤KPI列表""" + dims = kpi_visible_dims(role, db) + if not dims: + return kpis # 全部可见 + return [k for k in kpis if k.dimension in dims or (hasattr(k, 'dimension') and k.dimension in dims)] diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 00000000..a55438ca --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,122 @@ +"""数据库配置""" +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from sqlalchemy import inspect +import os +import logging + +logger = logging.getLogger("cma") + +DB_USER = os.getenv("CMA_DB_USER", "cma_user") +DB_PASS = os.getenv("CMA_DB_PASS", "cma_pass_2026") +DB_HOST = os.getenv("CMA_DB_HOST", "127.0.0.1") +DB_PORT = os.getenv("CMA_DB_PORT", "3306") +DB_NAME = os.getenv("CMA_DB_NAME", "cma") + +DATABASE_URL = "mysql+pymysql://%(user)s:%(password)s@%(host)s:%(port)s/%(name)s?charset=utf8mb4" % { + "user": DB_USER, + "password": DB_PASS, + "host": DB_HOST, + "port": DB_PORT, + "name": DB_NAME, +} + +_engine = None +_SessionLocal = None +Base = declarative_base() + + +def get_engine(): + global _engine + if _engine is None: + _engine = create_engine(DATABASE_URL, echo=False, pool_size=5, max_overflow=10, pool_pre_ping=True) + return _engine + + +def get_session_local(): + global _SessionLocal + if _SessionLocal is None: + _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=get_engine()) + return _SessionLocal + + +def get_db(): + db = get_session_local()() + try: + yield db + finally: + db.close() + + +def init_db(): + import app.models + Base.metadata.create_all(bind=get_engine()) + logger.info("CMA数据库已初始化") + + # ── 初始化组织层级示例数据 ── + try: + inspector = inspect(get_engine()) + if "org_nodes" in inspector.get_table_names(): + Session = get_session_local() + session = Session() + try: + cnt = session.query(app.models.OrgNode).count() + if cnt == 0: + _seed_org_data(session) + finally: + session.close() + except Exception as e: + logger.warning(f"组织数据初始化跳过: {e}") + + +def _seed_org_data(db_session): + """插入5层级组织示例数据""" + from app.models import OrgNode + + # 1. 集团 + g = OrgNode(id=1, parent_id=None, name="博海网络科技", code="BH", level=1, sort_order=1, enabled=1) + db_session.add(g) + db_session.flush() + + # 2. 事业部 + depts = [ + OrgNode(parent_id=1, name="技术事业部", code="TECH", level=2, sort_order=1, enabled=1), + OrgNode(parent_id=1, name="销售事业部", code="SALES", level=2, sort_order=2, enabled=1), + OrgNode(parent_id=1, name="财务事业部", code="FIN", level=2, sort_order=3, enabled=1), + ] + db_session.add_all(depts) + db_session.flush() + + # 3. 区域/部门级 + regions = [ + OrgNode(parent_id=2, name="华南区域", code="SC", level=3, sort_order=1, enabled=1), + OrgNode(parent_id=2, name="华东区域", code="EC", level=3, sort_order=2, enabled=1), + OrgNode(parent_id=3, name="销售一部", code="S1", level=3, sort_order=1, enabled=1), + OrgNode(parent_id=3, name="销售二部", code="S2", level=3, sort_order=2, enabled=1), + ] + db_session.add_all(regions) + db_session.flush() + + # 4. 部门 + departs = [ + OrgNode(parent_id=5, name="研发部", code="RD", level=4, sort_order=1, enabled=1), + OrgNode(parent_id=5, name="实施部", code="IMP", level=4, sort_order=2, enabled=1), + OrgNode(parent_id=5, name="运维部", code="OPS", level=4, sort_order=3, enabled=1), + OrgNode(parent_id=6, name="前端研发", code="FE", level=4, sort_order=1, enabled=1), + OrgNode(parent_id=6, name="后端研发", code="BE", level=4, sort_order=2, enabled=1), + OrgNode(parent_id=8, name="KA客户部", code="KA", level=4, sort_order=1, enabled=1), + ] + db_session.add_all(departs) + db_session.flush() + + # 5. 班组 + teams = [ + OrgNode(parent_id=12, name="前端组", code="FE-TEAM", level=5, sort_order=1, enabled=1), + OrgNode(parent_id=12, name="后端组", code="BE-TEAM", level=5, sort_order=2, enabled=1), + OrgNode(parent_id=12, name="测试组", code="QA-TEAM", level=5, sort_order=3, enabled=1), + OrgNode(parent_id=13, name="实施一组", code="IMP1", level=5, sort_order=1, enabled=1), + OrgNode(parent_id=13, name="实施二组", code="IMP2", level=5, sort_order=2, enabled=1), + ] + db_session.add_all(teams) + db_session.commit() + logger.info("组织层级示例数据已初始化") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 00000000..1653a0ac --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,115 @@ +"""管理会计OS — 主入口""" +import logging +from fastapi import FastAPI, Request +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, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict +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 + +load_dotenv() + +# 日志配置 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("cma") + +app = FastAPI(title="管理会计OS API", version="1.0.0", docs_url="/docs") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(kpis.router) +app.include_router(maps.router) +app.include_router(dashboard.router) +app.include_router(data.router) +app.include_router(alerts.router) +app.include_router(ai_analysis.router) +app.include_router(alert_rules.router) +app.include_router(users.router) +app.include_router(thresholds.router) +app.include_router(notifications.router) +app.include_router(permissions.router) +app.include_router(action_plans.router) +app.include_router(alignment.router) +app.include_router(org.router) +app.include_router(objectives.router) +app.include_router(versions.router) +app.include_router(budget.router) +app.include_router(cost.router) +app.include_router(predict.router) + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.error(f"未捕获异常: {exc}", exc_info=True) + return JSONResponse(status_code=500, content={"detail": "服务器内部错误"}) + + +@app.on_event("startup") +def startup(): + init_db() + logger.info("管理会计OS后端启动完成") + + +@app.post("/api/cma/admin/erp-sync") +def admin_erp_sync(kpi_codes: str = None): + """手动触发ERP数据同步""" + kpi_list = kpi_codes.split(",") if kpi_codes else None + try: + run_erp_sync(dry_run=False, kpi_codes=kpi_list, use_api=True) + return {"message": "ERP同步完成", "kpis": kpi_list} + except Exception as e: + return JSONResponse(status_code=500, content={"detail": f"同步失败: {str(e)}"}) + + +@app.get("/api/cma/admin/erp-sync/dry-run") +def admin_erp_sync_dry_run(kpi_codes: str = None): + "试运行,不写入数据库""" + kpi_list = kpi_codes.split(",") if kpi_codes else None + try: + run_erp_sync(dry_run=True, kpi_codes=kpi_list, use_api=True) + return {"message": "试运行完成"} + except Exception as e: + return JSONResponse(status_code=500, content={"detail": f"试运行失败: {str(e)}"}) + + +@app.post("/api/cma/admin/alerts/check") +def admin_check_alerts(): + """手动触发预警检查""" + from app.database import get_session_local + from scripts.alert_generator import generate_and_push + db = get_session_local()() + try: + result = generate_and_push(db) + return {"message": "预警检查完成", "result": result} + except Exception as e: + return JSONResponse(status_code=500, content={"detail": f"检查失败: {str(e)}"}) + finally: + db.close() + + +@app.post("/api/cma/admin/cache/clear") +def admin_clear_cache(module: str = None): + """清空缓存,指定module则只清该模块""" + if module: + delete_cache(module) + return {"message": f"缓存已清空: {module}"} + else: + clear_cache() + return {"message": "全部缓存已清空"} + + +@app.get("/health") +def health(): + return {"status": "ok", "version": "1.0.0"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 00000000..b3f09adb --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,214 @@ +"""管理会计OS 数据模型""" +from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func +from app.database import Base + +from app.models.budget_plan import BudgetPlan +from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation + + +class User(Base): + """用户""" + __tablename__ = "users" + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(128), nullable=False) + name = Column(String(100), nullable=False) + role = Column(String(20), default="finance") # ceo / finance / business / it + phone = Column(String(20), nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + +class StrategicMap(Base): + """战略地图""" + __tablename__ = "strategic_maps" + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False, comment="地图名称") + version = Column(String(20), default="v1.0", comment="版本号") + status = Column(String(20), default="draft", comment="draft/published") + dimensions = Column(JSON, nullable=True, comment="四维度和目标列表") + canvas_data = Column(JSON, nullable=True, 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 KPIDefinition(Base): + """KPI字典""" + __tablename__ = "kpi_definitions" + id = Column(Integer, primary_key=True, index=True) + 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名称") + dimension = Column(String(50), comment="所属维度: finance/customer/process/learning") + objective = Column(String(200), comment="关联战略目标") + formula = Column(Text, nullable=True, comment="计算公式") + 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="数据源配置") + 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="目标值") + 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") + responsible_dept = Column(String(200), nullable=True, comment="负责部门") + responsible_user = Column(String(100), nullable=True, comment="负责人") + status = Column(String(20), default="active") + epic = Column(String(50), default="Epic2", comment="所属Epic") + 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 KPIValue(Base): + """KPI实际值""" + __tablename__ = "kpi_values" + id = Column(Integer, primary_key=True, index=True) + 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="实际值") + source_type = Column(String(20), default="manual", comment="erp/excel/manual") + source_batch = Column(String(100), nullable=True, comment="导入批次号") + data_status = Column(String(20), default="pending", comment="pending/verified/error") + calculated_at = Column(DateTime, server_default=func.now()) + remark = Column(String(500), nullable=True) + + +class DataSourceConfig(Base): + """数据源配置""" + __tablename__ = "data_source_config" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False, comment="数据源名称") + source_type = Column(String(20), nullable=False, comment="erp/business/excel") + api_endpoint = Column(String(500), nullable=True, comment="API地址") + api_key = Column(String(200), nullable=True, comment="API Key") + query_sql = Column(Text, nullable=True, comment="SQL查询语句") + sync_type = Column(String(20), default="realtime", comment="realtime/batch") + status = Column(String(20), default="active") + last_sync_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + +class KPIAlert(Base): + """预警记录""" + __tablename__ = "kpi_alerts" + id = Column(Integer, primary_key=True, index=True) + kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False) + kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True) + alert_level = Column(String(20), default="yellow", comment="green/yellow/red") + alert_message = Column(String(500), nullable=False) + status = Column(String(20), default="pending", comment="pending/processing/resolved") + assignee = Column(String(100), nullable=True, comment="处理人") + resolution = Column(Text, nullable=True, comment="处理结果") + resolved_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + +class OperationLog(Base): + """操作日志""" + __tablename__ = "operation_logs" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=True) + action = Column(String(50), nullable=False, comment="create/update/delete/calculate/import") + target_type = Column(String(50), nullable=False, comment="kpi/map/alert/source") + target_id = Column(Integer, nullable=True) + detail = Column(JSON, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + +class NotificationChannel(Base): + """通知渠道配置""" + __tablename__ = "notification_channels" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False, comment="渠道名称") + channel_type = Column(String(30), nullable=False, comment="wecom/mail/sms") + config = Column(JSON, nullable=True, comment="渠道配置") + enabled = Column(Boolean, default=True) + created_at = Column(DateTime, server_default=func.now()) + + +class NotificationLog(Base): + """通知发送日志""" + __tablename__ = "notification_logs" + id = Column(Integer, primary_key=True, index=True) + alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True) + channel = Column(String(30), nullable=False, comment="wecom/mail") + recipient = Column(String(200), nullable=True, comment="收件人") + title = Column(String(200), nullable=True) + content = Column(Text, nullable=True) + status = Column(String(20), default="pending", comment="pending/sent/failed") + error_msg = Column(String(500), nullable=True) + sent_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + +class RolePermission(Base): + """角色权限配置(单条记录,key-value)""" + __tablename__ = "role_permissions" + id = Column(Integer, primary_key=True, index=True) + key = Column(String(50), unique=True, nullable=False, comment="配置键: route_permissions / action_permissions") + value = Column(JSON, nullable=False, comment="配置值") + 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") + title = Column(String(200), nullable=False, comment="计划标题") + description = Column(Text, nullable=True, comment="详细描述") + assignee = Column(String(100), nullable=True, comment="负责人") + priority = Column(String(20), default="medium", comment="high/medium/low") + due_date = Column(DateTime, nullable=True, comment="截止日期") + 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="改善结果") + 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 OrgNode(Base): + """组织节点: 集团→事业部→区域→部门→班组 5级""" + __tablename__ = "org_nodes" + id = Column(Integer, primary_key=True, index=True) + parent_id = Column(Integer, ForeignKey("org_nodes.id"), nullable=True, comment="父节点ID") + name = Column(String(100), nullable=False, comment="节点名称") + code = Column(String(50), unique=True, nullable=True, comment="编码") + level = Column(Integer, nullable=False, comment="1=集团 2=事业部 3=区域 4=部门 5=班组") + sort_order = Column(Integer, default=0, comment="排序") + enabled = Column(Integer, default=1, comment="1启用 0禁用") + path = Column(String(500), nullable=True, comment="路径") + remark = Column(String(200), nullable=True, comment="备注") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class StrategicMapVersion(Base): + """战略地图版本快照""" + __tablename__ = "strategic_map_versions" + id = Column(Integer, primary_key=True, index=True) + 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="维度数据快照") + canvas_data = Column(JSON, nullable=False, comment="画布数据快照") + comment = Column(String(500), nullable=True, comment="说明") + created_by = Column(Integer, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + +class MapObjective(Base): + """战略地图目标: 每个维度下的具体目标""" + __tablename__ = "map_objectives" + id = Column(Integer, primary_key=True, index=True) + 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="图标标识") + 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()) diff --git a/backend/app/models/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6704734749ec69831f6ab3e0ecfe88eadd689372 GIT binary patch literal 15244 zcmc&*Yj7J?mX>2#lJ&MEKk~#RB*cV7NeHhH14$g`1x|=P449b}in?vf(TkQ8kY{(+ z84@r9OcER>4rah%Cm;rsn1un46DHZ(ty*g5kGAOAO}ch>s&*qker(NDVXC(Hv9;g1 zSL$xtB9CFWw#&ZiKKIeLTlYKXJLg>e&f%~caQx@5YrFq)wZZTobWuDi3MXH^S!OVt zGzf-vgHR^Siqz;|5j!B)6x^&X{OzMWTN+(^wq#j7UI_W|t^+8&#lP+S? z8c1t((#1?#2kCsBbP1C#fOMfwdJB^-f^@M?TF<0QAiYH=y_HGpA-z>6U7A_iuq?e0 z?`q=g>4{@6T)ptdB@m}1=3x~D}k zcBEsWhBC#tC72ZPY2A_*I>pr9NHEr5RP3#(U`z-~!q#{)rC2wIQt4oXo!d6=58*O= zFr3OL4!Vd$;-O$F9FK+g7tTv{_5C-lPZ~r+dl^>XJfW<;T$tBxoM#X#M3Yc1ln)!# zm0~6-{w+eq2UxRopoMAJthT6BTUgW0sYpYRY)r*?<%OqMaQvww}6pFbc z91F%m;`?QaSr9vd=|~EvPTb<4CT{{Ft#AN%{kUa4eh(1lA-n56D@ONa-@@KUuT;8@nTOg& zd~)SIy<4uhs*iUJ?CbB&`bH~c*Mq$~uJ{(5x@TzF-~%JovhQBBw^bcqG2rcAm93C% zYkHgbwWt4%p|EUQi)*{*_&ozl`!{BdvVC>$wkuV&1D27x5#MKZAJu)lK(5+!XqR+X zaY8fTKwmu_U$rr1{3pY~B$$USCE!r0E@!J?8@8)0G(c!A9D-BVfR zk*1|K-ZwDu`kDNjBl*GK0uIIMKs1<0wn;T;G4%zCX4aTD%O87n;=Pgg=P`&W=1@FJ zt5CWH&3cRn*R5HbriC`~Qs4NS!}+71q2W?oOB+!r6@nD(NcFhbhd+_t9|8agwAJ zK$xt#?7M9aCK;lZ^*@lU9(Bt0?ZA}Ncl_~zE&c7;n$d-_b6fBBD-Q4R=7IJ7yRzoo z{kvqx?%rkso^dFZyL*pZwYPWI6;IuOFKf)*vrG2u?%hqb=Im{9l^<1eAUa2Osq2#P z_g#O}^|#$})ni2f(<77ya1&nf&Db;1goQlUqLCuS(O$G@Rzb691)>QKh!< zsV%C7n&Da%njUH)dNFdHuAi?&zxl!fT|cnK!o9WkvQSv0YXJs{k!vyXV%^B#iiLhl z)ESivx9D1cHuXg1dx?J~G7$N&dr=`6Hu>>p&t*)TK~c(AVEo&D7#+!UEHv z`6C}s9y+1d_tO1%Drr87=Hkc`7k)l5bm;wZX#rIchhlnLx({c?HeCOI?T2XmrQZBU zZ*i@?vA#gD{GoI*6_1LNKOx0KVlwFmsg`0O;SFVMv^mq^+MbyhF#CzKc)H>Ig`-o` zU;^Cmu4^@cF`=kpD=c8 z>d31TpPyCC(ReJ?70L807lPqP#{aA+9>7OI>Ic!2QlbP&2J?<&zA0PIcxnFC*ROv5 zDmQN2JoR3Xme7kKLppK(;>4kIQv-eZ-V01Hb^h>7!Nk}*n&98iWlQVUt7ETBjJ-GU z(fNGe2OH}p@t_z>i-Arl{%op?USCHT{4yMYC*cpl&j^Gtb5KSe~+_L&1osU7~A<>@;&kG;tEw$&MiWm3YjbOeYeNOd!-149EN+l3^Ic z3}e1DgA~S#r2>g?0yQzwFGdrQct#WhVyv^sMZ>XJ{2;pp|2jW;d-9XNntDDvbu#-k z(Sk`PxH>kbnCPAu|5iwqifxH-XnmSob-t$PTC?y{BPv=NZogrxPPd{pk_f0ooWK$S zJ60@o#@KhIYT1>_`2KE@Pl$*p{Drs_O-F)?y>J4cB}0mv41XXQPfH;&kjf-PP7yqM zFxKpX@kVJSU_;M5@oRpMgrg815padr}a4;Bw*WyZbNn)}q9ue@Ji(*W1Yl4gj zxA9rUp-CiBP%LmH#m=~tQQYX604E8<|G?xDshJY;JEYBYOQT5dvI(c9Sc z?Ifdu=vDIX07IfD-pjt_bLfe~du;ou_T0(`zFfTNQY!bouTH#AD zS!@4+?6%PdWY=~`+%?A!4(#vm$*uZdMw7J2@j zoUul|OZNWUgQD#2z_84XA-5$oa-VG51jofYe_(m`wo#kxZNjiMi%va0v~}>QkqwuY z$TfR=_wXCF4jve3y0k{FeT?p-`bNIdAIrWLyc+eXH~i6H*w}8IHW=Jb8S$WOnlpRr zM62Z578+2caTeLJ4lhxiW#`DB%eE%W@_&K1(brm70t zX0G$dmr6TPjKBHz)X7ik*RNfF&+4^zGlR_xkUFgNC|dQn$qLRfe%v^b1 zp`iMp@L{qGs?VlVqKdt6u|Jp!btyKkO}PuE21S@-r8r>JLeR@RE~1zu5s{$-v-MXO zW-d|jF#eOjLQ!z9>c3TgZTfO?;};dOqXoEAaaf(ZZ-?yI3DHdT;Hf)RGc<2-LH3E< zLyyUxeekF>)s%a1kL=wGC8D-^GWYNkvgb*(@+}y+Z)C?v(`P$A+VSx&+4m6U!|rxh z|8DqW4*#KM?j_RXXP}h)R64%edy^=|8rS<)*AI`F;8|$_6fHcW#RJO)yRHSp@vOU) z1F_l|%=Z!WE#lZBXn_|Of+bBEx+jPG2^rzA!jyP>tm0MzkO!%+&h!!KFl9IqgW93i=+^9=y4X$5aI(c7^9W; z(X?)*LJff23`l7mNl1hkq&N!29t2#W3=;8hETx#~3Q<;xaiB`j7tp`tGZCdSnTdrs z;n_@mEv#=O2ujLo5aA#atOY-2BQj03RM1;Zeu9D!IsdY65bX=m&+df z!Y6xM^yW}Anks`XWEWI8R^}dfLUue^bR@`WXc}E5yPCnJflEX-NB&H%YT|*-x@?o| zS;wQArIQWx95WEBTGM|)&CPLMtmq0AKNhTIivLv#CKVlK!Ge6)Y>duqVb!&;(HNBJ z(~YZIOr5L{fkbO}H$~KE9V&Wk>g+rjdxJx`r z6=%Fy9f-s`izr%*(Rpa7XF1P9`FS*(!UAFnafKoe^M|8}xRhe+zsH9~i}*{z&labj z)4Bo(yf+F0#Z1nty6Rb=06}?M!F49LTd6Eu0M<54RWS)-3c;L`9_}pX_CO~h9wA-^K8~9yVo$|a;#}+gX~y| zrr&BzXdV0G4?K9(}LsN(P3o*Ltn651aq^NOmagz4s87baJN5iJ5uE+VTh_nxSq2X&IWfG~DpAWT_%XofLavLb?!;iHCh8D+X1}$Ot1WBizVxJc()eghu4L-%C zM*(SM58ggUK>q0Qsop~cy<9Z|0h((5hOrt>d5G08O?@0=H1}AwWzT&QHEchA;?t|= zKP8$%ZVD$j-Xg(C(k6lqU}Xjg;^??J=Au|QDJlB@<^oQXN=Xcb69`qOq=#q%%)dbD z7@Kq;B9}s`WRZtJG}+0yikYT$eV+1j8jgzR@t-`4q5z-U=fKBReXR3TPuBnCEgLVj z$*x`C5^5@S`RF3q+01+fgd`Dx%(fv&>D~@Ct|nl8vV9Fpzfwq2b~fmZ0tm}q+3^?< z$6}gn0NK4B-GRU-up7d;`dSF22PloePf_z7tN=C#vHcLmL@C1{@KrJt2jn@r7vYAp zjv}?VUOf`B<-BXVi%}2fK5TEgI@khe6rZ(1F*6oY!6rRh>z9hK58*}<6|dnx`6LQj zwi_?|R?JzpR`>CmfqDH45C*?Hw9GH3j43` ztmD}Rw3#c6r}Q)I8ah z+HOL{tfPpy;f1PYV@Y`yvc6`I>yEfc#pr^};)W$`>msm8+ZvfE8b9%y{Lc?ho_;0& z)9mEg)A_#F5F}#h!9W6Sis&1_j2W-udF3hQlyeX<51faoSV&#c@tgi1@;~%~Q zlZi+S&007Xfa?x7ABkbG!34Q20{j-FK_g`9h%)e0^x0R&`;JXs9K_zE`n9Xq ztzFB9W-o&K@%(A}2J`2~$cQeZ8FF2*@P>5-g$txpRFLDr*`#I+ZIoY>!U^WAKaU2A z1=}9u5)!Z69$Exsh7lPry86XuqZoJ++ZlO=Z2b=$YL5W}ZQu$#$!1PniaigSMdP-}{xBd^bB`~W`lW|>Hjw#@1FU^UG6+2XY%C#kIIs)vPF63O;B+Lw|bMMV0 zco43e8)~--+D4jkp;|YZ#*u4uy0=!>qQH{zG}jKj*Qq*<=}D1v?=^c|uYP#Gs?%yR z3mO(G<~>qp3v&JGhw%m{uo2_@i<3Y7$;3~7v$1|^@P+)~JOBQ|Ygfl!zB>8_wkF^_ z|H@eY?YD8lcPijy;^>FySARElXJBuD)ES6jxPtEMv8w{wTtk+BqVH3FgPl!W2Wl=V zmls@A;w*fVGuvpxd59mvb&Y&t_3IneiPhiL$R}36p^;Cl{_aLTF{Zq}CKf&Zv)A%t zN0~yL!qPObl)}3Fv2)l)UB7nX-9z}~DF|vk`PsSrZx3^AOxO8ezBvAIRlAmjRT6PCP!nK@2(Q{UF>un=VmsZNQ-SDPCs_q^7 zUT%XZ+dDX`GH0tscF0wY&>GB>4(8Tv;kgBMMB_-8YP~( zA!jHbGX!*eW%(184EA3;hv{%2K`EkB?mu)51?{X__M85dJ#F6)uni+fJ0?2x^ zeZ7SY-%h+^#k_TM>(BzWP)XsNgx1(KHh=Kc#=?rTEtdiBFKss*mj)Hc*9cKpC# zPjO3KnwHcIOtSrx8|%l<9lbjGD-MvWqc2aqaX5eEv#X!?^9`YjlQ!_F8}o{u2cc9N zqvAY@X6bV}y`KuQuF`HQ?nA*yeBp-#>?aG%@N!2(G4cEXYlELYz#qZi zG(rWTufX(gZX=AOLQrw2+YDK5oh9GN_)1w6iZP5Zm_!M-8#ad*sWBD5!+-KK6a>jl zrG7o|?T}=-x21y{M=Ej;C1l?-K(lJoDvDOJz~H-N&qDyEYBRg=Qv};~*gRM`or(Mt z?HxTyOT#ku8x6&Z?FL9Kb&1i8?xz|RXu^VqY%39eQB2WMxSgvv($KV>A+d(G5ADZq z_9O?1YDmO?7PV$S`vo8KV;qfmGk58K_%NqMOOdIeqU1uxXS9S~+^f|p~0bMms8|ZU-qpfUh zUy7>MPA{^TH6Ft`KBkwTAUQk7Ak>|WWhrYMxNX{ikLem}FBICCXwVw<91Y70%+*QM@q8cOA z+pU?3Ma9<@3lFL~rh#sgV0Fak!x`!s#1}29=FS(IR^0+wc#R768civMiz~`VqMdV!oJGPL3e0gyJ4w5a&&vf7mAb*WU05o=v zAz1Me0=r-h8m^!MprIPHu8M|h(0Yc#R~w@a)#vwAW9<1CjR2(G`0SJN?Wyve#nRm?#q25#4Oi^t6gWr^#$7a+ zBC4*Cj64pL;NfCXTeV+fk5}uTGcd^2PV>H!~VcTZ(0;sX#Dva-0T|1Vh$H9?lRS zxFqZiq;A7}dshUHNx&%%$%^$kw*@NPlj8|unpPsIE3LX>C?lA)H8HFt$FYGDN-C9r z%CVMC)J#!ZDnr673L~t7bQ#DLx9&OdbL>#|joR(S_A%MBxsz)z^%tT8AgqgBduz7qx|G|ucA~#fH<0bwbttaA*jEUj-idMadSC9D z6|oS06_7~dEnzl&CzwNp$kA*s@U!Al(^Bky)0a)l2MUqSe_#(rAE56?za9N5N81LIrVPW|O@cc< z$zisuglL7`6S317yjMs&u!~hH4 literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/cost_model.cpython-312.pyc b/backend/app/models/__pycache__/cost_model.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6906bf67f5923035475988b58415439cfbbeb90a GIT binary patch literal 5347 zcmd5=TW}NS6<*zCU2IF1WMgu12v9@{q7*1Gm&Eu&z}N(^+f*6Q$l48-(Z&4Qbz}M> zrUXn<2UCY2Fs1eb6EV21l5~m_!h}v9N~bTH>2`{DrVnW>3BR?)OlR`cbN-cA>q`b` z`p}*6(eC%3|NOgW_k90%{3pBJ!oachqw1a)D;ef1yqG%-#ghjqIJwBMjGtjOtTv*F zYW>=%&aaE={rafEZ-^TG#;D0}ikkiAXqmq(YVlh%=&OrZqc*=y!)Te^468rIum;X{ zLnp`e+bL=U)TBboDQX6^OodiZ)B>ngg;w_4ymo^${FU+C!0G(ep?rEQclDjR zzbD_TD@=?PhE7j^cKQCq*#1M7{OFn7nbSqz!Lh>GGhZc7<|eMrTzabr-u}hR$Y}tl zZ(o_7yexzHk1wog*wyH@%;1fC%OqoCJR(G6l4(zj=ZsYN{uVpv`XJmD(Zd|V<(u64dnOVaPn1u&xWKH8{#iLB|C{uY@R2~+^ z!>aPIsXVNThdPE`57_FS4Kk*-H;NbIHOh=-k2m9y5tk`FO{1Mlar4CdY*D_lOMcF$iu z1s}0go+!o)gdk|5_+%#^g(TFCA^Bvy4K_sLMz$O{=m_N5N7suQ3}&-uPo*YJsSb9P&oSQ$$0 zk)hD=Pt%>*?XQZJ2LP&hCMBeW$(UHvmesqbtd8@mQ_kVFqkQHy(YiC)H055NYECy# z?iAg7lkHQk+SKOs=4}0sMVCL>HdXFA|HIU-VSm~)xm+x7PVSzvSDkN3Z5-a4HfQTw zMf<*F3qV6nqnkwg8aQ`Ur_7m}jO%92hczG9ijM8cy$i5A-FMhObpO8liyqN&;N)IX zKbMzzTZgh=_x%5E9qJ8g(bfS}X=Y2C#Qbdp)BOd-Z~InymcVD^wv?@z?RrL zINTmwJ!>Jy3l3!ya4VaDr{CkPmMje+9!k0q3HwEuv{+J5!k)hMZu7xcefhKRl)Ct+F7yz;XtG!O%sr045o#mAZX89nCP=iT z2F)@w%h5aqhVrAlK#@C;bS%+pl8cJYD!k80< zzUGo7)0S10GT^GXKtf!Jp2!xA^8+;aX5`Y@hG#qG3??e^M=(e(jrUwD7Lkim!0znU zS48_BDBAxe-*}!%b*4Kf+eJ@naz8{)txB(&d`7H#1sKa&lX9nF*&m6{*OIOCsH8)* zcP>RAa|D48IFBGOn|M6<=-Uy54hjp^C2mh}_`gYAS`}qO)g@;I-D(>zp;Rx@qPMFw zXSvFQ7CrSS5eI6=QVgmtxeB%tO5LffCu>JUt5hh}j?~wsLQ8tlt=xr&tyXyy^&;Gd zy9Yne1&2ZlC2d#@z6Q?qs2PtMA1`Uc8mbMg4P7DNns6WN;sTaG^d)kuGCle0+~+^b zO)XOm8d*rAx8N| zh33zlyZ?D+hJ~;cl?7Ms?R0Krlmhaptkeh!ZJ`28v9(IxlKm-jsND0(Td47nGm7&E zPS3nGSa|mgFcS>QvwZTH>`WPwJPR`v!4{6u$LA;|k>_Ek-Mj3`c*$N&V85)lN|z|O zWHD62SS}HgtW>?Q1e79?tnzOFrIyTN7Cz1wsa>)cNBgjo

Lu%6Jpp?UDqx6$ciu z*lxtoAZaGP1cNO0>{1fRReNd6=!%j0G&|XOw_S9-4q}C3&yQ@&xbKvUuKg5AZA!14 zgsa_f<*d2n8P$y}r*g>I1`I?I?MN*|X&0TXfPj86j2_Q!Y!n?$AYG^&dMVaOm1IZmh(zL{AP=fuz+Mm7qgb@G#_msKK$Xb7 z<MhTW{z8^MjQVu zeINrMSHM4Xu=b&+rT^b43JmuX6*6hSg|Xk0P%2RF48Z-A^oFc5iWM1(#}ZJIERd-D zl+e^*T`yUY3yQG=S3-k{NL&Pi#kl>RYt`dO3fSta$JS=oz5M4D4R@PG=Rv5K$CDYd zfWZF6a{v^C+@55&?GxQ?(4QQe;XOi5zSwj!+&88|BZQi*67e2)C_H!W$<@)O}S>rkZYEKpV<`^II?n< S!Qa`HRv2k|$iNR3d;bAY`U^V% literal 0 HcmV?d00001 diff --git a/backend/app/models/budget_plan.py b/backend/app/models/budget_plan.py new file mode 100644 index 00000000..9ac8e643 --- /dev/null +++ b/backend/app/models/budget_plan.py @@ -0,0 +1,21 @@ +"""预算计划模型 — 管理会计OS""" +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, func +from app.database import Base + + +class BudgetPlan(Base): + """预算计划 — 按KPI按月分解的目标值""" + __tablename__ = "budget_plans" + + id = Column(Integer, primary_key=True, index=True) + 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="预算值") + budget_year = Column(Integer, nullable=False, comment="预算年份") + 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") + remark = Column(String(500), nullable=True, comment="备注") + created_by = Column(String(100), nullable=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/cost_model.py b/backend/app/models/cost_model.py new file mode 100644 index 00000000..0d65ee82 --- /dev/null +++ b/backend/app/models/cost_model.py @@ -0,0 +1,74 @@ +"""成本分析模型 — 管理会计OS +标准成本卡片、实际成本归集、作业成本法(ABC) +""" +from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey, JSON, func +from app.database import Base + + +class StandardCost(Base): + """标准成本卡片 — 每项产品或服务的标准成本构成""" + __tablename__ = "standard_costs" + + id = Column(Integer, primary_key=True, index=True) + 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") + item_name = Column(String(200), nullable=False, comment="成本项目名称") + standard_quantity = Column(Float, nullable=False, comment="标准用量") + unit = Column(String(20), nullable=True, comment="单位") + standard_price = Column(Float, nullable=False, comment="标准单价") + standard_cost = Column(Float, nullable=False, comment="标准成本 = 用量×单价") + version = Column(String(20), default="v1.0", comment="版本号") + status = Column(String(20), default="active", comment="active/archived") + remark = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class ActualCost(Base): + """实际成本归集 — 从ERP/手工录入的实际成本""" + __tablename__ = "actual_costs" + + id = Column(Integer, primary_key=True, index=True) + 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="产品/服务名称") + cost_type = Column(String(20), nullable=False, comment="成本类型: material/labor/overhead") + item_name = Column(String(200), nullable=False, comment="成本项目名称") + actual_quantity = Column(Float, nullable=False, comment="实际用量") + actual_price = Column(Float, nullable=False, comment="实际单价") + actual_cost = Column(Float, nullable=False, comment="实际成本 = 用量×单价") + source = Column(String(50), default="manual", comment="数据来源: erp/manual") + created_at = Column(DateTime, server_default=func.now()) + + +class AbcActivity(Base): + """ABC作业中心定义""" + __tablename__ = "abc_activities" + + id = Column(Integer, primary_key=True, index=True) + 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="作业描述") + cost_driver = Column(String(100), nullable=False, comment="成本动因") + driver_unit = Column(String(50), nullable=True, comment="动因单位") + total_cost = Column(Float, default=0, comment="作业总成本") + driver_volume = Column(Float, default=0, comment="动因总量") + driver_rate = Column(Float, default=0, 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 AbcAllocation(Base): + """ABC成本分配记录 — 按动因分配到产品""" + __tablename__ = "abc_allocations" + + id = Column(Integer, primary_key=True, index=True) + 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="产品/服务编码") + product_name = Column(String(200), nullable=False, comment="产品/服务名称") + driver_consumed = Column(Float, nullable=False, comment="消耗的动因量") + allocated_cost = Column(Float, nullable=False, comment="分配的成本") + created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/utils/__pycache__/__init__.cpython-312.pyc b/backend/app/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74699bb382c49cd8df4a40a57ded7e127c625e53 GIT binary patch literal 147 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 literal 0 HcmV?d00001 diff --git a/backend/app/utils/__pycache__/calc_engine.cpython-312.pyc b/backend/app/utils/__pycache__/calc_engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aab77dddf95689386c14b3c75c1fc11a78dc2fab GIT binary patch literal 12611 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 literal 0 HcmV?d00001 diff --git a/backend/app/utils/__pycache__/deviation_engine.cpython-312.pyc b/backend/app/utils/__pycache__/deviation_engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95b86a8c2abd696fa3170d71af321e238cfd6990 GIT binary patch literal 12407 zcmd5?dvp`mnV->PGLi0*0Qyoj>xHqJ?Qle2r;!*=%%3DH=YL(h75@q;}*r>5?nHu-D6Z$=}_ z#(A{ep6=MX_s+fF{pQY{@BY5Wz5lA$YY4brdpo~7=RSh?FT9a1p#^T{eoYd@MS>wZ z2!>>2F0zA!T<(&&WgRjS>l7}zThXBq%1W2gO?6O0nR2PzbO-HLcc|T(4vkyep(P0! z!IIqt(nIERM)f8<2d@r2L$d}(4L=QQ?4%g&(~1rgQ_ARAGoxp-7z6w?tb#QZ0(B?N z7@t;lWT#u_TPVf^J##WzkeeCma+xxrHVZgq3*3yrCkOcCG0W2Qx$rz30nOw=OFoPM zpRQreoeCx&Y6{pwrr=~XL2!=>Z?#Ss{(=PFwP8VpaKjY7RrIDztj8IF-qf2ZGAZvU zQDc1pTauoaQn)W;irLav2&Uw+T&gDtwxoOOy-$%ikwl-YPgePrRHun2<%fyWq}AfD zhfgv2-nGdOzjNmYPtUylqsb31Oug{Di)@D$+sn-4?Ma4y%1rRnFN`OSv|&~+_z4G+T8YNwxn z2eQn5&|F_*nfd9t$!9-;mzf+JoBG?})Zd>2els7vI{m@hZBrvxZjYUt`e3N`_SmzN z&!u>-1D+YLKl9p;CeIH+r#r8FZz>d?3`M7(k4}vYi0?7|;}51kxPpVZgCC*IO7b*g z_pv^wo5k|pUZ1naV|PK>+{OBAUe@b{3Y)9P0hJ0~zw6P~CbrY*aUva0!|frv%g+j> zMiPMk z1F``8fOiwr%aNy*@)yHn#SbaX>gCDr52n9k8;66n%6U`zwQP2GkKg0t4f8b|Oet@0 zdTl+YSk5NC?H%lRhr!+?ujHT$V^wg7TpWy!;HeZF1$Or0G+5yJd}A%w)8ng!iCp8h zd+c4ToAvl=kJ}w5Sr1cd@9nMi`1)C;HDw0KI$s$Xn?$Rpgt)rl~lA41jz&oAnb=sT^PxZ2#vxnhT zFdZmQ4tzc7qM#sr@$U=Z9U!{XUsfU^2wz%8Bq-^Iuetb|r|L$oi>?5PQj|eMFCu{3 zfFNlZG?Hq`AY}wTZ{nEY>Q@BhTtz_f730x~)R}2Oj^mNu2lyzujhXFYIb$rQdt1A+ z@2!)-RN~y7`^7Z<%7Bv71(e-+_tG377#XYyxqlV3%{>3!*LhGfTud0E+5Nrs0q0up~ma8`}|%G z*Bnpxa11V%K3?PRMNDFBb|0@`Sg(Uub~-t)kJpJS$#x1M$4c=s=J-4}feTlLtQ5Tf zK34Zs7%#1jm)1tt$4fUROq)W= zq}FtP%fObARpUjq@uJ%CqIL12b%~;egm(S7_My1;p@ep8u<29F^3b|*ZAn~P5>wQS zE{BA}b?Zop925xx#a~!975{IRjRe2l2xyD03^86}DJ&)U_lU815lgt0X!_-_2z3E@ zch>7eNtR=SPSkTL zIaP2g7Ifz2AirsV&ydYjFG)7nKrb}Nye>#UHZ{8Q)dSTpKA6yyjB6_68jwcQdE@4;U8xR6r4Y6acZ!mzK4FY$=Fk(iSbFK&g

q>LLNw) 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 X@wpdmn}6Drv%Q@7=kl!W_45A(hWrqQ literal 0 HcmV?d00001 diff --git a/backend/app/utils/__pycache__/notifier.cpython-312.pyc b/backend/app/utils/__pycache__/notifier.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8c7307a1c29aa21a52f20e15e2d056f93030685 GIT binary patch literal 12061 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 literal 0 HcmV?d00001 diff --git a/backend/app/utils/__pycache__/predict_engine.cpython-312.pyc b/backend/app/utils/__pycache__/predict_engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de0015bbe536053b272e1d27f5604fa5c2c628dd GIT binary patch literal 8363 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 UFk5d str: + """生成统一格式的缓存key: cma:cache:{module}:{hash}""" + h = hashlib.md5(key.encode()).hexdigest()[:16] + return f"cma:cache:{module}:{h}" + + +def get(module: str, key: str) -> Optional[Any]: + """获取缓存""" + if not _available: + return None + try: + full_key = _make_key(module, key) + data = _client.get(full_key) + if data: + return json.loads(data) + return None + except Exception as e: + logger.warning(f"缓存读取失败 [{module}]: {e}") + return None + + +def set(module: str, key: str, value: Any, ttl_seconds: int = 300) -> bool: + """写入缓存,默认5分钟""" + if not _available: + return False + try: + full_key = _make_key(module, key) + _client.setex(full_key, ttl_seconds, json.dumps(value, ensure_ascii=False)) + return True + except Exception as e: + logger.warning(f"缓存写入失败 [{module}]: {e}") + return False + + +def delete(module: str, key: str = None) -> bool: + """删除缓存。不传key则清空该模块所有缓存""" + if not _available: + return False + try: + if key: + full_key = _make_key(module, key) + _client.delete(full_key) + else: + pattern = f"cma:cache:{module}:*" + cursor = 0 + while True: + cursor, keys = _client.scan(cursor=cursor, match=pattern, count=100) + if keys: + _client.delete(*keys) + if cursor == 0: + break + return True + except Exception as e: + logger.warning(f"缓存删除失败 [{module}]: {e}") + return False + + +def clear_all() -> bool: + """清空所有CMA缓存""" + if not _available: + return False + try: + cursor = 0 + while True: + cursor, keys = _client.scan(cursor=cursor, match="cma:cache:*", count=200) + if keys: + _client.delete(*keys) + if cursor == 0: + break + return True + except Exception as e: + logger.warning(f"缓存清空失败: {e}") + return False diff --git a/backend/app/utils/calc_engine.py b/backend/app/utils/calc_engine.py new file mode 100644 index 00000000..5cd20ba7 --- /dev/null +++ b/backend/app/utils/calc_engine.py @@ -0,0 +1,93 @@ +"""KPI计算引擎 v4 — 基于会计科目余额和销售报表""" +import httpx, asyncio +from datetime import datetime +from app.database import get_session_local +from app.models import KPIDefinition, KPIValue + +ERP_API = "http://127.0.0.1:8300" +ERP_KEY = "erp-gateway-key-bhwl-2026" + +async def _get(url: str, params: dict = None): + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(url, headers={"X-API-Key": ERP_KEY}, params=params) + return r.json() + +async def calculate_all(): + db = get_session_local()() + try: + now = datetime.now() + period = f"{now.year}-{now.month:02d}" + + # 1. 从科目余额表取数据(BalanceInfo) + balance_data = await _get(f"{ERP_API}/api/v1/query", {"table": "BalanceInfo", "limit": 200}) + balances = balance_data.get("data", []) + + # 按科目和期间汇总 + revenue = 0 # 营业收入 (Act_ID=4) + cost = 0 # 营业成本 (Act_ID=5) + ar_balance = 0 # 应收账款 (Act_ID=3) + inv_balance = 0 # 库存商品 (Act_ID=2) + + for b in balances: + aid = b.get("Act_ID") + tot = float(b.get("Act_Tot", 0) or 0) + hap = float(b.get("Act_Hap", 0) or 0) + if aid == 4: # 营业收入(本期发生额更准确) + revenue += hap if hap > 0 else tot + elif aid == 5: # 营业成本 + cost += hap if hap > 0 else tot + elif aid == 3: # 应收账款余额 + ar_balance = tot + elif aid == 2: # 存货余额 + inv_balance = tot + + # 2. 从销售总览取数据 + summary = await _get(f"{ERP_API}/api/v1/stats/sales-summary", {"year": now.year}) + s = summary.get("summary", {}) + total_sales = s.get("total_amount", 0) + total_customers = s.get("customer_count", 0) + + # 3. 前5客户集中度 + top_customers = await _get(f"{ERP_API}/api/v1/stats/customer-top", {"year": now.year, "limit": 5}) + top5_amt = sum(c["amount"] for c in top_customers.get("data", [])) + top5_ratio = round(top5_amt / total_sales * 100, 1) if total_sales > 0 else 0 + + # 4. 计算KPI + gross_margin = round((revenue - cost) / revenue * 100, 2) if revenue > 0 else 0 + ar_turnover = round(revenue / ar_balance, 2) if ar_balance > 0 else 0 + inv_turnover = round(cost / inv_balance, 2) if inv_balance > 0 else 0 + + kpi_values = { + "SALES_TOTAL": total_sales, + "CUSTOMER_COUNT": total_customers, + "SALES_PROFIT_RATE": gross_margin, + "RECEIVABLE_TURNOVER": ar_turnover, + "TURNOVER_RATE": inv_turnover, + "TOP5_CUSTOMER_RATIO": top5_ratio, + } + + for code, value in kpi_values.items(): + kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first() + if kpi and (value > 0 or kpi.kpi_code in ("SALES_PROFIT_RATE","RECEIVABLE_TURNOVER","TURNOVER_RATE")): + existing = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, + KPIValue.period == period, + KPIValue.source_type == "erp", + ).first() + if not existing: + kv = KPIValue(kpi_id=kpi.id, period=period, actual_value=round(value, 2), source_type="erp", data_status="verified") + db.add(kv) + + db.commit() + print(f"✅ KPI计算完成: {period}") + for code, value in kpi_values.items(): + kpi_n = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first() + n = kpi_n.kpi_name if kpi_n else code + print(f" {n}: {round(value,2) if value else '-'}") + except Exception as e: + print(f"❌ KPI计算失败: {e}") + finally: + db.close() + +if __name__ == "__main__": + asyncio.run(calculate_all()) diff --git a/backend/app/utils/cost_engine.py b/backend/app/utils/cost_engine.py new file mode 100644 index 00000000..ac53112c --- /dev/null +++ b/backend/app/utils/cost_engine.py @@ -0,0 +1,282 @@ +"""成本分析引擎 — 管理会计OS +标准成本vs实际成本差异分析(量差/价差/效率差异) +ABC作业成本法分配 +""" +import logging +from datetime import datetime +from typing import Optional, List, Dict +from app.database import get_session_local +from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation, KPIDefinition, KPIValue + +logger = logging.getLogger("cma.cost") + +ERP_API = "http://127.0.0.1:8300" +ERP_KEY = "erp-gateway-key-bhwl-2026" + + +# ============================================================ +# 差异计算 +# ============================================================ + +def calc_variance(standard_qty: float, actual_qty: float, + standard_price: float, actual_price: float) -> dict: + """计算量差和价差 + + 量差 = (实际用量 - 标准用量) × 标准价格 + 价差 = (实际价格 - 标准价格) × 实际用量 + 总差异 = 量差 + 价差 + """ + qty_variance = round((actual_qty - standard_qty) * standard_price, 2) + price_variance = round((actual_price - standard_price) * actual_qty, 2) + total_variance = round(qty_variance + price_variance, 2) + return { + "qty_variance": qty_variance, # 量差 + "price_variance": price_variance, # 价差 + "total_variance": total_variance, # 总差异 + "standard_cost": round(standard_qty * standard_price, 2), + "actual_cost": round(actual_qty * actual_price, 2), + } + + +def calc_efficiency_variance(standard_hours: float, actual_hours: float, + standard_rate: float) -> dict: + """计算效率差异(人工/制造费用) + + 效率差异 = (实际工时 - 标准工时) × 标准分配率 + 分配率差异 = (实际分配率 - 标准分配率) × 实际工时 + """ + eff = round((actual_hours - standard_hours) * standard_rate, 2) + # 假设实际分配率从外面传入 + return { + "efficiency_variance": eff, + } + + +# ============================================================ +# 产品级差异分析 +# ============================================================ + +def calc_product_variance(product_code: str, period: str) -> dict: + """计算指定产品在指定期间的成本差异""" + db = get_session_local()() + try: + standards = db.query(StandardCost).filter( + StandardCost.product_code == product_code, + StandardCost.status == "active", + ).all() + actuals = db.query(ActualCost).filter( + ActualCost.product_code == product_code, + ActualCost.period == period, + ).all() + + if not standards or not actuals: + return {"product_code": product_code, "error": "标准成本或实际成本数据不足", "items": [], "summary": {}} + + # 按 cost_type 分组 + cost_types = set() + for s in standards: cost_types.add(s.cost_type) + for a in actuals: cost_types.add(a.cost_type) + + items = [] + total_std = 0 + total_act = 0 + total_qty_var = 0 + total_price_var = 0 + + for ct in sorted(cost_types): + std_items = [s for s in standards if s.cost_type == ct] + act_items = [a for a in actuals if a.cost_type == ct] + + if std_items and act_items: + s = std_items[0] + a = act_items[0] + var = calc_variance(s.standard_quantity, a.actual_quantity, + s.standard_price, a.actual_price) + items.append({ + "cost_type": ct, + "item_name": s.item_name, + "standard_quantity": s.standard_quantity, + "actual_quantity": a.actual_quantity, + "standard_price": s.standard_price, + "actual_price": a.actual_price, + "standard_cost": var["standard_cost"], + "actual_cost": var["actual_cost"], + "qty_variance": var["qty_variance"], + "price_variance": var["price_variance"], + "total_variance": var["total_variance"], + }) + total_std += var["standard_cost"] + total_act += var["actual_cost"] + total_qty_var += var["qty_variance"] + total_price_var += var["price_variance"] + + return { + "product_code": product_code, + "period": period, + "items": items, + "summary": { + "total_standard_cost": round(total_std, 2), + "total_actual_cost": round(total_act, 2), + "total_variance": round(total_act - total_std, 2), + "total_qty_variance": round(total_qty_var, 2), + "total_price_variance": round(total_price_var, 2), + "variance_rate": round((total_act - total_std) / total_std * 100, 2) if total_std else 0, + } + } + finally: + db.close() + + +# ============================================================ +# ABC 作业成本分配 +# ============================================================ + +def calc_driver_rate(activity_id: int) -> dict: + """计算作业动因分配率 = 总成本 / 动因总量""" + db = get_session_local()() + try: + act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first() + if not act or not act.driver_volume: + return {"error": "作业中心不存在或动因总量为0"} + rate = round(act.total_cost / act.driver_volume, 4) if act.driver_volume > 0 else 0 + act.driver_rate = rate + db.commit() + return { + "activity_code": act.activity_code, + "activity_name": act.activity_name, + "total_cost": act.total_cost, + "driver_volume": act.driver_volume, + "driver_rate": rate, + } + finally: + db.close() + + +def allocate_cost(activity_id: int, period: str, product_code: str, + product_name: str, driver_consumed: float) -> dict: + """按动因分配成本到产品""" + db = get_session_local()() + try: + act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first() + if not act or act.driver_rate == 0: + # 自动计算分配率 + if act and act.driver_volume > 0: + act.driver_rate = round(act.total_cost / act.driver_volume, 4) + db.commit() + if not act or act.driver_rate == 0: + return {"error": "分配率未设置"} + allocated = round(driver_consumed * act.driver_rate, 2) + alloc = AbcAllocation( + period=period, + activity_id=activity_id, + product_code=product_code, + product_name=product_name, + driver_consumed=driver_consumed, + allocated_cost=allocated, + ) + db.add(alloc) + db.commit() + return { + "activity_code": act.activity_code, + "product_code": product_code, + "driver_consumed": driver_consumed, + "driver_rate": act.driver_rate, + "allocated_cost": allocated, + } + finally: + db.close() + + +# ============================================================ +# 成本总览数据 +# ============================================================ + +def get_cost_overview(period: str) -> dict: + """获取成本总览数据(总成本、结构占比、趋势)""" + db = get_session_local()() + try: + # 从实际成本表汇总 + actual_costs = db.query(ActualCost).filter(ActualCost.period == period).all() + total_cost = sum(a.actual_cost for a in actual_costs) + + # 按成本类型分组 + by_type: Dict[str, float] = {} + for a in actual_costs: + by_type[a.cost_type] = by_type.get(a.cost_type, 0) + a.actual_cost + + structure = [ + {"cost_type": k, "amount": round(v, 2), "ratio": round(v / total_cost * 100, 1) if total_cost else 0} + for k, v in sorted(by_type.items()) + ] + + # 从ERP获取成本数据做补充 + import httpx + try: + resp = httpx.get(f"{ERP_API}/api/v1/query", params={"table": "BalanceInfo", "limit": 100}, + headers={"X-API-Key": ERP_KEY}, timeout=10) + balance_data = resp.json().get("data", []) + erp_cost = 0 + for b in balance_data: + if b.get("Act_ID") == 5: # 营业成本 + erp_cost += float(b.get("Act_Hap", 0) or 0) + float(b.get("Act_Tot", 0) or 0) + except Exception: + erp_cost = 0 + + # 历史趋势(近6个月) + from sqlalchemy import text + year = period[:4] + months_texts = [] + try: + m = int(period.split("-")[1]) + for i in range(6): + pm = m - i + py = int(year) + while pm <= 0: + pm += 12 + py -= 1 + months_texts.append(f"{py}-{pm:02d}") + + trend = [] + for p in reversed(months_texts): + costs = db.query(ActualCost).filter(ActualCost.period == p).all() + total = round(sum(c.actual_cost for c in costs), 2) + trend.append({"period": p, "total_cost": total}) + except Exception: + trend = [] + + return { + "period": period, + "total_cost": round(total_cost + erp_cost, 2), + "erp_cost": round(erp_cost, 2), + "manual_cost": round(total_cost, 2), + "structure": structure, + "trend": trend, + } + finally: + db.close() + + +def get_cost_breakdown(product_code: str, period: str) -> dict: + """获取成本构成(料/工/费占比)""" + db = get_session_local()() + try: + actuals = db.query(ActualCost).filter( + ActualCost.product_code == product_code, + ActualCost.period == period, + ).all() + + material = sum(a.actual_cost for a in actuals if a.cost_type == "material") + labor = sum(a.actual_cost for a in actuals if a.cost_type == "labor") + overhead = sum(a.actual_cost for a in actuals if a.cost_type == "overhead") + total = material + labor + overhead + + return { + "product_code": product_code, + "period": period, + "material": {"amount": round(material, 2), "ratio": round(material / total * 100, 1) if total else 0}, + "labor": {"amount": round(labor, 2), "ratio": round(labor / total * 100, 1) if total else 0}, + "overhead": {"amount": round(overhead, 2), "ratio": round(overhead / total * 100, 1) if total else 0}, + "total": round(total, 2), + } + finally: + db.close() diff --git a/backend/app/utils/deviation_engine.py b/backend/app/utils/deviation_engine.py new file mode 100644 index 00000000..c3230054 --- /dev/null +++ b/backend/app/utils/deviation_engine.py @@ -0,0 +1,311 @@ +"""差异预警引擎 — 管理会计OS +实际 vs 预算/目标对比,超阈值自动推送预警 + +功能: + 1. 实际 vs 预算差异计算(差异额/差异率) + 2. 同比/环比差异计算 + 3. 趋势异常检测(连续N期下滑/上升) + 4. 差异预警触发(集成到现有预警系统) +""" +import logging +from datetime import datetime +from typing import Optional + +from app.database import get_session_local +from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan + +logger = logging.getLogger("cma.deviation") + + +# ============================================================ +# 差异计算 +# ============================================================ + +def calc_deviation(actual: float, budget: float) -> dict: + """计算差异额和差异率""" + if budget is None or budget == 0: + return { + "deviation_amount": None, + "deviation_rate": None, + "is_over_budget": None, + } + amount = round(actual - budget, 2) + rate = round(amount / budget * 100, 2) + return { + "deviation_amount": amount, + "deviation_rate": rate, + "is_over_budget": amount > 0, + } + + +def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]: + """获取指定KPI在指定期间的预算值""" + query = db.query(BudgetPlan).filter( + BudgetPlan.kpi_id == kpi_id, + BudgetPlan.period == period, + BudgetPlan.status == "active", + ) + if version: + query = query.filter(BudgetPlan.version == version) + plan = query.order_by(BudgetPlan.updated_at.desc()).first() + return plan.budget_value if plan else None + + +def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]: + """获取指定KPI在指定期间的实际值""" + val = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi_id, + KPIValue.period == period, + ).order_by(KPIValue.calculated_at.desc()).first() + return val.actual_value if val else None + + +def calc_period_deviation(db, kpi_id: int, period: str) -> dict: + """单KPI单期的差异计算""" + actual = get_actual_for_kpi(db, kpi_id, period) + budget = get_budget_for_kpi(db, kpi_id, period) + + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + kpi_code = kpi.kpi_code if kpi else "unknown" + + # 如果没预算值,用 target_value 作为替代 + if budget is None and kpi: + # 尝试把年度目标按月均分 + month = int(period.split("-")[1]) + target = kpi.target_value + if target and target > 0 and kpi.frequency == "monthly": + budget = round(target / 12, 2) + + deviation = calc_deviation(actual, budget) if actual is not None else None + + result = { + "kpi_id": kpi_id, + "kpi_code": kpi_code, + "period": period, + "actual_value": actual, + "budget_value": budget, + } + if deviation: + result.update(deviation) + return result + + +# ============================================================ +# 同比/环比差异 +# ============================================================ + +def calc_period_diff(db, kpi_id: int, current_period: str, diff_type: str = "yoy") -> dict: + """计算同比(上年同期)或环比(上期)差异""" + year, month = current_period.split("-") + y, m = int(year), int(month) + + if diff_type == "yoy": + # 同比:上年同期 + prev_period = f"{y-1}-{m:02d}" + label = "同比" + elif diff_type == "mom": + # 环比:上个月 + prev_m = m - 1 + prev_y = y + if prev_m <= 0: + prev_m += 12 + prev_y -= 1 + prev_period = f"{prev_y}-{prev_m:02d}" + label = "环比" + else: + return {"error": f"未知比较类型: {diff_type}"} + + current = get_actual_for_kpi(db, kpi_id, current_period) + previous = get_actual_for_kpi(db, kpi_id, prev_period) + + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + kpi_code = kpi.kpi_code if kpi else "unknown" + + if current is None or previous is None or previous == 0: + return { + "kpi_id": kpi_id, + "kpi_code": kpi_code, + "type": diff_type, + "label": label, + "current_period": current_period, + "prev_period": prev_period, + "current_value": current, + "prev_value": previous, + "diff_amount": None, + "diff_rate": None, + } + + diff_amount = round(current - previous, 2) + diff_rate = round(diff_amount / previous * 100, 2) + return { + "kpi_id": kpi_id, + "kpi_code": kpi_code, + "type": diff_type, + "label": label, + "current_period": current_period, + "prev_period": prev_period, + "current_value": current, + "prev_value": previous, + "diff_amount": diff_amount, + "diff_rate": diff_rate, + } + + +# ============================================================ +# 趋势检测 +# ============================================================ + +def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> dict: + """检测连续N期下滑或上升的趋势异常""" + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + return {"anomaly": False} + + # 获取包括当前期在内的近期数据 + year, month = period.split("-") + y, m = int(year), int(month) + + values = [] + for i in range(consecutive + 2): # 多取2期做参考 + p = f"{y}-{m:02d}" + v = get_actual_for_kpi(db, kpi_id, p) + if v is not None: + values.append({"period": p, "value": v}) + m -= 1 + if m <= 0: + m += 12 + y -= 1 + + values.reverse() # 按时间正序 + if len(values) < consecutive: + return {"anomaly": False, "reason": "数据不足"} + + last_n = values[-consecutive:] + all_decreasing = all(last_n[i]["value"] > last_n[i + 1]["value"] for i in range(len(last_n) - 1)) + all_increasing = all(last_n[i]["value"] < last_n[i + 1]["value"] for i in range(len(last_n) - 1)) + + if all_decreasing: + return { + "anomaly": True, + "type": "continuous_decline", + "level": "yellow" if consecutive >= 3 else "green", + "periods": [v["period"] for v in last_n], + "values": [v["value"] for v in last_n], + "message": f"{kpi.kpi_name} 连续{consecutive}期下滑", + } + if all_increasing: + return { + "anomaly": True, + "type": "continuous_rise", + "level": "yellow" if consecutive >= 3 else "green", + "periods": [v["period"] for v in last_n], + "values": [v["value"] for v in last_n], + "message": f"{kpi.kpi_name} 连续{consecutive}期上升(可能过热)", + } + + return {"anomaly": False} + + +# ============================================================ +# 差异预警触发 +# ============================================================ + +def run_deviation_check(db_session, period: str = None) -> int: + """运行差异预警检查,返回新增预警数""" + if period is None: + period = datetime.now().strftime("%Y-%m") + + kpis = db_session.query(KPIDefinition).filter( + KPIDefinition.status == "active" + ).all() + + new_count = 0 + for kpi in kpis: + # 1. 差异预警:实际 vs 预算 + deviation = calc_period_deviation(db_session, kpi.id, period) + if deviation.get("deviation_rate") is not None: + rate = abs(deviation["deviation_rate"]) + + # 差异化阈值:越高越好型 vs 越低越好型 + higher_better = kpi.kpi_code in [ + "SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE", + "RECEIVABLE_TURNOVER", "TURNOVER_RATE", + "CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE", + ] + + if higher_better: + # 实际低于预算才是问题 + if deviation["actual_value"] < deviation["budget_value"] and rate >= 10: + level = "yellow" if rate >= 10 else "green" + level = "red" if rate >= 30 else level + else: + continue + else: + # 实际高于预算才是问题(成本型) + if deviation["actual_value"] > deviation["budget_value"] and rate >= 10: + level = "yellow" if rate >= 10 else "green" + level = "red" if rate >= 30 else level + else: + continue + + alert_msg = ( + f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} " + f"vs 预算{deviation['budget_value']}," + f"差异率{deviation['deviation_rate']}%" + ) + + # 检查是否已有同KPI同期间的差异预警 + existing = db_session.query(KPIAlert).filter( + KPIAlert.kpi_id == kpi.id, + KPIAlert.alert_message.contains("[差异预警]"), + KPIAlert.alert_message.contains(period), + KPIAlert.status.in_(["pending", "processing"]), + ).first() + + if not existing: + alert = KPIAlert( + kpi_id=kpi.id, + alert_level=level, + alert_message=f"[差异预警] {alert_msg}", + status="pending", + ) + db_session.add(alert) + new_count += 1 + logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%") + + # 2. 趋势异常检测(每期检查连续3期) + trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3) + if trend.get("anomaly") and trend.get("level") in ("yellow", "red"): + trend_alert_msg = trend["message"] + existing_trend = db_session.query(KPIAlert).filter( + KPIAlert.kpi_id == kpi.id, + KPIAlert.alert_message.contains("[趋势预警]"), + KPIAlert.status.in_(["pending", "processing"]), + ).first() + + if not existing_trend: + alert = KPIAlert( + kpi_id=kpi.id, + alert_level=trend["level"], + alert_message=f"[趋势预警] {trend_alert_msg}", + status="pending", + ) + db_session.add(alert) + new_count += 1 + logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}") + + db_session.commit() + return new_count + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + db = get_session_local()() + try: + n = run_deviation_check(db) + print(f"差异预警检查完成: 新增 {n} 条") + finally: + db.close() diff --git a/backend/app/utils/notifier.py b/backend/app/utils/notifier.py new file mode 100644 index 00000000..d0a202d3 --- /dev/null +++ b/backend/app/utils/notifier.py @@ -0,0 +1,251 @@ +""" +预警通知推送模块 — 管理会计OS +支持渠道:企业微信 (群机器人/应用消息)、邮件 +""" + +import json +import logging +import urllib.request +import urllib.error +import smtplib +from email.mime.text import MIMEText +from email.header import Header +from datetime import datetime +from typing import Optional + +logger = logging.getLogger("cma.notifier") + +# ============================================================ +# 企业微信机器人推送 +# ============================================================ + +def send_wecom_robot(webhook_url: str, title: str, content: str, alert_level: str = "yellow") -> dict: + """通过企业微信群机器人发送告警""" + color_tag = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(alert_level, "⚪") + msg = { + "msgtype": "markdown", + "markdown": { + "content": f"## {color_tag} 管理会计OS预警通知\n" + f"**{title}**\n\n" + f"{content}\n\n" + f"---\n" + f"⏰ {datetime.now().strftime('%Y-%m-%d %H:%M')}" + } + } + data = json.dumps(msg).encode("utf-8") + req = urllib.request.Request(webhook_url, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read().decode()) + if result.get("errcode") == 0: + return {"success": True, "message": "已推送至企业微信群"} + else: + return {"success": False, "message": f"推送失败: {result.get('errmsg', '未知错误')}"} + except Exception as e: + return {"success": False, "message": f"推送异常: {str(e)}"} + + +# ============================================================ +# 企业微信应用消息推送(通过自建应用 message/send API) +# ============================================================ + +def send_wecom_app(corp_id: str, corp_secret: str, agent_id: str, + touser: str, title: str, content: str, alert_level: str = "yellow") -> dict: + """通过企微自建应用发送应用消息""" + import requests + try: + token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corp_id}&corpsecret={corp_secret}" + r = requests.get(token_url, timeout=10) + token_data = r.json() + if token_data.get("errcode") != 0: + return {"success": False, "message": f"获取token失败: {token_data.get('errmsg', '')}"} + access_token = token_data["access_token"] + + color_tag = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(alert_level, "⚪") + md = "## " + color_tag + " 管理会计OS预警通知\n\n" + md += "**" + title + "**\n\n" + md += content + "\n\n---\n" + md += "⏰ " + datetime.now().strftime("%Y-%m-%d %H:%M") + + payload = { + "touser": touser, + "msgtype": "markdown", + "agentid": int(agent_id), + "markdown": {"content": md}, + "safe": 0, + } + send_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + access_token + r2 = requests.post(send_url, json=payload, timeout=10) + send_data = r2.json() + if send_data.get("errcode") == 0: + return {"success": True, "message": f"已推送到企微用户 {touser}"} + else: + return {"success": False, "message": f"推送失败: {send_data.get('errmsg', '')}"} + except Exception as e: + return {"success": False, "message": f"推送异常: {str(e)}"} + + +# ============================================================ +# 邮件推送 +# ============================================================ + +def send_mail(smtp_config: dict, to_addrs: list, title: str, content: str) -> dict: + """通过 SMTP 发送邮件告警""" + try: + msg = MIMEText(content, "plain", "utf-8") + msg["Subject"] = Header(f"[管理会计OS预警] {title}", "utf-8") + msg["From"] = smtp_config.get("from_addr", "") + msg["To"] = ", ".join(to_addrs) + + host = smtp_config.get("host", "smtp.qq.com") + port = int(smtp_config.get("port", 465)) + user = smtp_config.get("user", "") + password = smtp_config.get("password", "") + use_ssl = smtp_config.get("use_ssl", True) + + if use_ssl: + server = smtplib.SMTP_SSL(host, port, timeout=10) + else: + server = smtplib.SMTP(host, port, timeout=10) + server.starttls() + + server.login(user, password) + server.sendmail(user, to_addrs, msg.as_string()) + server.quit() + return {"success": True, "message": f"已发送邮件至 {', '.join(to_addrs)}"} + except Exception as e: + return {"success": False, "message": f"邮件发送失败: {str(e)}"} + + +# ============================================================ +# 主推送函数 +# ============================================================ + +def push_alert(alert: dict, channels: list[dict]) -> list[dict]: + """向所有已启用渠道推送一条预警""" + results = [] + alert_level = alert.get("alert_level", "yellow") + title = alert.get("alert_message", "预警通知") + content = _build_content(alert) + + for ch in channels: + if not ch.get("enabled", True): + continue + + ch_type = ch.get("channel_type", "") + config = ch.get("config", {}) + result = {"channel": ch_type, "channel_name": ch.get("name", ""), "success": False} + + if ch_type == "wecom": + webhook = config.get("webhook_url", "") + if webhook: + result = send_wecom_robot(webhook, title, content, alert_level) + result["channel"] = "wecom" + + elif ch_type == "wecom_app": + result = send_wecom_app( + corp_id=config.get("corp_id", ""), + corp_secret=config.get("corp_secret", ""), + agent_id=config.get("agent_id", ""), + touser=config.get("touser", ""), + title=title, content=content, alert_level=alert_level, + ) + result["channel"] = "wecom_app" + + elif ch_type == "mail": + to_list = config.get("to", []) + if to_list: + result = send_mail(config, to_list, title, content) + result["channel"] = "mail" + + results.append({**result, "channel_name": ch.get("name", "")}) + + return results + + +def _build_content(alert: dict) -> str: + """构建预警详情内容""" + parts = [ + f"KPI: {alert.get('kpi_name', '未知')}", + f"期间: {alert.get('period', '')}", + f"实际值: {alert.get('actual_value', '-')}", + f"目标值: {alert.get('target_value', '-')}", + f"预警级别: {'🔴 紧急' if alert.get('alert_level') == 'red' else '🟡 警告'}", + ] + if alert.get("resolution"): + parts.append(f"处理建议: {alert['resolution']}") + return "\n".join(parts) + + +# ============================================================ +# 从数据库加载渠道配置并推送待处理预警 +# ============================================================ + +def push_pending_alerts(db_session) -> int: + """推送所有待处理预警""" + from app.models import NotificationChannel, NotificationLog, KPIAlert, KPIDefinition, KPIValue + + # 加载已启用的通知渠道 + channels = db_session.query(NotificationChannel).filter( + NotificationChannel.enabled == True + ).all() + + if not channels: + logger.info("无已启用的通知渠道,跳过推送") + return 0 + + # 查待处理的预警 + alerts = db_session.query(KPIAlert).filter( + KPIAlert.status == "pending" + ).all() + + if not alerts: + logger.info("无待处理预警") + return 0 + + channel_configs = [json.loads(json.dumps({ + "name": c.name, "channel_type": c.channel_type, + "config": c.config, "enabled": c.enabled + })) for c in channels] + + pushed = 0 + for alert in alerts: + # 获取预警详情 + kpi = db_session.query(KPIDefinition).filter( + KPIDefinition.id == alert.kpi_id + ).first() + kpi_value = db_session.query(KPIValue).filter( + KPIValue.id == alert.kpi_value_id + ).first() + + alert_data = { + "alert_level": alert.alert_level, + "alert_message": alert.alert_message, + "kpi_name": kpi.kpi_name if kpi else "未知", + "period": kpi_value.period if kpi_value else "", + "actual_value": kpi_value.actual_value if kpi_value else None, + "target_value": kpi.target_value if kpi else None, + } + + results = push_alert(alert_data, channel_configs) + + # 记录推送日志 + for r in results: + log = NotificationLog( + alert_id=alert.id, + channel=r.get("channel", ""), + recipient=r.get("channel_name", ""), + title=alert.alert_message[:200], + content=alert.alert_message, + status="sent" if r.get("success") else "failed", + error_msg=r.get("message") if not r.get("success") else None, + sent_at=datetime.now(), + ) + db_session.add(log) + if r.get("success"): + pushed += 1 + logger.info(f" 已推送预警 #{alert.id} -> {r.get('channel_name')}") + + db_session.commit() + return pushed diff --git a/backend/app/utils/predict_engine.py b/backend/app/utils/predict_engine.py new file mode 100644 index 00000000..7db36349 --- /dev/null +++ b/backend/app/utils/predict_engine.py @@ -0,0 +1,260 @@ +"""预测模拟引擎 — 管理会计OS +CVP本量利分析、投资决策(NPV/IRR)、敏感性分析、情景模拟 +""" +import math +import logging +from typing import List, Dict, Optional, Tuple + +logger = logging.getLogger("cma.predict") + + +# ============================================================ +# CVP 本量利分析 +# ============================================================ + +def cvp_analysis( + unit_price: float, # 单价 + unit_variable_cost: float, # 单位变动成本 + fixed_cost: float, # 固定成本 + target_profit: float = None, # 目标利润(可选) + actual_volume: float = None, # 实际销量(可选) +) -> dict: + """CVP本量利分析 + + 返回:盈亏平衡点、安全边际、目标利润所需销量 + """ + if unit_price <= unit_variable_cost: + return {"error": "单价必须大于单位变动成本"} + + contribution_margin = unit_price - unit_variable_cost # 单位边际贡献 + contribution_ratio = round(contribution_margin / unit_price * 100, 2) # 边际贡献率 + + # 盈亏平衡点(保本点) + bep_units = round(fixed_cost / contribution_margin, 2) # 保本销量 + bep_revenue = round(bep_units * unit_price, 2) # 保本销售额 + + result = { + "unit_price": unit_price, + "unit_variable_cost": unit_variable_cost, + "fixed_cost": fixed_cost, + "contribution_margin": round(contribution_margin, 2), + "contribution_ratio": contribution_ratio, + "bep_units": bep_units, + "bep_revenue": bep_revenue, + } + + # 安全边际 + if actual_volume is not None: + safety_margin_units = actual_volume - bep_units + safety_margin_ratio = round(safety_margin_units / actual_volume * 100, 2) if actual_volume > 0 else 0 + actual_profit = round((unit_price - unit_variable_cost) * actual_volume - fixed_cost, 2) + result["safety_margin_units"] = round(safety_margin_units, 2) + result["safety_margin_revenue"] = round(safety_margin_units * unit_price, 2) + result["safety_margin_ratio"] = safety_margin_ratio + result["actual_profit"] = actual_profit + + # 目标利润 + if target_profit is not None: + target_units = round((fixed_cost + target_profit) / contribution_margin, 2) + target_revenue = round(target_units * unit_price, 2) + result["target_profit"] = target_profit + result["target_units"] = target_units + result["target_revenue"] = target_revenue + + return result + + +# ============================================================ +# 投资决策模型 +# ============================================================ + +def npv(initial_investment: float, cash_flows: List[float], discount_rate: float) -> dict: + """计算净现值 NPV = Σ CFt / (1+r)^t - I0""" + if not cash_flows: + return {"error": "现金流列表不能为空"} + r = discount_rate / 100 + pv = 0 + for t, cf in enumerate(cash_flows, 1): + pv += cf / ((1 + r) ** t) + npv_value = round(pv - initial_investment, 2) + + # 盈利能力指数 PI = PV / I0 + pi = round(pv / initial_investment, 4) if initial_investment > 0 else 0 + + return { + "initial_investment": initial_investment, + "discount_rate": discount_rate, + "pv_of_cash_flows": round(pv, 2), + "npv": npv_value, + "profitability_index": pi, + "is_viable": npv_value > 0, + } + + +def irr(initial_investment: float, cash_flows: List[float], max_iter: int = 1000, tolerance: float = 1e-6) -> dict: + """计算内部收益率 IRR(迭代法)""" + if not cash_flows: + return {"error": "现金流列表不能为空"} + + # 确保现金流总和 > 初始投资(否则 IRR 可能为负) + total_cf = sum(cash_flows) + if total_cf <= initial_investment: + # 用牛顿法尝试求负IRR + pass + + def _npv_at(rate: float) -> float: + return sum(cf / ((1 + rate) ** (t + 1)) for t, cf in enumerate(cash_flows)) - initial_investment + + # 牛顿法求根 + rate = 0.1 # 初始猜测 10% + for _ in range(max_iter): + f = _npv_at(rate) + if abs(f) < tolerance: + break + # 导数近似 + h = 1e-4 + df = (_npv_at(rate + h) - _npv_at(rate - h)) / (2 * h) + if abs(df) < tolerance: + break + rate -= f / df + if rate < -0.99: # IRR 不能低于 -99% + rate = -0.99 + break + + irr_value = round(rate * 100, 2) + + # 回收期 + cumulative = 0 + payback_period = None + for t, cf in enumerate(cash_flows, 1): + cumulative += cf + if cumulative >= initial_investment: + payback_period = t + break + + # 动态回收期(折现) + r = irr_value / 100 if irr_value > 0 else 0.1 + discounted_cumulative = 0 + discounted_payback = None + for t, cf in enumerate(cash_flows, 1): + discounted_cumulative += cf / ((1 + r) ** t) + if discounted_cumulative >= initial_investment: + discounted_payback = t + break + + return { + "initial_investment": initial_investment, + "cash_flows": cash_flows, + "irr": irr_value, + "payback_period": payback_period, # 静态回收期(年) + "discounted_payback_period": discounted_payback, # 动态回收期 + "is_viable": irr_value > 0, + } + + +# ============================================================ +# 敏感性分析 +# ============================================================ + +def sensitivity_analysis( + base_revenue: float, # 基准收入 + base_cost: float, # 基准成本 + base_profit: float = None, # 基准利润(若为None则自动 = 收入-成本) + step: float = 5, # 步长 % + max_step: float = 20, # 最大变动 % +) -> dict: + """单因素敏感性分析 + + 分析销量、单价、成本变动对利润的影响 + """ + if base_profit is None: + base_profit = base_revenue - base_cost + + factors = [] + steps = [s for s in range(-max_step, max_step + 1, step)] or [0] + + for pct in steps: + factor = pct / 100 + + # 收入变动(销量变动) + revenue_change_profit = base_profit * (1 + factor) + rev_sensitivity = round((revenue_change_profit - base_profit) / base_profit * 100, 2) if base_profit else 0 + + # 成本变动 + cost_change_profit = base_profit - base_cost * factor + cost_sensitivity = round((cost_change_profit - base_profit) / base_profit * 100, 2) if base_profit else 0 + + # 同时变动(收入+5%同时成本+5%) + both_profit = (base_revenue * (1 + factor)) - (base_cost * (1 + factor)) + both_sensitivity = round((both_profit - base_profit) / base_profit * 100, 2) if base_profit else 0 + + factors.append({ + "change_pct": pct, + "revenue_change_profit": round(revenue_change_profit, 2), + "revenue_sensitivity": rev_sensitivity, + "cost_change_profit": round(cost_change_profit, 2), + "cost_sensitivity": cost_sensitivity, + "both_change_profit": round(both_profit, 2), + "both_sensitivity": both_sensitivity, + }) + + return { + "base_revenue": base_revenue, + "base_cost": base_cost, + "base_profit": round(base_profit, 2), + "step": step, + "max_step": max_step, + "factors": factors, + } + + +# ============================================================ +# 情景模拟 +# ============================================================ + +def scenario_analysis( + optimistic: dict, # {"revenue": 130, "cost": 90} + pessimistic: dict, # {"revenue": 80, "cost": 110} + base: dict, # {"revenue": 100, "cost": 100} +) -> dict: + """三情景模拟(乐观/中性/悲观) + + 每个情景包含 revenue(收入) 和 cost(成本) + 计算各情景下的利润和偏差 + """ + scenarios = [] + for label, data in [("乐观", optimistic), ("中性", base), ("悲观", pessimistic)]: + revenue = data.get("revenue", 0) + cost = data.get("cost", 0) + profit = round(revenue - cost, 2) + scenarios.append({ + "scenario": label, + "revenue": revenue, + "cost": cost, + "profit": profit, + "profit_margin": round(profit / revenue * 100, 2) if revenue else 0, + }) + + base_profit = scenarios[1]["profit"] # 中性情景利润 + for s in scenarios: + if base_profit: + s["deviation_from_base"] = round(s["profit"] - base_profit, 2) + s["deviation_pct"] = round((s["profit"] - base_profit) / base_profit * 100, 2) + else: + s["deviation_from_base"] = s["profit"] + s["deviation_pct"] = 0 + + # 最好/最坏/期望值(假设各1/3概率) + expected_profit = round( + (scenarios[0]["profit"] + scenarios[1]["profit"] + scenarios[2]["profit"]) / 3, 2 + ) + variance = sum((s["profit"] - expected_profit) ** 2 for s in scenarios) / 3 + std_dev = round(math.sqrt(variance), 2) + + return { + "scenarios": scenarios, + "expected_profit": expected_profit, + "std_deviation": std_dev, + "best_case": scenarios[0], + "worst_case": scenarios[2], + } diff --git a/backend/check_db.py b/backend/check_db.py new file mode 100644 index 00000000..0f1bc9b6 --- /dev/null +++ b/backend/check_db.py @@ -0,0 +1,13 @@ +import sqlite3 +conn = sqlite3.connect('/root/cma-management/backend/cma.db') +cursor = conn.cursor() +tables = cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall() +for t in tables: + print(f'=== {t[0]} ===') + cols = cursor.execute(f'PRAGMA table_info({t[0]})').fetchall() + for c in cols: + print(f' {c[1]:25s} {c[2]:15s} null={c[3]} default={c[4]} pk={c[5]}') + rowcount = cursor.execute(f'SELECT COUNT(*) FROM {t[0]}').fetchone()[0] + print(f' 行数: {rowcount}') + print() +conn.close() diff --git a/backend/cma.db b/backend/cma.db new file mode 100644 index 00000000..e69de29b diff --git a/backend/deploy.sh b/backend/deploy.sh new file mode 100755 index 00000000..9a510740 --- /dev/null +++ b/backend/deploy.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# 管理会计OS — 后端生产部署脚本(systemd 方式) +set -e + +cd "$(dirname "$0")" + +echo "===== CMA 后端部署 =====" + +if [ ! -f .env ]; then + echo "[!] 缺少 .env 文件" + exit 1 +fi + +# 安装依赖 +pip install -r requirements.txt --quiet --no-cache-dir +echo "[✓] 依赖安装完成" + +# 重启 systemd 服务 +systemctl daemon-reload +systemctl restart cma-backend +systemctl status cma-backend --no-pager | head -5 + +echo "[✓] 部署完成" +echo " systemctl status cma-backend # 查看状态" +echo " journalctl -u cma-backend -f # 查看日志" diff --git a/backend/dev.sh b/backend/dev.sh new file mode 100755 index 00000000..1b9e0c39 --- /dev/null +++ b/backend/dev.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# 管理会计OS — 后端开发启动脚本 +set -e + +cd "$(dirname "$0")" + +# 检查 .env +if [ ! -f .env ]; then + echo "[!] 未找到 .env 文件,请从 .env.example 复制" + exit 1 +fi + +# 检查依赖安装 +if [ ! -d "venv" ]; then + echo "[*] 创建虚拟环境..." + python3 -m venv venv +fi + +source venv/bin/activate +pip install -r requirements.txt --quiet + +echo "[✓] 启动后端服务 (开发模式, 热重载)" +echo " http://127.0.0.1:8010" +echo " http://127.0.0.1:8010/docs (API文档)" +exec python3 -m uvicorn app.main:app --host 127.0.0.1 --port 8010 --reload diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..aace6e63 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.35 +pymysql==1.2.0 +pydantic==2.9.0 +python-dotenv==1.0.1 +python-multipart==0.0.12 +openpyxl==3.1.5 +pandas==3.0.3 +redis==5.2.1 +PyJWT==2.9.0 +httpx==0.27.0 diff --git a/backend/scripts/__pycache__/ai_brief.cpython-312.pyc b/backend/scripts/__pycache__/ai_brief.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a33a6cf8640f0148e35f9bbfb26720d7f77ecf8 GIT binary patch literal 10972 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 literal 0 HcmV?d00001 diff --git a/backend/scripts/__pycache__/erp_sync.cpython-312.pyc b/backend/scripts/__pycache__/erp_sync.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a263f2aaf85b3fd660b79557343bbee753ae283 GIT binary patch literal 17530 zcmcJ0ZFp11mGHg#y!w_b8`;J-@)uZuzcB`5Fa~TeHU?}5f&-{RR~QLdHdhkFj+{E# ze8>r@As^!GjnIooH;Xh?wNDWocUOxkYW)2@8B|9(|Qd1Kj?uAvRLMxO^9Km7>RXa zBu?_|cqh)LyiOjQ@;mu#D(DojsjySXrlL+Un-Xlww@bPuosw>8rxeF5xNRBNO(L~r zB=TI2#4Ws}u**4!MEZ9_W+!lQIg9EYJeq$fDuE6)b+XK5e_Qu0oW zj?Szvg_4r8I8-(%x8#rt%ZlRyQu&OaQ%|Za%Sg3l1*u`nbB_~b3Y6z1Dc3@IejH9f zrowZ91e0LoMizRRrI6IYKb^%?cw9+TFYA7V@Y6Eka{xra^5@qXdSGy82&N54x?byTh)K3A=1K$cfuZ14Wo1nDfbw2l2 zaac)EQ$(f#?TE}J0ZKhfir`U>SvFm+Tr}_NfC5Czy0bk3gs+xdZ&?RDRWI(TkB52! zj88v|@o($>@`l7J>f`nC@-a(Y0Y;^FZe{yk$Bwefvh^JHx*##qLm0{H-1^wov%id{ zXYe!Fi?@8w z&0c@)_LYl!+okh?4**Jkve#-PEyt}6tIOKsaF!hJp}O66lYVyc-CM6cbNkXWbKbGp zYu^L3w|;W%*0l@sFAmKOzZ&b}gP+YkH#Ya|4_PdyO?J1%DV=}iz4^0G)kyXFV*Rd; zj(z(2eNFoL@r!fUhimkA24B1V(eru>)mv=pwH9}oT$VGYKK=ZKAKrfN&ABUY%#Z#o zikfZSXtLXnnan5S%>mWyOak&ABr}f_r5wG zc>ea4iMd}q8=VFg-Q1Pe5dNV{w=bS8GE+Scm+6>6zeYbl^y1tL*-q+J%5(c@NA3Qvq*qB#8O}mD>3XaY%c?RHWmyBJ$wgt@jl^)y{W3n-DHt`5NLg-IM z%Fi!a>>k|A!?AWufVn`Fhz6U8%Oz4$;lW%n28v-eG@d1a`_u+R9uc*jRC+`<*%AOM+u|YX^NR+LjO&3+Ie8ei$zraLucTb=kyGg&d6b%DA~E+aDWONs zQM>57f(?0TAalR*8dP``RGvri?N=C3l*g@9L%zEhXt{mq2lH?EfM3o%dl@#5KV3gN zKkS3O2YBA?i{o?OKRfsC==_!cHSZ71O+3R$_n4e63$@K^x3@C<_DA+~7(|Q^IK17$ z2#;GWc9P+ny1E$QnG+Vu!tg268AghjE@`DKW*4w>MaSNKRmSa)w0G>?)6hntMG2)T zT!FE@zQfp7-_f*}t5WP}XlXjo&~^wFH^fUj+UlEHn_71+Diyj50NdQqOI34M-kkSd zz4OZ8+$&e^y!m?R{NT0t!|L*~GB;YiivZ6(2OII+hd*-bp?v<@#rbE3qbOjfLyhAP zY-0p8_xy8cYy9x7*=zpWSKgd^=^qhXG{*t~cgE(foSpaj<}Q42`^vksAAX3C&3*XF z;%5ZfXb@278=w$TjFg>4qvi2lib{uq{>?=&&7SRhA8GCI)F0SsIP6?~xTvUPO`YK| z%9q0bDfxPe02RexmyQBhsZVdt71P3;{` zt=l^cb*O94p87{UdzxAwAc4WlS;ARj86it&zl3Y3XOwJ-kt<-Jw-So&UvY3(Lt6v% z0)sIWP)O%AC>f#EW$AV@B6ly63>Y4)B4M}5Wj?{+O^mRM>T&lnLZ^$eLgMa)&7F}t zT_(!qJY#j8V8oQA*KRUH8)vWG>SFkIi-Qqcol!`U(_*5`Ck#qT37w&B+Ud5t7+L2o zauiO9IjF%hglqCsnZMt zbU8~od8MSck5K?>r^Of(GN|ooe$C67VNGsOlN-|H5AMDxR)xixL2;(PdRm-6E)5rL z4i;^mJULyoJCa@m=+RhGvcf3^!IXl)V-tCyl&WybreMmZP|9YnII;rK!j3_$8&!-b z{N|84&nx^)rjF#~2kOU8cpH7yFYLXUu8-kbVTd=#46F4)wLYK?sSCZr8JW`SezC@{ zd7&~J`{JeXZRon0BpTjLY?&WxyLVa_s!GH7jn z;NazB?~!klAxY)nw$H?pNM_cBqOqbt`MEXW%r(KxHKEL6=o;qjrc5)q=N_ytjNya! z!id<_M?z|$tUaW8v3w~3C|0&T4|3xu=8DU$9@YqziE(@F0RMqHJ}FpKC-7i4#2A)@ z0bxSw1Q}515rEvt|JpJUf+NKqK0rT+fnaMSuM0rV77U17aaos@jCliMkdj5GaZ2ez z<6;BIzCt9ea^rX!_lP_~B#{gdF4jngz>-N0AO%B9Qsp6_{UWK`g;)X`Y-%o-NRvrd zAn}MVt2y~l33ZvM zj_Wb5%_Ja__p{s@?V&srmkyb6`NcmVfd>Lmzmp_Ez@blqke-<@bIUSleC`=^Z@CSL*GmHMwp6J3mRthnG%XWb%o(2s1M7hNN%@F zXj$fwKgf-_Hk_Gm>0F!*$IN#{ZiMwK?PH+$MD1I5kU8Xz7@}4x590pRQjj!3DC-8B z)(LxGT^~r3oBK0jr68I*oyEOW&vC0ORwZ7^f+k?hZl|lK+d>t)dU|6p#7euh+v?g} z^|pYKwb!>av>QA2cGS0k;2hJDjI0FBXrh(-+V(a!u_{snqkTY^imFZh>g{{#i(wGO z?T1>omzI~58F>AYM~lJMRorap>z6*_g#PQhK-zSt!l>rY|MPy-@yuF-HU15+FAt_y{`Wnk ze?6_z;D0a0)Db{L9fioiqtMX?h4lQzL{lax_4#+5F$s zF{r&mLPX_4zV*EcFk~-ULxyyQZ-rM%Ak(+G2NsPSHA9Pvk+Qbg28!yT z7?HiFs|&O^fz@%m2h={wZnqvodfX{6bHj>g<1$dw(+k?1z~=06FhXP?b}|AA)*~Y# zEvN@ZXkk%GkU>0(UA4W(;Q+$}*ngu10v1rx@VMF1i%JyV51*OXex6cTq&o@8S?92C^@OAnJ#@ojmXm!Q3pmJ8K9xfRw z2}|`sDQHFGU9?mmlCB@zzQ7X+t7jD2;iE%G!;1W%B0sQwB867uhZO4;FkG^3QZS>{ zjn<6R_{j^_G3&XL;heHyPFX0YBBZVK?)VEIU_dP<)V|H1iuE@&X`|^Q>Hd|0ypX2w z&lpd#+FKt{seS1~Pxz0AR0T7-EdPH0<71D7bcRpWh6N!&z{IK3d>x~Yjy&qOj;Dmu z)`ru{f@x(FIa8WY+V+rY2Tkm_Dbdhrg`Y|aBM)^^oUDr{#*T(`Yd=-5MO_pxh%mK= zMN#yrq-aK!8tsnGDW510rB#Jg>uF*=Tfh2K$?AwoJN)?2<9<sz;?#T z>GaA_TBTPytIPK}=zfoa_GshsxYW33|J?1w5l+qs)v>j%lmp9=&RO-x#WDoO0dZ(^W0>x;=DGYe>15CiZ?YBTGe1tDz;WFQ3Sr z(9$_oAtjp5FBbSvasMytU>xj#?>l$FSM|?Txy=&nSIa7P*Yf{`ARvFEreY6||C?H2 zvq=0gj!Hh3WVh7uKVDhWqUV32mqY%OwaS(n{wFm;$p4ln+#?g4(I&tJ0y3;F4JVZB zPG#e27Nr9MI2}SzOF}|I=cINUl3$jD1e6W+wj_WS9@l6SRY;Rq)DH3TB_Y}A1v=&G zifhfBB%ADC5YiMnO*;#7+8FyXfs+8I2bq^6H>c9!elz`Q(Ihdt9&wzmK3n| z0>EHPLK(=vUM;p?5lfkrug(7<5N6L^b0#4{er!gx38&M<&8-xH!FN@`8 z=qXkT`U2meaBl-j>WVqHIIBeyE0g@4cfzoZV zw^I86mpcohBl-l&`G1NY4-ek{Xk`BU1^s<|vlXk7V|Nf~UGRsnaceBow3TXx=ZBC) z&zSEZTl!mbgV*&2RdiDof)qgYQOFR(h>lzBAm1?}BvDvNh6jI{v&T)DEv%6i$s0z7 z=%GN?FdplR?%D#<;xwb92?~S>xykH;ff(5{bm{~krK}Jc#EHG0k&?%_(`6Pzln%o3 zxYg;hI^YO-M?+)%BP|{6PV{BPcHijctBh{G$7AQ|)Hwin8~#pks)F73CbzvJ7M>Zk zKE6eO*)gmt3#!Uy2=%aPNae4*uzqZPkjRDSFp&j@&oH4668b>lcsiY15h5z@3HfT# zUqqN#_0);8j}L!$=(}N6eo&Pk0!}SjdC7gz{ev?LVoa#GD@B{xu5hX$m};O?t7Zu0 zuxv=?Gy9ADnRI^L6!CAWU#jT3ee}NlbX#Y*tt;5pMejdBr&^~8TSTP`s|tdu0-7jT zAh7ftuoXtKb0aJAVN+L&26rvU5&8{5@$!ce-$)N`ZKLbj>C}#C;t@dQCxWV6n#jE= zQ&Br$PRzjF9tbSYLyT|~WN-t|y$iCiTJ@mG=$+;hmTr^&&hyXTIrm&tIRozsSgn7$ z{@S8boG(4@Y2^fCKOkk_>SDo|Nip|y_qtuMijY9Y8u=+C2LBrV>{}khz>Y3gvxGM? zVv_1JQf@d`aDzD>gnmW=dJOVPh?z!_-pB`u?x;X`h^JPQvH{7r9>3Bfj+=KEvwMlt1pUU%B5c1n^cpAY6fi`$Bg3GBUI0TRz6RR2Zlm$I$pfqm2_W-03eTHlx!B}RDn^3>? z+stNHTMqk97p-&0VHYw4up#*y@Q6JGxr|(a+Jn1Kf?~yG{W~#EHh>(KsyIA})T89q z1HM@JL_Bp#_zM1)o1Aj!v3dy-c;wzAj`hjFY#N_aFhQ2VVM&5Z{%t&1?U6nxq=AQ_ zVnF(&)WpyMQinN#=n_$jObUaMJ|&uSC!4ILqo@XVd0_T!tU0DHT2fCm*?L& z4@S(hfHhIf*)|yon60efv0x$HNMA^Z%3MP?`Zl9P4!?&yD_KX5ITPWpE=gT3TTuAB*chwZ`VTjXkxC;&_Bnt{Op8M(is}p*v0~&%$1vpL`L0I;| zQ&ct{MY->zN_k8WKgPOh$dJLxh^*yqaOI#tc%x2oC|{kJd-Dx_?43jhzzx!n&8VX- z6H-0vs=>S11r8$XAl)g$YS`8o-+&q-XX`4U79t2QKwNY62&PB zMc+danh0tbMeJBcP;MuR&{(KfQN#{y9OaM*4_-|q*cm}k1Y12rP(4wXSx{40DaCJ56i`z*z-&IfNAXgN+FjoO#qfV)q@T^D8GBC#&Dz zOIPlgYP_+YZaetv%{NNHBGQTHL~;r)*v4$*SrcR^XA_-W3pNwY7H}02*4-6htJaMi zxt{(})(2VFa>5(;1vl=a_dgoicxbQ{XjNzi8zPF7!A5Wv0V|%Q9-m3g4de$-k3Sho z-QsNks%Vuyl9BBb&8U}+%0^^Q<}88fMQfy!~wgmkhsl-e9elwuXxCK$3FfF3Z4r}v++Wfb6g|x+C?Yf|L-HbeSc<<0&zw^TBvC~0$A@}@bP+koA zuv{OM>jMozx#6BzkShHPfyvZEg)gpJfV&+U(7{sk-#&eD!{jD9Z|BshPtyM*>(^Ny z=Y*TS6KwhpZ8{ZdqUc>tTI2GH{wUK%3Jl&|)AHQU%BwHdOg@J4dU$c(U1#@3@{8EV z8SwaN9%}aI`^^{ZV|M@1@r@Hl>2*7%woSRGy6E)Q-^usRYBD0a%+b>$rz5(;NOoQ% z3)I4bz)`wj9i3hE+3K>f-4iDxy6n$YM2gb8Z6O6y=!RQ|TKyH%@|>^jU|PQSCXo^* zGJ-_LG_f4!?ysMB;g~uD%yVdM0D%Zsdq8_B>ta?wIo>^4KUqmHuM5k!ek$LJjCBC^ z#pju96CNxBXwfp%;;)1`_KbP_4tnjD$y1YNI%C_kd^=bjzd)ANj4wXlhhy@zKVy7& zQHX%$L1Otdk&Rxhd`Rwdf@)qnR_iaJi#AMZCi!$4`eeWY`^AD7n%sBphA;4+Yd1FQ zuwQMc&uP(O|B5%H?NMSM>m)5|{>S>l78(B&nGo`yXeE0fpH;X=#Q&`bY=HdVDkZJU z_`lUvwQBhPt`S0>k#Hv`&Ib&Hag8zn-})%rp-8yYaMBBx4hTTv7X1G*6vF)ykct;c z0a6(Ml0dQ!8=T<}%y2kW0;aGe($gY5-_W?|!8z%WOOcr`nQ<{bA0p5Pc^*D!nQ^xS z8#?P+5;%C9Zb=0^f-4JHb5Ix%C*;Fq2jHZ@6_<-VahVx(0-=WoG3cQXi(il5B!e4h z1Zb@UXsse}?*}^@NPcl`30$VZLCV7e9WrKLphO^9OVDka0pe^rxb{TzlCwDiuroMa z3CSF3-HD_8WZF|QN?kM+PnAZp~gC0wDknCqe2$GA#8bMC_UB-1Wh^OD{%2;*xHSo^@M>J3~kY zW<|Q_g`dr=Xth=k*4I0uKiy0r-8p(@j#dvPLlF{F3_@_#M(2s>oZW1OD>PtJHCgT8 zwn`vbq#K>mA~C2DPWI&%hto}geamUKTH%HbODLR|%3`)I^JNilo8u)~B z6e2MFO0uRUnITD5NRl(SZGl%H)JD`Pqr?btK0Bmd6;_u7)g?3XjM!$sD=1&fKCKSQ zSC3nQa%A(>NWH?l1Z+@|)Qr)Vk(Rfz!@1SL-0E=dreN--$)-?lV<>f}x8bHLb+~V+ z@5Lv)g3t1b#zdD-yk~#g{*EJD(hw|ZnCc3Zw0adWafM!1eAzrv@Q!u-F?vJujh#0( z(j76Nw;8)hE$LM_r=+uML#G@c&M%{*AlK$1p zL;k}rN$;~SailS$aTE5hn^JZv_zYn*cAMZ3!N_S*QPnKEveB(HbeMn~v%ztoUs7BQ zhTvjk2nG-I9{5yTl-t7UX|va!z4IpA>hr>F3Urf>y}cL1_2J9lpz#sArcPMwz5T-C zVpwbU@ilCp^Jkw2ofWCL*I&IAcnZ8vgbuJRJE?z%7X3tVv7-lF>0`Z^ZvE&f&>lgt zi*f)yE^ zJ*`iG>euU|fb-|k!OnVMIsp8q>qEc_1njpjhWm^raGz&M11+5Wj1j}RgFcS?crn8- zt03K|IEqQ%&o3z{fy)j_zPx^(KFTEYoGuvUDN$NEo`P^8_6L+0_)E|j_>CQ{20>#h z#M>CL5iSp!jYe?k=yNi>9%qyrh>^1i(jXD@W|UE9laiRP31vft@>n73N)qKY$m+ug z;8MT|Mr3t>`xLmPzTUX?G?c@T!UQXKf?zN7u=_;W>-`TejK&+ zqKMUV(b)wfIM&l+XSpFGu*2CE%aYIvM`Qz)1S~7orJGUKQ(bO!`(PisUjXN8Y%v9- zlcW)#*gDFBkV@GqR0ys&Y)o)#i{;2@e<4kMjD~fRaT>WwbTNW5Il3$iZiVX{$bT;8 z34{D+7(8}ZC+wxbWhlyKMUEbHwh~p}k%}CB-$zky2a2{qbf2h&Idt1CTc|JK5ea6_ zJP?aP1;FvUJTWe~mxAH4-(!;BV-PFZOxo`;?H{nBn;8Fj;wj>J)l(`T?yDHB9;qI! z8>tIaT&liUeW~tZ-9*LJ>i4U!*1cahwINj563X8b(zZ@xd*`s6&oJ3Did)$FKVY>% ztoA0bL|s^t8I)xDYeJHhgCgKJ>I^T1_%LXKhMghRu0eT3sq-EiwhmeS3VPMHkaGK= zY(c&ePw`hSV2}jX-9yR3R^DcOlW$-FgJhg!lgaIDGSzSo6)x=JW#DSRVgZ9>+{Gq0 zv}{7}KX4C~E^NhxPzQ)1nW(yll7)0^%XU1nq4qAn4ad`b#S0ipC!h8}_RpA7Qtqw4sZL|<3#JhjxZ=QrbVLf76s_;n z_g6;L8eiLp*oULc{t=N6Ul3y2RNtwWSHcU=$OH{B*I(~9`>TCNy^VjAYkWKXjs6XR zN;+lLw7h76Cs34srcMu}O%z-$dcWv~;6G%)mQC%Y4(aMd#BWnIgsQsxIvc|F|Qm>}JpFZXKw_gitSG str: + """调用DeepSeek API生成分析内容""" + api_key = os.getenv("DEEPSEEK_API_KEY", "") + api_url = "https://api.deepseek.com/v1/chat/completions" + + if not system_prompt: + system_prompt = "你是一名CMA管理会计师,擅长用数据驱动的方式分析企业经营状况,给出专业的财务分析和管理建议。回答要简洁、专业、有数据支撑。" + + if not api_key: + logger.warning("DEEPSEEK_API_KEY 未配置,跳过AI调用") + return "(AI简报暂不可用:API Key未配置)" + + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post( + api_url, + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={ + "model": "deepseek-chat", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + "stream": False, + "temperature": 0.3, + } + ) + data = resp.json() + if resp.status_code != 200: + logger.error(f"DeepSeek API错误: {resp.status_code} {data}") + return "" + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + + +def generate_brief(db_session) -> dict: + """生成经营简报""" + period = datetime.now().strftime("%Y-%m") + + kpis = db_session.query(KPIDefinition).filter(KPIDefinition.status == "active").all() + kpi_lines = [] + for k in kpis: + latest = db_session.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + ).order_by(KPIValue.period.desc()).first() + + prev_month = f"{int(period[:4])}-{int(period[5:7])-1:02d}" if int(period[5:7]) > 1 else f"{int(period[:4])-1}-12" + prev = db_session.query(KPIValue).filter( + KPIValue.kpi_id == k.id, KPIValue.period == prev_month + ).first() + + alert = db_session.query(KPIAlert).filter( + KPIAlert.kpi_id == k.id, KPIAlert.status == "pending" + ).first() + + if latest and latest.actual_value is not None: + line = f"- {k.kpi_name}({k.kpi_code}): {latest.actual_value}{k.unit or ''}" + if k.target_value: + line += f" | 目标: {k.target_value}" + if prev and prev.actual_value: + diff = latest.actual_value - prev.actual_value + direction = "↑" if diff > 0 else "↓" + line += f" | 环比: {direction}{abs(diff):.1f}" + if alert: + line += f" | ⚠️ {alert.alert_level}预警" + kpi_lines.append(line) + + plans = db_session.query(ActionPlan).order_by(ActionPlan.created_at.desc()).all() + plan_lines = [] + for p in plans: + plan_lines.append(f"- {p.title} | 负责人: {p.assignee} | 状态: {p.status} | 进度: {p.progress}%") + + kpi_text = "\n".join(kpi_lines) if kpi_lines else "暂无KPI数据" + plan_text = "\n".join(plan_lines) if plan_lines else "暂无行动计划" + + red_count = db_session.query(KPIAlert).filter( + KPIAlert.status == "pending", KPIAlert.alert_level == "red" + ).count() + yellow_count = db_session.query(KPIAlert).filter( + KPIAlert.status == "pending", KPIAlert.alert_level == "yellow" + ).count() + + today_str = datetime.now().strftime('%Y-%m-%d') + prompt = f"""请为管理层生成一份今日经营简报(日期:{today_str})。 + +## 本月KPI数据 +{kpi_text} + +## 待处理预警 +- 红色(紧急): {red_count}条 +- 黄色(预警): {yellow_count}条 + +## 正在执行的改善行动 +{plan_text} + +请按以下结构生成简报(不超过800字): +1. 📊 **经营概览**:一句话总结本月经营状况 +2. 🔍 **关键发现**:最重要的3个发现(数据驱动) +3. ⚠️ **预警聚焦**:最需要关注的预警及其影响 +4. ✅ **行动进展**:改善计划执行情况 +5. 💡 **今日建议**:今天最应该做的1-2件事""" + + try: + import asyncio + analysis = asyncio.run(call_deepseek(prompt)) + except Exception as e: + logger.error(f"AI简报生成异常: {e}") + analysis = f"简报生成异常: {str(e)}" + + return { + "period": period, + "brief": analysis, + "kpi_count": len(kpi_lines), + "red_alerts": red_count, + "yellow_alerts": yellow_count, + "plan_count": len(plan_lines), + } + + +def push_brief(brief: dict): + """将简报推送到企微""" + from app.utils.notifier import send_wecom_app + + db = get_session_local()() + try: + from app.models import NotificationChannel + channels = db.query(NotificationChannel).filter( + NotificationChannel.enabled == True, + NotificationChannel.channel_type == "wecom_app", + ).all() + + pushed = 0 + for ch in channels: + config = ch.config or {} + result = send_wecom_app( + corp_id=config.get("corp_id", ""), + corp_secret=config.get("corp_secret", ""), + agent_id=config.get("agent_id", ""), + touser=config.get("touser", "@all"), + title=f"📋 {brief['period']} 经营简报", + content=brief["brief"], + alert_level="green", + ) + if result.get("success"): + pushed += 1 + logger.info(f"简报推送成功: {ch.name}") + else: + logger.warning(f"简报推送失败: {result.get('message')}") + + return pushed + finally: + db.close() + + +def run_brief(): + """主入口:生成简报并推送""" + logger.info("开始生成AI经营简报...") + db = get_session_local()() + try: + brief = generate_brief(db) + logger.info(f"简报生成完成: {brief['period']}, KPI数={brief['kpi_count']}, 预警={brief['red_alerts']}红/{brief['yellow_alerts']}黄") + + if brief["brief"] and len(brief["brief"]) > 50: + pushed = push_brief(brief) + logger.info(f"推送完成: {pushed} 个渠道") + else: + logger.warning("简报内容不足50字,跳过推送") + + return brief + finally: + db.close() + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + result = run_brief() + print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/backend/scripts/alert_generator.py b/backend/scripts/alert_generator.py new file mode 100644 index 00000000..d65048f1 --- /dev/null +++ b/backend/scripts/alert_generator.py @@ -0,0 +1,178 @@ +""" +预警自动生成 — 管理会计OS +比对 KPI 实际值与阈值配置(threshold_green/yellow/red),超出则写入 kpi_alerts +在 daily_sync 之后运行 +""" + +import sys +import os +import re +import json +import logging +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.database import get_session_local +from app.models import KPIDefinition, KPIValue, KPIAlert + +logger = logging.getLogger("cma.alert_gen") + + +def _parse_threshold(expr: str) -> tuple: + """解析阈值表达式,返回 (operator, value) + 示例: + >=32000000 -> ('>=', 32000000) + <20 -> ('<', 20) + <=55 -> ('<=', 55) + >60 -> ('>', 60) + """ + m = re.match(r"(>=|<=|>|<|=|!=)\s*([\d.]+)", str(expr).strip()) + if m: + return m.group(1), float(m.group(2)) + return None, None + + +def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple: + """检查实际值是否触发阈值,返回 (触发, 消息)""" + if not threshold_expr or actual is None: + return False, "" + + op, val = _parse_threshold(threshold_expr) + if op is None: + return False, "" + + triggered = False + if op == ">=" and actual >= val: + triggered = True + elif op == "<=" and actual <= val: + triggered = True + elif op == ">" and actual > val: + triggered = True + elif op == "<" and actual < val: + triggered = True + elif op == "=" and actual == val: + triggered = True + + if triggered: + level_names = {"green": "正常", "yellow": "预警", "red": "紧急"} + msg = (f"KPI当前值 {actual:.2f},触发{level_names.get(level, level)}阈值 " + f"({threshold_expr})") + return True, msg + + return False, "" + + +def run_alert_check(db_session, period: str = None) -> int: + """检查所有KPI的实际值是否触发预警,返回新生成的预警数""" + if period is None: + period = datetime.now().strftime("%Y-%m") + + kpis = db_session.query(KPIDefinition).filter( + KPIDefinition.status == "active" + ).all() + + new_count = 0 + for kpi in kpis: + # 跳过无阈值的KPI + thr = { + "red": kpi.threshold_red, + "yellow": kpi.threshold_yellow, + "green": kpi.threshold_green, + } + if not any(thr.values()): + continue + + # 获取该KPI当前期间的最新值 + latest = db_session.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, + KPIValue.period == period, + ).order_by(KPIValue.calculated_at.desc()).first() + + if not latest or latest.actual_value is None: + continue + + actual = latest.actual_value + + # 从绿到红检查(绿灯最高优先级——满足即止) + for level in ["green", "yellow", "red"]: + expr = thr[level] + if not expr: + continue + triggered, msg = _check_threshold(actual, expr, level) + if triggered: + # 检查是否已有该期间该KPI同级别的预警 + existing = db_session.query(KPIAlert).filter( + KPIAlert.kpi_id == kpi.id, + KPIAlert.kpi_value_id == latest.id, + KPIAlert.alert_level == level, + ).first() + + if existing: + # 已有预警,跳过 + break + + # 创建新预警 + alert = KPIAlert( + kpi_id=kpi.id, + kpi_value_id=latest.id, + alert_level=level, + alert_message=f"{kpi.kpi_name}[{period}] {msg}", + status="pending", + ) + db_session.add(alert) + new_count += 1 + logger.info(f" 新增预警 [{level}] {kpi.kpi_name}: {msg}") + break # 只取最高级别 + elif level == "red" and not triggered: + # 红没触发,如果已有红色预警但当前不满足,自动降级或关闭 + existing_red = db_session.query(KPIAlert).filter( + KPIAlert.kpi_id == kpi.id, + KPIAlert.kpi_value_id == latest.id, + KPIAlert.alert_level == "red", + KPIAlert.status.in_(["pending", "processing"]), + ).first() + if existing_red: + existing_red.status = "resolved" + existing_red.resolution = "自动解除: 当前值不再触发红色阈值" + existing_red.resolved_at = datetime.now() + logger.info(f" 自动解除预警 [red] {kpi.kpi_name}") + + db_session.commit() + return new_count + + +def generate_and_push(db_session) -> dict: + """生成预警并推送,返回统计""" + period = datetime.now().strftime("%Y-%m") + + # 1. 生成预警 + logger.info(f"开始预警检查 ({period})...") + new_count = run_alert_check(db_session, period) + logger.info(f"预警检查完成: 新增 {new_count} 条") + + # 2. 推送 + pushed = 0 + if new_count > 0: + try: + from app.utils.notifier import push_pending_alerts + pushed = push_pending_alerts(db_session) + logger.info(f"推送完成: {pushed} 条") + except Exception as e: + logger.error(f"推送失败: {e}") + + return {"period": period, "new_alerts": new_count, "pushed": pushed} + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + db = get_session_local()() + try: + result = generate_and_push(db) + print(f"预警检查: {result['new_alerts']}条新预警 / {result['pushed']}条已推送") + finally: + db.close() diff --git a/backend/scripts/daily_sync.py b/backend/scripts/daily_sync.py new file mode 100755 index 00000000..fa66a305 --- /dev/null +++ b/backend/scripts/daily_sync.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +管理会计OS — 每日自动同步入口 + +由 cron 每天 01:00 调用: + 0 1 * * * cd /root/cma-management/backend && python3 scripts/daily_sync.py >> /var/log/cma-daily-sync.log 2>&1 + +手动执行: + python3 scripts/daily_sync.py +""" + +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + +from app.database import get_session_local + +if __name__ == "__main__": + db = get_session_local()() + + # 1. ERP数据同步 + print("=" * 50) + print("1/3 ERP数据同步") + try: + from scripts.erp_sync import run_sync + run_sync(dry_run=False, use_api=True) + print(" ✅ ERP同步完成") + except Exception as e: + print(f" ❌ ERP同步异常: {e}") + + # 2. 预警生成 + print("\n" + "=" * 50) + print("2/3 预警检查") + try: + from scripts.alert_generator import run_alert_check + new_count = run_alert_check(db) + print(f" ✅ 预警检查完成: 新增 {new_count} 条") + except Exception as e: + print(f" ❌ 预警生成异常: {e}") + + # 3. 预警推送 + print("\n" + "=" * 50) + print("3/3 预警推送") + try: + from app.utils.notifier import push_pending_alerts + pushed = push_pending_alerts(db) + print(f" ✅ 预警推送完成: {pushed} 条") + except Exception as e: + print(f" ❌ 预警推送异常: {e}") + + # 4. 差异预警(实际vs预算) + print("\n" + "=" * 50) + print("4/4 差异预警检查") + try: + from app.utils.deviation_engine import run_deviation_check + new_alerts = run_deviation_check(db) + print(f" ✅ 差异预警检查完成: 新增 {new_alerts} 条") + except Exception as e: + print(f" ❌ 差异预警异常: {e}") + + db.close() + print("\n" + "=" * 50) + print("✅ 全部完成") diff --git a/backend/scripts/erp_sync.py b/backend/scripts/erp_sync.py new file mode 100644 index 00000000..d9d31ee9 --- /dev/null +++ b/backend/scripts/erp_sync.py @@ -0,0 +1,374 @@ +""" +ERP数据同步脚本 — 管理会计OS +根据 kpi_definitions.formula 中的规则从ERP系统拉取数据并写入 kpi_values +支持: + - HTTP API 模式: 通过 erp-api-gateway 查询实时数据 + - Fallback 模式: API不可达时使用本地已有数据或标记待同步 + - 定时执行(crontab) + 手动触发 +""" + +import sys +import os +import json +import logging +import re +import urllib.request +import urllib.error +from datetime import datetime, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +from app.database import get_engine, get_session_local +from app.models import KPIDefinition, KPIValue, OperationLog + +logger = logging.getLogger("erp_sync") + +# ERP API 配置 +ERP_API_BASE = os.getenv("ERP_API_BASE", "http://127.0.0.1:8300/api/v1") +ERP_API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026") + +# ============================================================ +# 公式解析 +# ============================================================ + +def parse_formula(formula: str) -> dict: + """解析KPI公式,提取ERP表和字段映射""" + result = {"table": "MasterBill", "field": None, "agg": "SUM", + "where": None, "raw": formula, "erp_direct": True} + + # 特殊处理中文描述的公式 + ZH_PATTERNS = { + "前5客户销售额/总销售额*100": ("TOP5_CUSTOMER", "MasterBill"), + "前5客户集中度": ("TOP5_CUSTOMER", "MasterBill"), + "满意客户数/总客户数*100": ("CUSTOMER_SAT_RATIO", "MasterBill"), + "准时交付订单/总订单*100": ("DELIVERY_RATE", "MasterBill"), + "完成培训人数/应培训人数*100": ("TRAINING_RATE", "MasterBill"), + } + for zh_pattern, (agg_type, table) in ZH_PATTERNS.items(): + if zh_pattern in formula: + result.update({"agg": agg_type, "table": table, "field_expr": formula, + "erp_direct": False}) # 不能直接跑SQL + return result + + # 优先检测比率型公式: SUM(A)/SUM(B)*100 + ratio_m = re.match(r"(SUM|COUNT|AVG)\s*\((.+?)\)\s*/\s*(SUM|COUNT|AVG)\s*\((.+?)\)", formula, re.I) + if ratio_m: + result["agg"] = f"RATIO_{ratio_m.group(1)}" + result["field_expr"] = f"({ratio_m.group(2)})/({ratio_m.group(4)})" + return result + + # 匹配完整聚合: SUM(...), COUNT(DISTINCT ...), COUNT(...), AVG(...) + m = re.match(r"(SUM|COUNT(?:\s+DISTINCT)?|AVG|MAX|MIN)\s*\((.+?)\)", formula, re.I) + if not m: + result["field_expr"] = "1" + result["table"] = "MasterBill" + result["erp_direct"] = False + return result + + agg_func = m.group(1).strip().upper() + field_expr = m.group(2).strip() + + if agg_func.startswith("COUNT") and field_expr.startswith("DISTINCT "): + result["agg"] = "COUNT_DISTINCT" + cleaned = field_expr.replace("DISTINCT ", "").strip() + result["field_expr"] = cleaned + parts = cleaned.split(".") + if parts: + result["table"] = parts[0] + else: + result["agg"] = agg_func + result["field_expr"] = field_expr + parts = field_expr.split(".") + if len(parts) >= 2: + candidate = parts[0].strip() + if candidate and candidate[0].isupper(): + result["table"] = candidate + + wm = re.search(r"WHERE\s+(.+)$", formula, re.I) + if wm: + result["where"] = wm.group(1).strip() + + return result + + +# ============================================================ +# API 模式: 通过 ERP 接口查询 +# ============================================================ + +def fetch_via_api(kpi: KPIDefinition, parsed: dict, period: str) -> float: + """通过 erp-api-gateway 查询ERP数据""" + period_month = int(period[5:7]) + period_year = int(period[:4]) + kpi_code = kpi.kpi_code + + # 各KPI对应的API路径 + API_MAP = { + "SALES_TOTAL": f"{ERP_API_BASE}/stats/monthly?year={period_year}", + "CUSTOMER_COUNT": f"{ERP_API_BASE}/stats/monthly?year={period_year}", + "SALES_PROFIT_RATE": f"{ERP_API_BASE}/stats/gross-profit?year={period_year}&month={period_month}", + "TOP5_CUSTOMER_RATIO": f"{ERP_API_BASE}/stats/customer-top?year={period_year}&limit=5", + } + + headers = {"X-API-Key": ERP_API_KEY, "User-Agent": "CMA-ERP-SYNC/1.0"} + + if kpi_code not in API_MAP: + raise ValueError(f"未配置API映射: {kpi_code}") + + url = API_MAP[kpi_code] + logger.info(f" [{kpi_code}] API请求: {url}") + + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + raise ConnectionError(f"API返回 {e.code}: {e.read().decode()[:200]}") + except Exception as e: + raise ConnectionError(f"API请求失败: {e}") + + if kpi_code == "SALES_TOTAL": + # 从 monthly trend 中取对应月份 + for m in data.get("data", []): + if m["period"] == period: + return float(m["amount"]) + # fallback: 取汇总 + return float(data.get("summary", {}).get("total_amount", 0)) + + elif kpi_code == "CUSTOMER_COUNT": + for m in data.get("data", []): + if m["period"] == period: + return float(m["customers"]) + return 0 + + elif kpi_code == "SALES_PROFIT_RATE": + return float(data.get("gross_profit_rate", 0)) + + elif kpi_code == "TOP5_CUSTOMER_RATIO": + top5 = data.get("data", []) + top5_total = sum(c["amount"] for c in top5) + # 同时获取全年总额 + total_url = f"{ERP_API_BASE}/stats/monthly?year={period_year}" + req2 = urllib.request.Request(total_url, headers=headers) + with urllib.request.urlopen(req2, timeout=15) as resp2: + total_data = json.loads(resp2.read().decode()) + total_amount = sum(m["amount"] for m in total_data.get("data", [])) + if total_amount > 0: + return round(top5_total / total_amount * 100, 2) + return 0 + + raise ValueError(f"未实现的API映射: {kpi_code}") + + +# ============================================================ +# Fallback 模式: 本地已有数据推算 +# ============================================================ + +def fetch_fallback(kpi: KPIDefinition, parsed: dict, db_session, period: str) -> float: + """Fallback: 从本地已有 kpi_values 推算或返回 None""" + kpi_code = kpi.kpi_code + + # 对于已有数据的KPI,沿用最近月份的值(标注为estimated) + existing = db_session.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, + KPIValue.source_type.in_(["erp", "manual"]), + ).order_by(KPIValue.period.desc()).first() + + if existing and existing.actual_value is not None: + logger.info(f" [{kpi_code}] Fallback: 沿用最近期 {existing.period}={existing.actual_value}") + return existing.actual_value + + # 特殊KPI的默认值 + DEFAULTS = { + "SALES_TOTAL": 800000, + "CUSTOMER_COUNT": 25, + "SALES_PROFIT_RATE": 25.0, + "TOP5_CUSTOMER_RATIO": 50.0, + } + if kpi_code in DEFAULTS: + logger.info(f" [{kpi_code}] Fallback: 使用默认值 {DEFAULTS[kpi_code]}") + return DEFAULTS[kpi_code] + + return None + + +# ============================================================ +# TOP5_CUSTOMER_RATIO 的公式补充处理 +# ============================================================ + +def compute_top5_ratio(db_session, period: str) -> float: + """从 ERP schema 采集数据计算:前5客户销售额/总销售额*100""" + # 先检查 erp_schema 是否有 MasterBill 的完整数据 + # 如果有物化数据,可以在这里做本地计算 + # 目前 erp_schema 只有元数据没有数据,返回 None 表示需要 API + return None + + +# ============================================================ +# 核心同步函数 +# ============================================================ + +def sync_kpi(kpi: KPIDefinition, db_session, dry_run: bool = False, + use_api: bool = True, target_period: str = None) -> bool: + """同步单个KPI的ERP数据""" + if kpi.data_source_type not in ("erp",): + return False + + formula = kpi.formula + if not formula: + logger.warning(f" [{kpi.kpi_code}] 无公式定义") + return False + + parsed = parse_formula(formula) + logger.info(f" [{kpi.kpi_code}] 解析: table={parsed['table']}, agg={parsed['agg']}, " + f"erp_direct={parsed.get('erp_direct',True)}") + + current_period = target_period if target_period else datetime.now().strftime("%Y-%m") + + # 尝试通过 API 获取 + value = None + api_ok = False + if use_api: + try: + value = fetch_via_api(kpi, parsed, current_period) + if value is not None: + api_ok = True + logger.info(f" [{kpi.kpi_code}] API结果: {current_period}={value}") + except Exception as e: + logger.warning(f" [{kpi.kpi_code}] API失败: {e}") + + # API 失败则 fallback + if not api_ok: + try: + value = fetch_fallback(kpi, parsed, db_session, current_period) + if value is not None: + source_note = "estimated" + logger.info(f" [{kpi.kpi_code}] Fallback结果: {current_period}={value}") + else: + logger.warning(f" [{kpi.kpi_code}] 无可用数据, 跳过") + return False + except Exception as e: + logger.error(f" [{kpi.kpi_code}] Fallback失败: {e}") + return False + + if dry_run: + logger.info(f" [{kpi.kpi_code}] DRY RUN: 跳过写入 value={value}") + return True + + # 写入 kpi_values + try: + existing = db_session.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, + KPIValue.period == current_period, + KPIValue.source_type == "erp", + ).first() + + remark = f"ERP自动同步{' (API)' if api_ok else ' (估算)'} {datetime.now().strftime('%Y-%m-%d %H:%M')}" + + if existing: + existing.actual_value = value + existing.data_status = "verified" if api_ok else "estimated" + existing.remark = remark + existing.source_type = "erp" + logger.info(f" [{kpi.kpi_code}] 更新 {current_period}: {value}") + else: + kv = KPIValue( + kpi_id=kpi.id, + period=current_period, + actual_value=value, + source_type="erp", + source_batch=f"sync_{current_period}", + data_status="verified" if api_ok else "estimated", + remark=remark, + ) + db_session.add(kv) + logger.info(f" [{kpi.kpi_code}] 新增 {current_period}: {value}") + + db_session.commit() + return True + + except Exception as e: + db_session.rollback() + logger.error(f" [{kpi.kpi_code}] 写入失败: {e}") + return False + + +def run_sync(dry_run: bool = False, kpi_codes: list = None, use_api: bool = True, period: str = None): + """执行全部ERP KPI同步""" + db = get_session_local()() + try: + query = db.query(KPIDefinition).filter( + KPIDefinition.status == "active", + KPIDefinition.data_source_type == "erp", + ) + if kpi_codes: + query = query.filter(KPIDefinition.kpi_code.in_(kpi_codes)) + + kpis = query.all() + target_period = period if period else datetime.now().strftime("%Y-%m") + logger.info(f"开始同步ERP数据: {len(kpis)} 个KPI (API模式={use_api}, 期间={target_period})") + + success = 0 + fail = 0 + for kpi in kpis: + if sync_kpi(kpi, db, dry_run, use_api, target_period): + success += 1 + else: + fail += 1 + + if not dry_run: + log = OperationLog( + action="erp_sync", + target_type="kpi", + detail=json.dumps({ + "total": len(kpis), "success": success, + "failed": fail, "api_mode": use_api, + "period": datetime.now().strftime("%Y-%m"), + }, ensure_ascii=False), + ) + db.add(log) + db.commit() + + logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计") + + finally: + db.close() + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="ERP数据同步") + parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入数据库") + parser.add_argument("--kpi", nargs="+", help="指定KPI编码") + parser.add_argument("--no-api", action="store_true", help="禁用API模式,仅用本地fallback") + parser.add_argument("--backfill", type=int, default=0, + help="回填历史月份数(如 --backfill 6 回填最近6个月)") + args = parser.parse_args() + + if args.backfill: + from datetime import datetime, timedelta + from app.database import get_session_local + + today = datetime.now() + months_backfilled = 0 + for i in range(1, args.backfill + 1): + # 计算目标月份 + m = today.month - i + y = today.year + while m <= 0: + m += 12 + y -= 1 + period = f"{y}-{m:02d}" + + print(f"回填 {period}...") + try: + run_sync(dry_run=False, kpi_codes=args.kpi, use_api=not args.no_api, period=period) + months_backfilled += 1 + except Exception as e: + print(f" {period} 失败: {e}") + + print(f"回填完成: {months_backfilled} 个月") + else: + run_sync(dry_run=args.dry_run, kpi_codes=args.kpi, use_api=not args.no_api) diff --git a/backend/test_sync.py b/backend/test_sync.py new file mode 100644 index 00000000..44269db2 --- /dev/null +++ b/backend/test_sync.py @@ -0,0 +1,11 @@ +"""测试 ERP sync""" +import logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') + +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from scripts.erp_sync import run_sync +print('=== START ===') +run_sync(dry_run=False, use_api=True) +print('=== DONE ===') diff --git a/backend/test_sync_profit.py b/backend/test_sync_profit.py new file mode 100644 index 00000000..75fab61e --- /dev/null +++ b/backend/test_sync_profit.py @@ -0,0 +1,10 @@ +"""同步毛利率""" +import logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') + +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from scripts.erp_sync import run_sync +run_sync(dry_run=False, use_api=True, kpi_codes=['SALES_PROFIT_RATE']) +print('DONE') diff --git a/check.sh b/check.sh new file mode 100755 index 00000000..4d7dbbc2 --- /dev/null +++ b/check.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# 管理会计OS — 环境自检脚本 +set -e + +echo "===== CMA 环境自检 =====" +echo "" + +# 1. 后端服务 +echo "[1] 后端服务" +if systemctl is-active cma-backend &>/dev/null; then + echo " ✓ cma-backend 运行中" +else + echo " ✗ cma-backend 未运行" +fi + +# 2. Nginx +echo "[2] Nginx" +if nginx -t 2>&1 | grep -q "successful"; then + echo " ✓ Nginx 配置正确" +else + echo " ✗ Nginx 配置有误" +fi + +# 3. 数据库 +echo "[3] 数据库" +if mysql -ucma_user -pcma_pass_2026 -h127.0.0.1 cma -e "SELECT 1;" &>/dev/null; then + echo " ✓ MariaDB 连接正常" +else + echo " ✗ MariaDB 连接失败" +fi + +# 4. API 检查 +echo "[4] API 端点" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8010/health 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ]; then + echo " ✓ API /health 返回 200" +else + echo " ✗ API /health 返回 $HTTP_CODE" +fi + +# 5. 线上前端 +echo "[5] 线上前端" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://cma.sxbh.ltd/ 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ]; then + echo " ✓ https://cma.sxbh.ltd 返回 200" +else + echo " ✗ https://cma.sxbh.ltd 返回 $HTTP_CODE" +fi + +# 6. 线上 API +echo "[6] 线上 API" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://cma.sxbh.ltd/api/cma/users 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ]; then + echo " ✓ /api/cma/users 返回 200" +else + echo " ✗ /api/cma/users 返回 $HTTP_CODE" +fi + +echo "" +echo "===== 自检完成 =====" diff --git a/check_cma.py b/check_cma.py new file mode 100644 index 00000000..05256fe5 --- /dev/null +++ b/check_cma.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""查询管理会计OS各模块数据量""" +import subprocess, json, sys + +# 登录获取token +r = subprocess.run( + "curl -s -X POST http://127.0.0.1:8010/api/cma/auth/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"admin123\"}'", + shell=True, capture_output=True, text=True, timeout=10 +) +login = json.loads(r.stdout) +token = login['token'] + +endpoints = [ + ("KPI定义", "/api/cma/kpis"), + ("战略地图", "/api/cma/maps"), + ("预警记录", "/api/cma/alerts"), + ("用户列表", "/api/cma/users"), + ("数据源配置", "/api/cma/data/sources"), + ("通知渠道", "/api/cma/notifications/channels"), + ("预警规则", "/api/cma/alert-rules"), + ("行动计划", "/api/cma/action-plans"), + ("dashboard/KPI", "/api/cma/dashboard/kpis"), + ("dashboard/财务分析", "/api/cma/dashboard/finance-analysis"), + ("dashboard/总览", "/api/cma/dashboard/summary"), + ("dashboard/预测", "/api/cma/dashboard/predict"), + ("权限配置", "/api/cma/permissions/config"), + ("权限模块", "/api/cma/permissions/modules"), +] + +for name, path in endpoints: + cmd = f"curl -s '{path}' -H 'Authorization: Bearer {token}'" + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=15) + try: + data = json.loads(r.stdout) + if isinstance(data, list): + print(f" {name:25s} → {len(data)} 条记录") + elif isinstance(data, dict): + if 'detail' in data: + print(f" {name:25s} → ❌ {str(data['detail'])[:50]}") + else: + # 尝试找列表字段 + found = False + for k in ['data', 'items', 'records', 'modules', 'actions', 'roles', 'channels']: + if k in data and isinstance(data[k], list): + print(f" {name:25s} → {len(data[k])} 条 ({k})") + found = True + break + if not found: + preview = {k: str(v)[:60] for k, v in list(data.items())[:4]} + print(f" {name:25s} → dict: {preview}") + except: + print(f" {name:25s} → ⚠️ 解析失败: {r.stdout[:100]}") diff --git a/check_cma_detail.py b/check_cma_detail.py new file mode 100644 index 00000000..c8c85a6a --- /dev/null +++ b/check_cma_detail.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""管理会计OS数据详情检查""" +import json, urllib.request + +BASE = "http://127.0.0.1:8010" + +def login(): + data = json.dumps({"username":"admin","password":"admin123"}).encode() + req = urllib.request.Request(BASE + "/api/cma/auth/login", data=data, + headers={"Content-Type":"application/json"}, method="POST") + resp = json.loads(urllib.request.urlopen(req).read()) + return resp["token"] + +def api(path, token): + auth = "Bearer " + token + req = urllib.request.Request(BASE + path, headers={"Authorization": auth}) + resp = json.loads(urllib.request.urlopen(req).read()) + return resp + +token = login() + +# KPI详情 +print("="*60) +print("KPI定义(前5条)") +print("="*60) +data = api("/api/cma/kpis", token) +for kpi in data.get("data", data)[:5]: + print(" [%s] %s" % (kpi.get("code","?"), kpi.get("name","?"))) + print(" 维度:%s | 权重:%s | 单位:%s" % ( + kpi.get("dimension","?"), kpi.get("weight","?"), kpi.get("unit","?"))) + print(" 当前值:%s | 目标值:%s | 实际值:%s" % ( + kpi.get("current_value","-"), kpi.get("target_value","-"), kpi.get("actual_value","-"))) + if kpi.get("threshold_green"): + print(" 阈值: 绿<%s 黄<%s 红<%s" % ( + kpi.get("threshold_green",""), kpi.get("threshold_yellow",""), kpi.get("threshold_red",""))) + print() + +# 用户 +print("="*60) +print("用户列表") +print("="*60) +data = api("/api/cma/users", token) +if isinstance(data, dict): + # 可能是 {username: {...}} 格式 + users = [data[k] for k in data if isinstance(data[k], dict)] +else: + users = data +for u in users: + print(" %-12s | %-15s | %-10s | role_code=%s" % ( + u.get("username","?"), u.get("name","?"), u.get("role_name","?"), u.get("role","?"))) + +# 战略地图 +print() +print("="*60) +print("战略地图") +print("="*60) +data = api("/api/cma/maps", token) +for m in data.get("data", data): + print(" [%s] %s - %s, 状态:%s" % ( + m.get("id","?"), m.get("title","?"), m.get("period","?"), m.get("status","?"))) + +# 数据源 +print() +print("="*60) +print("数据源配置") +print("="*60) +data = api("/api/cma/data/sources", token) +for s in data.get("data", data): + print(" %s - 类型:%s, API:%s" % ( + s.get("name","?"), s.get("type","?"), s.get("api_endpoint","?"))) + +# 通知渠道 +print() +print("="*60) +print("通知渠道") +print("="*60) +data = api("/api/cma/notifications/channels", token) +for c in data.get("data", data): + print(" %s - %s, 启用:%s" % ( + c.get("name","?"), c.get("type","?"), c.get("enabled","?"))) + +# 预警规则 +print() +print("="*60) +print("预警规则(前8条)") +print("="*60) +data = api("/api/cma/alert-rules", token) +rules = data.get("data", data) +print(" 共 %d 条规则" % len(rules)) +for r in rules[:8]: + print(" %s - KPI:%s, 条件:%s %s" % ( + r.get("name","?"), r.get("kpi_code","?"), r.get("condition","?"), r.get("threshold","?"))) + +# 预警记录 +print() +print("="*60) +print("预警记录(前6条)") +print("="*60) +data = api("/api/cma/alerts", token) +for a in data.get("data", data)[:6]: + print(" [%s] %s" % (a.get("severity","?"), a.get("kpi_name","?"))) + print(" 值:%s/%s 阈值:%s 状态:%s 处理人:%s" % ( + a.get("current_value","?"), a.get("target_value","?"), + a.get("threshold","?"), a.get("status","?"), a.get("resolver","?"))) + +# 行动计划 +print() +print("="*60) +print("行动计划") +print("="*60) +data = api("/api/cma/action-plans", token) +for p in data.get("data", data): + print(" %s - KPI:%s, 状态:%s, 负责人:%s" % ( + p.get("title","?"), p.get("kpi_code","?"), p.get("status","?"), p.get("assignee","?"))) + +# Dashboard summary +print() +print("="*60) +print("Dashboard Summary") +print("="*60) +data = api("/api/cma/dashboard/summary", token) +print(" KPI总数:%s" % data.get("kpi_total")) +print(" 预警数:%s" % data.get("alert_count")) +ds = data.get("dimension_stats", {}) +print(" 维度统计: %s" % json.dumps(ds, ensure_ascii=False)) + +# 权限 +print() +print("="*60) +print("权限配置") +print("="*60) +data = api("/api/cma/permissions/config", token) +rp = data.get("route_permissions", {}) +print(" 路由权限(每个角色模块数):") +for role, modules in rp.items(): + print(" %-12s: %d 个模块" % (role, len(modules))) +ap = data.get("action_permissions", {}) +print(" 操作权限:") +for role, actions in ap.items(): + print(" %-12s: %s" % (role, actions)) diff --git a/check_raw.py b/check_raw.py new file mode 100644 index 00000000..2eb7a484 --- /dev/null +++ b/check_raw.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""查看原始API返回的key和样本数据""" +import json, urllib.request + +BASE = "http://127.0.0.1:8010" + +def login(): + data = json.dumps({"username":"admin","password":"admin123"}).encode() + req = urllib.request.Request(BASE + "/api/cma/auth/login", data=data, + headers={"Content-Type":"application/json"}, method="POST") + resp = json.loads(urllib.request.urlopen(req).read()) + return resp["token"] + +def api(path, token): + auth = "Bearer " + token + req = urllib.request.Request(BASE + path, headers={"Authorization": auth}) + resp = json.loads(urllib.request.urlopen(req).read()) + return resp + +token = login() + +# 看KPI完整结构 - 第一个元素 +print("="*60) +print("KPI - 第一个元素完整keys") +print("="*60) +data = api("/api/cma/kpis", token) +items = data.get("data", data) +if items and isinstance(items, list): + first = items[0] + for k, v in first.items(): + print(" %-25s: %s" % (k, str(v)[:80])) + +# 看用户结构 +print() +print("="*60) +print("Users - 原始结构") +print("="*60) +data = api("/api/cma/users", token) +print(" type: %s" % type(data)) +print(" keys: %s" % list(data.keys()) if isinstance(data, dict) else "list") +if isinstance(data, dict): + for k, v in list(data.items())[:3]: + if isinstance(v, dict): + print(" [%s] keys: %s" % (k, list(v.keys()))) + for k2, v2 in v.items(): + print(" %-20s: %s" % (k2, str(v2)[:60])) + else: + print(" [%s] = %s" % (k, str(v)[:80])) + +# 预警规则 +print() +print("="*60) +print("预警规则 - 第一个元素") +print("="*60) +data = api("/api/cma/alert-rules", token) +items = data.get("data", []) if isinstance(data, dict) else data +if items and isinstance(items, list): + first = items[0] + print(" type: %s, keys: %s" % (type(first), list(first.keys()))) + for k, v in first.items(): + print(" %-25s: %s" % (k, str(v)[:80])) + +# 预警记录 +print() +print("="*60) +print("预警记录 - 第一个元素") +print("="*60) +data = api("/api/cma/alerts", token) +items = data.get("data", []) if isinstance(data, dict) else data +if items and isinstance(items, list): + first = items[0] + print(" keys: %s" % list(first.keys())) + for k, v in first.items(): + print(" %-25s: %s" % (k, str(v)[:80])) + +# Dashboard summary完整 +print() +print("="*60) +print("Dashboard Summary 完整结构") +print("="*60) +data = api("/api/cma/dashboard/summary", token) +print(json.dumps(data, indent=2, ensure_ascii=False)[:1000]) diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 00000000..00c95b48 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# 管理会计OS — 一键构建并部署到生产 +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "===== 管理会计OS 生产部署 =====" +echo "" + +# 1. 构建前端 +echo "[1/4] 构建前端..." +cd "$SCRIPT_DIR/frontend" +pnpm build --no-frozen-lockfile +echo " ✓ 构建完成" + +# 2. 部署前端到 Nginx +echo "[2/4] 部署前端..." +rm -rf /var/www/cma/* +cp -r dist/* /var/www/cma/ +echo " ✓ 部署完成" + +# 3. 更新后端依赖 +echo "[3/4] 更新后端..." +cd "$SCRIPT_DIR/backend" +pip install -r requirements.txt --quiet --no-cache-dir +echo " ✓ 依赖更新完成" + +# 4. 重启后端服务 +echo "[4/4] 重启后端服务..." +systemctl daemon-reload +systemctl restart cma-backend +systemctl reload nginx 2>/dev/null || nginx -s reload +echo " ✓ 服务重启完成" + +echo "" +echo "===== 部署完成 =====" +echo " 前端: https://cma.sxbh.ltd" +echo " 后端: http://127.0.0.1:8010/docs" +echo " 后端状态: systemctl status cma-backend" diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..07593a71 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# 管理会计OS — 前端环境变量 +VITE_API_BASE=/api/cma +VITE_APP_TITLE=管理会计OS diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 00000000..cc294a48 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "tabWidth": 2, + "arrowParens": "always", + "endOfLine": "lf", + "vueIndentScriptAndStyle": true +} diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts new file mode 100644 index 00000000..9d240079 --- /dev/null +++ b/frontend/auto-imports.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + +} diff --git a/frontend/components.d.ts b/frontend/components.d.ts new file mode 100644 index 00000000..251e679d --- /dev/null +++ b/frontend/components.d.ts @@ -0,0 +1,63 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + ElAlert: typeof import('element-plus/es')['ElAlert'] + ElAside: typeof import('element-plus/es')['ElAside'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElContainer: typeof import('element-plus/es')['ElContainer'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElHeader: typeof import('element-plus/es')['ElHeader'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElMain: typeof import('element-plus/es')['ElMain'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElProgress: typeof import('element-plus/es')['ElProgress'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElTree: typeof import('element-plus/es')['ElTree'] + ElUpload: typeof import('element-plus/es')['ElUpload'] + MyDialog: typeof import('./src/components/MyDialog.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/frontend/deploy.sh b/frontend/deploy.sh new file mode 100755 index 00000000..95ba7d0e --- /dev/null +++ b/frontend/deploy.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# 管理会计OS — 前端生产构建+部署脚本 +set -e + +cd "$(dirname "$0")" + +echo "===== CMA 前端部署 =====" + +# 构建 +pnpm build +echo "[✓] 构建完成" + +# 部署到 Nginx 目录 +rm -rf /var/www/cma/* +cp -r dist/* /var/www/cma/ +nginx -s reload +echo "[✓] 部署到 /var/www/cma/ 并重载 Nginx" + +echo "===== 部署完成 =====" diff --git a/frontend/dev.sh b/frontend/dev.sh new file mode 100755 index 00000000..a12927a2 --- /dev/null +++ b/frontend/dev.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# 管理会计OS — 前端开发启动脚本 +set -e + +cd "$(dirname "$0")" + +# 检查 node_modules +if [ ! -d "node_modules" ]; then + echo "[*] 安装依赖..." + pnpm install --no-frozen-lockfile +fi + +echo "[✓] 启动前端开发服务器 (HMR)" +echo " http://localhost:5173" +exec pnpm dev diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..b6f0248f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + 管理会计OS + + + + + +

+ +
+ 加载中... +
+
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..962a3abd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,52 @@ +{ + "name": "fullstack-learn-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint src --ext .vue,.ts,.tsx --fix", + "format": "prettier --write \"src/**/*.{vue,ts,tsx,css,scss,json,md}\"", + "format:check": "prettier --check \"src/**/*.{vue,ts,tsx,css,scss,json,md}\"" + }, + "dependencies": { + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@element-plus/icons-vue": "^2.3.2", + "@vue-flow/background": "^1.3.2", + "@vue-flow/core": "^1.48.2", + "@vueuse/core": "^13.1.0", + "axios": "^1.15.2", + "codemirror": "^6.0.2", + "cropperjs": "^2.1.1", + "echarts": "^5.6.0", + "element-plus": "^2.13.7", + "esbuild": "^0.28.0", + "highlight.js": "^11.11.0", + "markdown-it": "^14.1.0", + "pinia": "^3.0.2", + "vue": "^3.5.32", + "vue-echarts": "^7.0.3", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@typescript-eslint/eslint-plugin": "^8.59.2", + "@typescript-eslint/parser": "^8.59.2", + "@vitejs/plugin-vue": "^6.0.6", + "@vue/tsconfig": "^0.9.1", + "eslint": "^10.2.1", + "eslint-plugin-vue": "^10.9.1", + "prettier": "^3.8.3", + "rollup": "^4.60.4", + "typescript": "~6.0.2", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^32.0.0", + "vite": "^5.0.0", + "vue-tsc": "^3.2.7" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..4a52fa22 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3377 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@codemirror/lang-html': + specifier: ^6.4.11 + version: 6.4.11 + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/lang-python': + specifier: ^6.2.1 + version: 6.2.1 + '@codemirror/theme-one-dark': + specifier: ^6.1.3 + version: 6.1.3 + '@element-plus/icons-vue': + specifier: ^2.3.2 + version: 2.3.2(vue@3.5.34(typescript@6.0.3)) + '@vue-flow/background': + specifier: ^1.3.2 + version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) + '@vue-flow/core': + specifier: ^1.48.2 + version: 1.48.2(vue@3.5.34(typescript@6.0.3)) + '@vueuse/core': + specifier: ^13.1.0 + version: 13.9.0(vue@3.5.34(typescript@6.0.3)) + axios: + specifier: ^1.15.2 + version: 1.16.1 + codemirror: + specifier: ^6.0.2 + version: 6.0.2 + cropperjs: + specifier: ^2.1.1 + version: 2.1.1 + echarts: + specifier: ^5.6.0 + version: 5.6.0 + element-plus: + specifier: ^2.13.7 + version: 2.14.0(vue@3.5.34(typescript@6.0.3)) + esbuild: + specifier: ^0.28.0 + version: 0.28.0 + highlight.js: + specifier: ^11.11.0 + version: 11.11.1 + markdown-it: + specifier: ^14.1.0 + version: 14.1.1 + pinia: + specifier: ^3.0.2 + version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) + vue: + specifier: ^3.5.32 + version: 3.5.34(typescript@6.0.3) + vue-echarts: + specifier: ^7.0.3 + version: 7.0.3(@vue/runtime-core@3.5.34)(echarts@5.6.0)(vue@3.5.34(typescript@6.0.3)) + vue-router: + specifier: ^4.6.4 + version: 4.6.4(vue@3.5.34(typescript@6.0.3)) + devDependencies: + '@types/node': + specifier: ^24.12.2 + version: 24.12.4 + '@typescript-eslint/eslint-plugin': + specifier: ^8.59.2 + version: 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3) + '@typescript-eslint/parser': + specifier: ^8.59.2 + version: 8.59.4(eslint@10.4.0)(typescript@6.0.3) + '@vitejs/plugin-vue': + specifier: ^6.0.6 + version: 6.0.7(vite@5.0.0(@types/node@24.12.4))(vue@3.5.34(typescript@6.0.3)) + '@vue/tsconfig': + specifier: ^0.9.1 + version: 0.9.1(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) + eslint: + specifier: ^10.2.1 + version: 10.4.0 + eslint-plugin-vue: + specifier: ^10.9.1 + version: 10.9.1(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(vue-eslint-parser@10.4.0(eslint@10.4.0)) + prettier: + specifier: ^3.8.3 + version: 3.8.3 + rollup: + specifier: ^4.60.4 + version: 4.60.4 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + unplugin-auto-import: + specifier: ^21.0.0 + version: 21.0.0(@vueuse/core@13.9.0(vue@3.5.34(typescript@6.0.3))) + unplugin-vue-components: + specifier: ^32.0.0 + version: 32.1.0(vue@3.5.34(typescript@6.0.3)) + vite: + specifier: ^5.0.0 + version: 5.0.0(@types/node@24.12.4) + vue-tsc: + specifier: ^3.2.7 + version: 3.3.1(typescript@6.0.3) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@codemirror/autocomplete@6.20.2': + resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/lint@6.9.6': + resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==} + + '@codemirror/search@6.7.0': + resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.0': + resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} + + '@cropper/element-canvas@2.1.1': + resolution: {integrity: sha512-QHiHnPHsykkc4salOiCLOIKWPK3sLQ1SzpJYFE/yQPvqTPu6ERZNcxVwB1x7WhNN+SdP0S0CEHPYIkgIfUo7VA==} + + '@cropper/element-crosshair@2.1.1': + resolution: {integrity: sha512-DtZnOiY2RZSizDZzMUvya3wZ55YnWclf3hmIrLW6jdcFlQkYEbX6bkOyOJzQmEX0yIbmwMkXfFcMkEZ9dE1rJQ==} + + '@cropper/element-grid@2.1.1': + resolution: {integrity: sha512-MZdPBh5QMU10T78nqG+5cK+gq4DHoWZvdOo+/wZ9/z3VH0u21sROeHOCnf0QAdxguPHSSFsPjzARK+ffS4Loog==} + + '@cropper/element-handle@2.1.1': + resolution: {integrity: sha512-TmSSphyMr2NEA0DqUTf9PCN8kcpzM0BxreOMyzbfuq1NUUePV1LxYgSOUT7dftMjZnTcu8cHQeFYa69U2BZnBA==} + + '@cropper/element-image@2.1.1': + resolution: {integrity: sha512-cldzN3hVJo0Luui+FDSIkmbQtwIjxWiTcTMLCMSi3hqIq6lc7YOBz+rC5R8AYnYV7YvsWonE6PrJZS+iZpBkdQ==} + + '@cropper/element-selection@2.1.1': + resolution: {integrity: sha512-QtMYhcuR+8JG3QEv8iSRCfQEVYljor61vLPrnGCFIkDJhvcyEWDx4H/Kfn6Jrdo90PyXf2VbpTG7TgH8i/EZFg==} + + '@cropper/element-shade@2.1.1': + resolution: {integrity: sha512-MhJVc3jC87TjQu4EfBnNFddL3LxvA+Wzjy1Qhyi0wNdeTAzs97uYJ3KwGVtvzPYP/4JaSU4JDpw+BfIdUqR0bg==} + + '@cropper/element-viewer@2.1.1': + resolution: {integrity: sha512-RpXTGW/rTtJVNd3/R3imdCBJzyZRhA/OeWNo7RID4qW7UYEiL6Lwh7Six3QhFIci/IaUAyL9NAAN10av2YWqog==} + + '@cropper/element@2.1.1': + resolution: {integrity: sha512-pthgIQq3PFAFRGUts96yrMgmMY/4rS/zKEq/Vvw7L0Ur09MgzrUg15z4k96K53bJ8XvNeXdQ0qIDw90gh4Xcug==} + + '@cropper/elements@2.1.1': + resolution: {integrity: sha512-cHShk4l6iExM4S6B1xx7+09gtDs+kdxWqe7Yv6DEbAyWhxVIVJyJAPpKlzLfA5MYnlSnkvCvY66hLLQPwooRMQ==} + + '@cropper/utils@2.1.1': + resolution: {integrity: sha512-0eQs08WyQUNFMfU3ieE091dEQkrW0YdgZ3n6Thnk1EUQv45ypfiL+MevHwr7UzNnUlqUYR2FNSCgE6qBxK0sFw==} + + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.19.3': + resolution: {integrity: sha512-w+Akc0vv5leog550kjJV9Ru+MXMR2VuMrui3C61mnysim0gkFCPOUTAfzTP0qX+HpN9Syu3YA3p1hf3EPqObRw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.19.3': + resolution: {integrity: sha512-Lemgw4io4VZl9GHJmjiBGzQ7ONXRfRPHcUEerndjwiSkbxzrpq0Uggku5MxxrXdwJ+pTj1qyw4jwTu7hkPsgIA==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.19.3': + resolution: {integrity: sha512-FKQJKkK5MXcBHoNZMDNUAg1+WcZlV/cuXrWCoGF/TvdRiYS4znA0m5Il5idUwfxrE20bG/vU1Cr5e1AD6IEIjQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.19.3': + resolution: {integrity: sha512-kw7e3FXU+VsJSSSl2nMKvACYlwtvZB8RUIeVShIEY6PVnuZ3c9+L9lWB2nWeeKWNNYDdtL19foCQ0ZyUL7nqGw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.3': + resolution: {integrity: sha512-tPfZiwF9rO0jW6Jh9ipi58N5ZLoSjdxXeSrAYypy4psA2Yl1dAMhM71KxVfmjZhJmxRjSnb29YlRXXhh3GqzYw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.19.3': + resolution: {integrity: sha512-ERDyjOgYeKe0Vrlr1iLrqTByB026YLPzTytDTz1DRCYM+JI92Dw2dbpRHYmdqn6VBnQ9Bor6J8ZlNwdZdxjlSg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.3': + resolution: {integrity: sha512-nXesBZ2Ad1qL+Rm3crN7NmEVJ5uvfLFPLJev3x1j3feCQXfAhoYrojC681RhpdOph8NsvKBBwpYZHR7W0ifTTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.19.3': + resolution: {integrity: sha512-qXvYKmXj8GcJgWq3aGvxL/JG1ZM3UR272SdPU4QSTzD0eymrM7leiZH77pvY3UetCy0k1xuXZ+VPvoJNdtrsWQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.19.3': + resolution: {integrity: sha512-zr48Cg/8zkzZCzDHNxXO/89bf9e+r4HtzNUPoz4GmgAkF1gFAFmfgOdCbR8zMbzFDGb1FqBBhdXUpcTQRYS1cQ==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.19.3': + resolution: {integrity: sha512-7XlCKCA0nWcbvYpusARWkFjRQNWNGlt45S+Q18UeS///K6Aw8bB2FKYe9mhVWy/XLShvCweOLZPrnMswIaDXQA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.19.3': + resolution: {integrity: sha512-qGTgjweER5xqweiWtUIDl9OKz338EQqCwbS9c2Bh5jgEH19xQ1yhgGPNesugmDFq+UUSDtWgZ264st26b3de8A==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.19.3': + resolution: {integrity: sha512-gy1bFskwEyxVMFRNYSvBauDIWNggD6pyxUksc0MV9UOBD138dKTzr8XnM2R4mBsHwVzeuIH8X5JhmNs2Pzrx+A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.19.3': + resolution: {integrity: sha512-UrYLFu62x1MmmIe85rpR3qou92wB9lEXluwMB/STDzPF9k8mi/9UvNsG07Tt9AqwPQXluMQ6bZbTzYt01+Ue5g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.3': + resolution: {integrity: sha512-9E73TfyMCbE+1AwFOg3glnzZ5fBAFK4aawssvuMgCRqCYzE0ylVxxzjEfut8xjmKkR320BEoMui4o/t9KA96gA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.19.3': + resolution: {integrity: sha512-LlmsbuBdm1/D66TJ3HW6URY8wO6IlYHf+ChOUz8SUAjVTuaisfuwCOAgcxo3Zsu3BZGxmI7yt//yGOxV+lHcEA==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.19.3': + resolution: {integrity: sha512-ogV0+GwEmvwg/8ZbsyfkYGaLACBQWDvO0Kkh8LKBGKj9Ru8VM39zssrnu9Sxn1wbapA2qNS6BiLdwJZGouyCwQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.19.3': + resolution: {integrity: sha512-o1jLNe4uzQv2DKXMlmEzf66Wd8MoIhLNO2nlQBHLtWyh2MitDG7sMpfCO3NTcoTMuqHjfufgUQDFRI5C+xsXQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.19.3': + resolution: {integrity: sha512-AZJCnr5CZgZOdhouLcfRdnk9Zv6HbaBxjcyhq0StNcvAdVZJSKIdOiPB9az2zc06ywl0ePYJz60CjdKsQacp5Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.19.3': + resolution: {integrity: sha512-Acsujgeqg9InR4glTRvLKGZ+1HMtDm94ehTIHKhJjFpgVzZG9/pIcWW/HA/DoMfEyXmANLDuDZ2sNrWcjq1lxw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.19.3': + resolution: {integrity: sha512-FSrAfjVVy7TifFgYgliiJOyYynhQmqgPj15pzLyJk8BUsnlWNwP/IAy6GAiB1LqtoivowRgidZsfpoYLZH586A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.19.3': + resolution: {integrity: sha512-xTScXYi12xLOWZ/sc5RBmMN99BcXp/eEf7scUC0oeiRoiT5Vvo9AycuqCp+xdpDyAU+LkrCqEpUS9fCSZF8J3Q==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.19.3': + resolution: {integrity: sha512-FbUN+0ZRXsypPyWE2IwIkVjDkDnJoMJARWOcFZn4KPPli+QnKqF0z1anvfaYe3ev5HFCpRDLLBDHyOALLppWHw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/css@1.3.3': + resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/python@1.1.18': + resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/web-bluetooth@0.0.18': + resolution: {integrity: sha512-v/ZHEj9xh82usl8LMR3GarzFY1IrbXJw5L4QfQhokjRV91q+SelFqxQWSep1ucXEZ22+dSTwLFkXeur25sPIbw==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-vue@6.0.7': + resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue-flow/background@1.3.2': + resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/core@1.48.2': + resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==} + peerDependencies: + vue: ^3.3.0 + + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + + '@vue/compiler-sfc@3.5.34': + resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} + + '@vue/compiler-ssr@3.5.34': + resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/language-core@3.3.1': + resolution: {integrity: sha512-NP8g6V7x81NVOXbLupUvYY6i6LqUkjkVowe2epRedmpgaFCOdjgWHE/rQBvEJ4r7koAYODIjGeBWEdt6n7jYXQ==} + + '@vue/reactivity@3.5.34': + resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} + + '@vue/runtime-core@3.5.34': + resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} + + '@vue/runtime-dom@3.5.34': + resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} + + '@vue/server-renderer@3.5.34': + resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} + peerDependencies: + vue: 3.5.34 + + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + + '@vue/tsconfig@0.9.1': + resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==} + peerDependencies: + typescript: '>= 5.8' + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@vueuse/core@10.5.0': + resolution: {integrity: sha512-z/tI2eSvxwLRjOhDm0h/SXAjNm8N5ld6/SC/JQs6o6kpJ6Ya50LnEL8g5hoYu005i28L0zqB5L5yAl8Jl26K3A==} + + '@vueuse/core@13.9.0': + resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@10.5.0': + resolution: {integrity: sha512-fEbElR+MaIYyCkeM0SzWkdoMtOpIwO72x8WsZHRE7IggiOlILttqttM69AS13nrDxosnDBYdyy3C5mR1LCxHsw==} + + '@vueuse/metadata@13.9.0': + resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==} + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@10.5.0': + resolution: {integrity: sha512-18iyxbbHYLst9MqU1X1QNdMHIjks6wC7XTVf0KNOv5es/Ms6gjVFCAAWTVP2JStuGqydg3DT+ExpFORUEi9yhg==} + + '@vueuse/shared@13.9.0': + resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + cropperjs@2.1.1: + resolution: {integrity: sha512-FDJMarkY+/SepYarPZsvkG2LmI2PElecciMFnvBiBIoKnFYua/scprC5qejCLLyuX2jEqJRS2njbAsHxfjtIXA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + + element-plus@2.14.0: + resolution: {integrity: sha512-POgH+TtoreaEKWqYYAVQyE6i8rQMEFqAEublyF29dBA5yASWPLKY6EzfeqBTr2Uv26mPss4vSrMrNPyaK7LX5w==} + peerDependencies: + vue: ^3.3.7 + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.19.3: + resolution: {integrity: sha512-UlJ1qUUA2jL2nNib1JTSkifQTcYTroFqRjwCFW4QYEKEsixXD5Tik9xML7zh2gTxkYTBKGHNH9y7txMwVyPbjw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-plugin-vue@10.9.1: + resolution: {integrity: sha512-cHB0Tf4Duvzwecwd/AqWzZvF/QszE13BhjVUpVXWCy9AeMR5GjkAjP3i85vqgLgOuTmkHR1OJ5oMeqLHtuw8zg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.3.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.4.0: + resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unimport@5.7.0: + resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} + engines: {node: '>=18.12.0'} + + unplugin-auto-import@21.0.0: + resolution: {integrity: sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^4.0.0 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@32.1.0: + resolution: {integrity: sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^3.2.2 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@5.0.0: + resolution: {integrity: sha512-ESJVM59mdyGpsiNAeHQOR/0fqNoOyWPYesFto8FFZugfmhdHx8Fzd8sF3Q/xkVhZsyOxHfdM7ieiVAorI9RjFw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-component-type-helpers@3.3.1: + resolution: {integrity: sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==} + + vue-demi@0.13.11: + resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-echarts@7.0.3: + resolution: {integrity: sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==} + peerDependencies: + '@vue/runtime-core': ^3.0.0 + echarts: ^5.5.1 + vue: ^2.7.0 || ^3.1.1 + peerDependenciesMeta: + '@vue/runtime-core': + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@3.3.1: + resolution: {integrity: sha512-webBP3jhlxzhELZ2g+11KJ6pg5OVY1xWhWrj7N/yQMi1CrtxJnW+tUACyRVeDK0cQNLP2Va5HNYK8pe+7c+msw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.34: + resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@codemirror/autocomplete@6.20.2': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.18 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.6': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + crelt: 1.0.6 + + '@codemirror/search@6.7.0': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.0': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@cropper/element-canvas@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/utils': 2.1.1 + + '@cropper/element-crosshair@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/utils': 2.1.1 + + '@cropper/element-grid@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/utils': 2.1.1 + + '@cropper/element-handle@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/utils': 2.1.1 + + '@cropper/element-image@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/element-canvas': 2.1.1 + '@cropper/utils': 2.1.1 + + '@cropper/element-selection@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/element-canvas': 2.1.1 + '@cropper/element-image': 2.1.1 + '@cropper/utils': 2.1.1 + + '@cropper/element-shade@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/element-canvas': 2.1.1 + '@cropper/element-selection': 2.1.1 + '@cropper/utils': 2.1.1 + + '@cropper/element-viewer@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/element-canvas': 2.1.1 + '@cropper/element-image': 2.1.1 + '@cropper/element-selection': 2.1.1 + '@cropper/utils': 2.1.1 + + '@cropper/element@2.1.1': + dependencies: + '@cropper/utils': 2.1.1 + + '@cropper/elements@2.1.1': + dependencies: + '@cropper/element': 2.1.1 + '@cropper/element-canvas': 2.1.1 + '@cropper/element-crosshair': 2.1.1 + '@cropper/element-grid': 2.1.1 + '@cropper/element-handle': 2.1.1 + '@cropper/element-image': 2.1.1 + '@cropper/element-selection': 2.1.1 + '@cropper/element-shade': 2.1.1 + '@cropper/element-viewer': 2.1.1 + + '@cropper/utils@2.1.1': {} + + '@ctrl/tinycolor@4.2.0': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.34(typescript@6.0.3))': + dependencies: + vue: 3.5.34(typescript@6.0.3) + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.19.3': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.19.3': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.19.3': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.19.3': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.19.3': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.19.3': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.19.3': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.19.3': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.19.3': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.19.3': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.19.3': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.19.3': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.19.3': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.19.3': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.19.3': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.19.3': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.19.3': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.19.3': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.19.3': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.19.3': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.19.3': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.19.3': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0)': + dependencies: + eslint: 10.4.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lezer/common@1.5.2': {} + + '@lezer/css@1.3.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/python@1.1.18': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@marijn/find-cluster-break@1.0.2': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@sxzz/popperjs-es@2.11.8': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/web-bluetooth@0.0.18': {} + + '@types/web-bluetooth@0.0.21': {} + + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 10.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + eslint: 10.4.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.59.4(eslint@10.4.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.4': {} + + '@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.4(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.0 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.4(eslint@10.4.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + eslint: 10.4.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-vue@6.0.7(vite@5.0.0(@types/node@24.12.4))(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 5.0.0(@types/node@24.12.4) + vue: 3.5.34(typescript@6.0.3) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.34(typescript@6.0.3)) + vue: 3.5.34(typescript@6.0.3) + + '@vue-flow/core@1.48.2(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vueuse/core': 10.5.0(vue@3.5.34(typescript@6.0.3)) + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.34(typescript@6.0.3) + transitivePeerDependencies: + - '@vue/composition-api' + + '@vue/compiler-core@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.34': + dependencies: + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/compiler-sfc@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.34': + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/language-core@3.3.1': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.4 + + '@vue/reactivity@3.5.34': + dependencies: + '@vue/shared': 3.5.34 + + '@vue/runtime-core@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/runtime-dom@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/runtime-core': 3.5.34 + '@vue/shared': 3.5.34 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + vue: 3.5.34(typescript@6.0.3) + + '@vue/shared@3.5.34': {} + + '@vue/tsconfig@0.9.1(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))': + optionalDependencies: + typescript: 6.0.3 + vue: 3.5.34(typescript@6.0.3) + + '@vueuse/core@10.5.0(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.18 + '@vueuse/metadata': 10.5.0 + '@vueuse/shared': 10.5.0(vue@3.5.34(typescript@6.0.3)) + vue-demi: 0.14.10(vue@3.5.34(typescript@6.0.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/core@13.9.0(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 13.9.0 + '@vueuse/shared': 13.9.0(vue@3.5.34(typescript@6.0.3)) + vue: 3.5.34(typescript@6.0.3) + + '@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.34(typescript@6.0.3)) + vue: 3.5.34(typescript@6.0.3) + + '@vueuse/metadata@10.5.0': {} + + '@vueuse/metadata@13.9.0': {} + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@10.5.0(vue@3.5.34(typescript@6.0.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.34(typescript@6.0.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/shared@13.9.0(vue@3.5.34(typescript@6.0.3))': + dependencies: + vue: 3.5.34(typescript@6.0.3) + + '@vueuse/shared@14.3.0(vue@3.5.34(typescript@6.0.3))': + dependencies: + vue: 3.5.34(typescript@6.0.3) + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + alien-signals@3.2.1: {} + + argparse@2.0.1: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.16.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@4.0.4: {} + + birpc@2.9.0: {} + + boolbase@1.0.0: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/search': 6.7.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + crelt@1.0.6: {} + + cropperjs@2.1.1: + dependencies: + '@cropper/elements': 2.1.1 + '@cropper/utils': 2.1.1 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + dayjs@1.11.20: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + + element-plus@2.14.0(vue@3.5.34(typescript@6.0.3)): + dependencies: + '@ctrl/tinycolor': 4.2.0 + '@element-plus/icons-vue': 2.3.2(vue@3.5.34(typescript@6.0.3)) + '@floating-ui/dom': 1.7.6 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.24 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 14.3.0(vue@3.5.34(typescript@6.0.3)) + async-validator: 4.2.5 + dayjs: 1.11.20 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.34(typescript@6.0.3) + vue-component-type-helpers: 3.3.1 + + entities@4.5.0: {} + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.19.3: + optionalDependencies: + '@esbuild/android-arm': 0.19.3 + '@esbuild/android-arm64': 0.19.3 + '@esbuild/android-x64': 0.19.3 + '@esbuild/darwin-arm64': 0.19.3 + '@esbuild/darwin-x64': 0.19.3 + '@esbuild/freebsd-arm64': 0.19.3 + '@esbuild/freebsd-x64': 0.19.3 + '@esbuild/linux-arm': 0.19.3 + '@esbuild/linux-arm64': 0.19.3 + '@esbuild/linux-ia32': 0.19.3 + '@esbuild/linux-loong64': 0.19.3 + '@esbuild/linux-mips64el': 0.19.3 + '@esbuild/linux-ppc64': 0.19.3 + '@esbuild/linux-riscv64': 0.19.3 + '@esbuild/linux-s390x': 0.19.3 + '@esbuild/linux-x64': 0.19.3 + '@esbuild/netbsd-x64': 0.19.3 + '@esbuild/openbsd-x64': 0.19.3 + '@esbuild/sunos-x64': 0.19.3 + '@esbuild/win32-arm64': 0.19.3 + '@esbuild/win32-ia32': 0.19.3 + '@esbuild/win32-x64': 0.19.3 + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-plugin-vue@10.9.1(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0)(vue-eslint-parser@10.4.0(eslint@10.4.0)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) + eslint: 10.4.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.8.0 + vue-eslint-parser: 10.4.0(eslint@10.4.0) + xml-name-validator: 4.0.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0)(typescript@6.0.3) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + exsolve@1.0.8: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + highlight.js@11.11.1: {} + + hookable@5.5.3: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-what@5.5.0: {} + + isexe@2.0.0: {} + + js-tokens@9.0.1: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + math-intrinsics@1.1.0: {} + + mdurl@2.0.0: {} + + memoize-one@6.0.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + normalize-wheel-es@1.2.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.1.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + vue: 3.5.34(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + proxy-from-env@2.1.0: {} + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + readdirp@5.0.0: {} + + rfdc@1.4.1: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + scule@1.3.0: {} + + semver@7.8.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-mod@4.1.3: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.3.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@6.0.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.4: {} + + undici-types@7.16.0: {} + + unimport@5.7.0: + dependencies: + acorn: 8.16.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.16 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + + unplugin-auto-import@21.0.0(@vueuse/core@13.9.0(vue@3.5.34(typescript@6.0.3))): + dependencies: + local-pkg: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.4 + unimport: 5.7.0 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + optionalDependencies: + '@vueuse/core': 13.9.0(vue@3.5.34(typescript@6.0.3)) + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@32.1.0(vue@3.5.34(typescript@6.0.3)): + dependencies: + chokidar: 5.0.0 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + obug: 2.1.1 + picomatch: 4.0.4 + tinyglobby: 0.2.16 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + vue: 3.5.34(typescript@6.0.3) + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite@5.0.0(@types/node@24.12.4): + dependencies: + esbuild: 0.19.3 + postcss: 8.5.15 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + + vscode-uri@3.1.0: {} + + vue-component-type-helpers@3.3.1: {} + + vue-demi@0.13.11(vue@3.5.34(typescript@6.0.3)): + dependencies: + vue: 3.5.34(typescript@6.0.3) + + vue-demi@0.14.10(vue@3.5.34(typescript@6.0.3)): + dependencies: + vue: 3.5.34(typescript@6.0.3) + + vue-echarts@7.0.3(@vue/runtime-core@3.5.34)(echarts@5.6.0)(vue@3.5.34(typescript@6.0.3)): + dependencies: + echarts: 5.6.0 + vue: 3.5.34(typescript@6.0.3) + vue-demi: 0.13.11(vue@3.5.34(typescript@6.0.3)) + optionalDependencies: + '@vue/runtime-core': 3.5.34 + transitivePeerDependencies: + - '@vue/composition-api' + + vue-eslint-parser@10.4.0(eslint@10.4.0): + dependencies: + debug: 4.4.3 + eslint: 10.4.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.0 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.34(typescript@6.0.3) + + vue-tsc@3.3.1(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.1 + typescript: 6.0.3 + + vue@3.5.34(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-sfc': 3.5.34 + '@vue/runtime-dom': 3.5.34 + '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3)) + '@vue/shared': 3.5.34 + optionalDependencies: + typescript: 6.0.3 + + w3c-keyname@2.2.8: {} + + webpack-virtual-modules@0.6.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + xml-name-validator@4.0.0: {} + + yocto-queue@0.1.0: {} + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 00000000..c9d0ac60 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true + vue-demi: set this to true or false diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 00000000..cd9578a2 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,6 @@ + + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 00000000..5a1d2d38 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,128 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api/cma', + timeout: 15000, +}) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('cma_token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +api.interceptors.response.use( + (response) => response.data, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('cma_token') + localStorage.removeItem('cma_user') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +export const authApi = { + login: (data: any) => api.post('/auth/login', data), + register: (data: any) => api.post('/auth/register', data), +} + +export const kpiApi = { + list: (params?: any) => api.get('/kpis', { params }), + get: (id: number) => api.get(`/kpis/${id}`), + create: (data: any) => api.post('/kpis', data), + update: (id: number, data: any) => api.put(`/kpis/${id}`, data), + delete: (id: number) => api.delete(`/kpis/${id}`), + listCategories: () => api.get('/kpis/categories'), +} + +export const mapApi = { + list: () => api.get('/maps'), + create: (data: any) => api.post('/maps', data), + update: (id: number, data: any) => api.put(`/maps/${id}`, data), + createWithTemplate: (data: any) => api.post('/maps/create-with-template', data), +} + +export const dashboardApi = { + summary: (role: string) => api.get('/dashboard/summary', { params: { role } }), + kpis: (params: any) => api.get('/dashboard/kpis', { params }), + myKpis: (params?: any) => api.get('/dashboard/my-kpis', { params }), + financeAnalysis: (params?: any) => api.get('/dashboard/finance-analysis', { params }), + predict: (params?: any) => api.get('/dashboard/predict', { params }), +} + +export const dataApi = { + importExcel: (file: File) => { + const form = new FormData() + form.append('file', file) + return api.post('/data/import-excel', form) + }, + listSources: () => api.get('/data/sources'), + createSource: (data: any) => api.post('/data/sources', data), + updateSource: (id: number, data: any) => api.put(`/data/sources/${id}`, data), + deleteSource: (id: number) => api.delete(`/data/sources/${id}`), +} + +export const alertApi = { + list: (params?: any) => api.get('/alerts', { params }), + resolve: (id: number, data: any) => api.post(`/alerts/${id}/resolve`, data), +} + +export const userApi = { + list: () => api.get('/users'), + create: (data: any) => api.post('/users', data), + update: (id: number, data: any) => api.put(`/users/${id}`, data), + delete: (id: number) => api.delete(`/users/${id}`), +} + +export const notificationApi = { + list: () => api.get('/notifications/channels'), + create: (data: any) => api.post('/notifications/channels', data), + update: (id: number, data: any) => api.put(`/notifications/channels/${id}`, data), + delete: (id: number) => api.delete(`/notifications/channels/${id}`), + test: (id: number) => api.post(`/notifications/channels/${id}/test`), + logs: (params?: any) => api.get('/notifications/logs', { params }), +} + +export const actionPlanApi = { + list: (params?: any) => api.get('/action-plans', { params }), + create: (data: any) => api.post('/action-plans', data), + update: (id: number, data: any) => api.put(`/action-plans/${id}`, data), + delete: (id: number) => api.delete(`/action-plans/${id}`), +} + +export const budgetApi = { + list: (params?: any) => api.get('/budget/plans', { params }), + create: (data: any) => api.post('/budget/plans', data), + update: (id: number, data: any) => api.put(`/budget/plans/${id}`, data), + delete: (id: number) => api.delete(`/budget/plans/${id}`), + autoDecompose: (data: any) => api.post('/budget/auto-decompose', data), + deviationReport: (params?: any) => api.get('/budget/deviation-report', { params }), +} + +export const costApi = { + dashboard: (params?: any) => api.get('/cost/dashboard', { params }), + overview: (params?: any) => api.get('/cost/overview', { params }), + variance: (params?: any) => api.get('/cost/variance', { params }), + breakdown: (params?: any) => api.get('/cost/breakdown', { params }), + listStandardCosts: (params?: any) => api.get('/cost/standard-costs', { params }), + createStandardCost: (data: any) => api.post('/cost/standard-costs', data), + updateStandardCost: (id: number, data: any) => api.put(`/cost/standard-costs/${id}`, data), + deleteStandardCost: (id: number) => api.delete(`/cost/standard-costs/${id}`), + listActualCosts: (params?: any) => api.get('/cost/actual-costs', { params }), + createActualCost: (data: any) => api.post('/cost/actual-costs', data), + listAbcActivities: () => api.get('/cost/abc/activities'), + createAbcActivity: (data: any) => api.post('/cost/abc/activities', data), + doAbcAllocate: (data: any) => api.post('/cost/abc/allocate', data), + listAbcAllocations: (params?: any) => api.get('/cost/abc/allocations', { params }), +} + +export const predictApi = { + cvp: (data: any) => api.post('/predict/cvp', data), + investment: (data: any) => api.post('/predict/investment', data), + sensitivity: (data: any) => api.post('/predict/sensitivity', data), + scenario: (data: any) => api.post('/predict/scenario', data), +} + +export default api diff --git a/frontend/src/api/orgApi.ts b/frontend/src/api/orgApi.ts new file mode 100644 index 00000000..875274a9 --- /dev/null +++ b/frontend/src/api/orgApi.ts @@ -0,0 +1,10 @@ + +import api from "./index" +export const orgApi = { + tree: () => api.get("/org/tree"), + list: () => api.get("/org/nodes"), + create: (data: any) => api.post("/org/nodes", data), + update: (id: number, data: any) => api.put(`/org/nodes/${id}`, data), + delete: (id: number) => api.delete(`/org/nodes/${id}`), + toggle: (id: number) => api.put(`/org/nodes/${id}/toggle`), +} diff --git a/frontend/src/components/MyDialog.vue b/frontend/src/components/MyDialog.vue new file mode 100644 index 00000000..0fbd3e9f --- /dev/null +++ b/frontend/src/components/MyDialog.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 00000000..f38bdbad --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,10 @@ +/// +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} +declare module 'markdown-it' { + const content: any + export default content +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 00000000..94fc3536 --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 00000000..0c54ee6c --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,26 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) + +const usedIcons = ['Monitor', 'Document', 'TrendCharts', 'WarningFilled', 'Connection', 'User', 'Bell', 'Menu', 'Setting', 'Expand', 'Fold', 'UploadFilled'] +for (const key of usedIcons) { + const component = (ElementPlusIconsVue as any)[key] + if (component) app.component(key, component) +} + +app.use(ElementPlus, { locale: zhCn }) +app.use(createPinia()) +app.use(router) + +// 应用挂载后立即清理可能残留的弹窗遮罩 +app.mount('#app') +setTimeout(() => { + document.querySelectorAll('.el-overlay').forEach(el => el.remove()) +}, 200) diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts new file mode 100644 index 00000000..59d35738 --- /dev/null +++ b/frontend/src/permission.ts @@ -0,0 +1,87 @@ +/** + * 角色权限配置 — 管理会计OS + * 4角色: ceo(总经理), finance(财务), business(业务), it(IT运维) + */ + +// 各角色可访问的路由列表 +export const ROLE_ROUTES: Record = { + ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide'], + finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide'], + business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/deviations', '/budget', '/action-plans', '/knowledge', '/guide'], + it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide'], +} + +// 可操作的CRUD权限 +export const ROLE_ACTIONS: Record = { + ceo: ['read'], + finance: ['read', 'write', 'import', 'export'], + business: ['read', 'write'], + it: ['read', 'write', 'delete', 'admin'], +} + +// 菜单项定义(带角色限制) +export interface MenuItem { + path: string + label: string + icon: string + roles: string[] + group?: string // 分组标识,仅首位项标记 +} + +export const MENU_ITEMS: MenuItem[] = [ + // ── 个人视角 ── + { path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '数据基础' }, + + // ── 数据基础 ── + { path: '/kpis', label: 'KPI字典', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '数据基础' }, + { path: '/data', label: '数据管理', icon: 'Connection', roles: ['ceo', 'finance', 'it'] }, + + // ── 分析决策 ── + { path: '/dashboard', label: '驾驶舱', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '分析决策' }, + { path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'] }, + { path: '/maps-review', label: '战略回顾会', icon: 'TrendCharts', roles: ['ceo', 'finance'] }, + { path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'] }, + { path: '/cost', label: '成本分析', icon: 'Money', roles: ['ceo', 'finance', 'it'] }, + { path: '/predict', label: '预测模拟', icon: 'DataLine', roles: ['ceo', 'finance', 'it'] }, + { path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'] }, + + // ── 行动管理 ── + { path: '/alerts', label: '预警中心', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '行动管理' }, + { path: '/action-plans', label: '改善行动', icon: 'Edit', roles: ['ceo', 'finance', 'business', 'it'] }, + + // ── 系统管理 ── + { path: '/org', label: '组织管理', icon: 'Collection', roles: ['ceo', 'it'], group: '系统管理' }, + { path: '/users', label: '用户管理', icon: 'User', roles: ['ceo', 'it'] }, + { path: '/notifications', label: '通知配置', icon: 'Bell', roles: ['ceo', 'it'] }, + { path: '/permissions', label: '系统设置', icon: 'Setting', roles: ['ceo', 'it'] }, + + // ── 帮助支持 ── + { path: '/knowledge', label: 'CMA知识库', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '帮助支持' }, + { path: '/guide', label: '新手引导', icon: 'Edit', roles: ['ceo', 'finance', 'business', 'it'] }, +] + +// 角色名称映射 +export const ROLE_LABELS: Record = { + ceo: '总经理', + finance: '财务部', + business: '业务部', + it: 'IT部', +} + +/** + * 检查用户是否有权访问某路由 + */ +export function hasRouteAccess(role: string, path: string): boolean { + const allowed = ROLE_ROUTES[role] + if (!allowed) return false + // 精确匹配或前缀匹配(如 /kpis/123 匹配 /kpis) + return allowed.some(r => path === r || path.startsWith(r + '/')) +} + +/** + * 检查用户是否有指定操作权限 + */ +export function hasAction(role: string, action: string): boolean { + const actions = ROLE_ACTIONS[role] + return actions ? actions.includes(action) : false +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 00000000..541e6af1 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,95 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { hasRouteAccess } from '../permission' + +const routes = [ + { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { noAuth: true } }, + { path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/dashboard', + children: [ + { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '驾驶舱', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'] } }, + { path: 'maps-review', name: 'MapReviewList', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'] } }, + { path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'] } }, + { path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'] } }, + { path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'guide', name: 'NewUserGuide', component: () => import('@/views/NewUserGuide.vue'), meta: { title: '新手引导', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'alerts', name: 'Alerts', component: () => import('@/views/AlertList.vue'), meta: { title: '预警中心', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'data', name: 'Data', component: () => import('@/views/DataManage.vue'), meta: { title: '数据管理', roles: ['ceo', 'finance', 'it'] } }, + { path: 'users', name: 'Users', component: () => import('@/views/UserManage.vue'), meta: { title: '用户管理', roles: ['ceo', 'it'] } }, + { path: 'notifications', name: 'Notifications', component: () => import('@/views/NotificationManage.vue'), meta: { title: '通知配置', roles: ['ceo', 'it'] } }, + { path: 'permissions', name: 'RolePermissions', component: () => import('@/views/RolePermissions.vue'), meta: { title: '系统设置', roles: ['ceo', 'it'] } }, + { path: 'budget', name: 'BudgetManagement', component: () => import('@/views/BudgetManagement.vue'), meta: { title: '预算管理', roles: ['ceo', 'finance', 'it'] } }, + { path: 'org', name: 'OrgManage', component: () => import('@/views/OrgManage.vue'), meta: { title: '组织管理', roles: ['ceo', 'it'] } }, + { path: 'deviations', name: 'DeviationDashboard', component: () => import('@/views/DeviationDashboard.vue'), meta: { title: '差异分析', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } }, + { path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } }, + { path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/AlertList.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'], tab: 'plans' } }, + ] + }, +] + +const router = createRouter({ history: createWebHistory(), routes }) + +router.beforeEach((to, _from, next) => { + // 路由切换时清理所有残留的弹窗遮罩 + document.querySelectorAll('.el-overlay').forEach(el => el.remove()) + + const token = localStorage.getItem('cma_token') + const userStr = localStorage.getItem('cma_user') + + // 不需要登录的页面(如登录页) + if (to.meta.noAuth) { + if (token && to.path === '/login') { + next('/dashboard') + } else { + next() + } + return + } + + // 检查登录 + if (!token || !userStr) { + next('/login') + return + } + + // 检查角色权限 + try { + const user = JSON.parse(userStr) + const role = user.role || '' + const routeRoles = to.meta.roles as string[] | undefined + + if (routeRoles && !routeRoles.includes(role)) { + // 权限不足,跳转到驾驶舱或401页 + next('/dashboard') + return + } + } catch { + next('/login') + return + } + + next() +}) + +router.afterEach(() => { + // 只清理残留的孤立遮罩(没有对应dialog的),不干扰正常弹窗 + setTimeout(() => { + document.querySelectorAll('.el-overlay').forEach(el => { + // 如果这个遮罩没有对应的dialog,说明是残留的,才删除 + const dialogId = el.getAttribute('aria-describedby') + if (!dialogId || !document.querySelector(`#${dialogId}`)) { + // 等待一帧确保 dialog 已渲染 + requestAnimationFrame(() => { + if (!el.parentElement?.querySelector('.el-dialog')) { + el.remove() + } + }) + } + }) + }, 100) +}) + +export default router diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 00000000..a0988412 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,356 @@ +/* ===== 博海网络科技 · 全栈学习平台 — 全局响应式样式 ===== */ + +/* === Reset === */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* === CSS 自定义属性(设计令牌) === */ +:root { + /* 颜色系统 */ + --bh-primary: #409eff; + --bh-primary-dark: #337ecc; + --bh-primary-light: #a0cfff; + --bh-success: #67c23a; + --bh-warning: #e6a23c; + --bh-danger: #f56c6c; + --bh-info: #909399; + + --bh-bg-main: #f0f2f5; + --bh-bg-card: #ffffff; + --bh-bg-dark: #304156; + --bh-bg-dark-light: #3a4a5e; + + --bh-text-primary: #303133; + --bh-text-regular: #606266; + --bh-text-secondary: #909399; + --bh-text-placeholder: #c0c4cc; + + --bh-border: #e4e7ed; + --bh-border-light: #ebeef5; + + /* 侧边栏暗色(永恒不变,无论明暗模式) */ + --sidebar-bg: #304156; + --sidebar-bg-hover: #263445; + --sidebar-text: #bfcbd9; + --sidebar-text-active: #409eff; + --sidebar-logo-bg: #2b3a4a; + + /* 间距系统 */ + --bh-space-xs: 4px; + --bh-space-sm: 8px; + --bh-space-md: 12px; + --bh-space-lg: 16px; + --bh-space-xl: 20px; + --bh-space-2xl: 24px; + + /* 圆角 */ + --bh-radius-sm: 4px; + --bh-radius-md: 8px; + --bh-radius-lg: 12px; + + /* 阴影 */ + --bh-shadow-sm: 0 1px 2px rgba(0,0,0,0.06); + --bh-shadow-md: 0 2px 8px rgba(0,0,0,0.08); + + /* 侧边栏宽度 */ + --bh-sidebar-width: 220px; + --bh-sidebar-collapsed: 64px; + + /* 顶栏高度 */ + --bh-header-height: 60px; + + /* 移动端底部导航高度 */ + --bh-mobile-nav-height: 56px; + + /* 断点变量(用于 JS) */ + --bh-breakpoint-sm: 768px; + --bh-breakpoint-md: 992px; + --bh-breakpoint-lg: 1200px; + --bh-breakpoint-xl: 1920px; +} + +/* 暗黑模式覆盖 — 只影响内容区,侧边栏保持不变 */ +html.dark { + --bh-bg-main: #121212; + --bh-bg-card: #1e1e1e; + --bh-text-primary: #e0e0e0; + --bh-text-regular: #b0b0b0; + --bh-text-secondary: #808080; + --bh-border: #333333; + --bh-border-light: #2a2a2a; + + /* Element Plus 暗黑变量覆盖 — 让 el-card 等卡片用我们的色值 */ + --el-bg-color: #1e1e1e; + --el-bg-color-overlay: #1e1e1e; + --el-bg-color-page: #121212; + --el-text-color-primary: #e0e0e0; + --el-text-color-regular: #b0b0b0; + --el-text-color-secondary: #808080; + --el-border-color: #333333; + --el-border-color-light: #2a2a2a; + --el-fill-color: #252525; + --el-fill-color-light: #2a2a2a; + --el-fill-color-lighter: #303030; + --el-mask-color: rgba(0, 0, 0, 0.6); +} + +/* === 侧边栏全局样式(不受暗黑影响) === */ +.sidebar-dark { + background-color: var(--sidebar-bg) !important; +} +.sidebar-dark .el-menu { + background-color: var(--sidebar-bg) !important; + border-right: none; +} +.sidebar-dark .el-menu-item { + color: var(--sidebar-text) !important; + background-color: transparent !important; +} +.sidebar-dark .el-menu-item:hover { + background-color: var(--sidebar-bg-hover) !important; +} +.sidebar-dark .el-menu-item.is-active { + color: var(--sidebar-text-active) !important; + background-color: var(--sidebar-bg-hover) !important; +} +.sidebar-dark .el-menu-item * { + color: inherit !important; +} + +/* === 响应式工具类 === */ +.d-only { display: revert; } +.m-only { display: none !important; } + +@media (max-width: 768px) { + .d-only { display: none !important; } + .m-only { display: revert; } + + :root { + --bh-sidebar-width: 0px; + --bh-sidebar-collapsed: 0px; + --bh-header-height: 52px; + } +} + +@media (max-width: 480px) { + :root { + --bh-header-height: 48px; + } +} + +/* === 通用响应式容器 === */ +.bh-container { + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding-left: var(--bh-space-lg); + padding-right: var(--bh-space-lg); +} + +@media (max-width: 768px) { + .bh-container { + padding-left: var(--bh-space-md); + padding-right: var(--bh-space-md); + } +} + +/* === 滚动条美化 === */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #909399; } + +/* 暗黑模式滚动条 */ +html.dark ::-webkit-scrollbar-thumb { background: #555; } +html.dark ::-webkit-scrollbar-thumb:hover { background: #777; } + +/* === Element Plus 全局覆盖 === */ +.el-main { + background: var(--bh-bg-main); + min-height: calc(100vh - var(--bh-header-height)); +} + +/* el-card 暗黑适配 */ +html.dark .el-card { + background-color: var(--bh-bg-card); + border-color: var(--bh-border); +} +html.dark .el-card__header { + border-bottom-color: var(--bh-border); +} + +/* el-table 暗黑适配 */ +html.dark .el-table { + --el-table-bg-color: var(--bh-bg-card); + --el-table-tr-bg-color: var(--bh-bg-card); + --el-table-header-bg-color: var(--bh-bg-dark); + --el-table-row-hover-bg-color: #2a2a2a; + border-color: var(--bh-border); +} +html.dark .el-table th.el-table__cell { + background-color: var(--bh-bg-dark); + color: var(--sidebar-text); +} +html.dark .el-table__inner-wrapper::before, +html.dark .el-table__border-left-patch { + background-color: var(--bh-border); +} +html.dark .el-table__body-wrapper::-webkit-scrollbar-thumb { + background: #555; +} + +/* el-tabs 暗黑适配 */ +html.dark .el-tabs__header { + border-bottom-color: var(--bh-border); +} +html.dark .el-tabs__item { + color: var(--bh-text-regular); +} +html.dark .el-tabs__item.is-active { + color: var(--bh-primary); +} + +/* el-progress 暗黑适配 */ +html.dark .el-progress__text { + color: var(--bh-text-secondary); +} + +/* el-collapse 暗黑适配 */ +html.dark .el-collapse { + border-top-color: var(--bh-border); + border-bottom-color: var(--bh-border); +} +html.dark .el-collapse-item__header { + background-color: var(--bh-bg-card); + color: var(--bh-text-primary); + border-bottom-color: var(--bh-border); +} +html.dark .el-collapse-item__wrap { + background-color: var(--bh-bg-card); +} +html.dark .el-collapse-item__content { + color: var(--bh-text-regular); +} + +/* el-dialog 暗黑适配 */ +html.dark .el-dialog { + background-color: var(--bh-bg-card); +} +html.dark .el-dialog__title { + color: var(--bh-text-primary); +} +html.dark .el-dialog__body { + color: var(--bh-text-regular); +} + +/* 自定义弹窗内下拉选择框层级修复 */ +.dialog-select-popper { + z-index: 10001 !important; +} + +/* el-empty 暗黑适配 */ +html.dark .el-empty__description p { + color: var(--bh-text-secondary); +} + +/* el-alert 暗黑适配(info 类型保持原样) */ +html.dark .el-alert--info { + background-color: #1a1a2e; +} + +/* el-tag 暗黑适配 */ +html.dark .el-tag.el-tag--success { + --el-tag-bg-color: #1a3a1a; + --el-tag-text-color: #67c23a; + --el-tag-border-color: #2a5a2a; +} +html.dark .el-tag.el-tag--danger { + --el-tag-bg-color: #3a1a1a; + --el-tag-text-color: #f56c6c; + --el-tag-border-color: #5a2a2a; +} + +/* el-form 暗黑适配 */ +html.dark .el-form-item__label { + color: var(--bh-text-regular); +} + +/* el-timeline 暗黑适配 */ +html.dark .el-timeline-item__timestamp { + color: var(--bh-text-secondary); +} + +/* el-dropdown 暗黑适配 */ +html.dark .el-dropdown-menu { + background-color: var(--bh-bg-card); + border-color: var(--bh-border); +} +html.dark .el-dropdown-menu__item { + color: var(--bh-text-regular); +} +html.dark .el-dropdown-menu__item:hover { + background-color: var(--bh-fill-color, #252525); +} + +/* el-drawer 暗黑适配 */ +html.dark .el-drawer { + background-color: var(--bh-bg-card); +} +html.dark .el-drawer__header { + color: var(--bh-text-primary); +} + +/* 表格在小屏横向滚动 */ +.el-table { + @media (max-width: 768px) { + width: 100% !important; + overflow-x: auto; + display: block; + } +} + +/* el-card 间距在手机上缩小 */ +@media (max-width: 768px) { + .el-card { margin-bottom: 12px !important; } + .el-card__body { padding: 14px !important; } +} + +/* 表单在小屏上 label 置顶 */ +@media (max-width: 576px) { + .el-form--label-top .el-form-item__label { + padding-bottom: 0; + } +} + +/* el-message-box / el-dialog 弹窗全局居中修复 — + 注意:不移除 .el-overlay 的 display:flex 会导致遮罩层布局问题, + Element Plus 默认的 el-overlay 已经有 position:fixed + inset:0, + 居中应该用 center 属性而非覆盖样式 */ +.el-message-box { + margin: 0 !important; +} +.el-dialog { + --el-dialog-margin-top: 1vh !important; +} + +/* 过渡动画 — 明暗切换平滑过渡 */ +html.dark, +html, +html *, +html *::before, +html *::after { + transition: background-color 0.3s ease, + color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; +} diff --git a/frontend/src/views/AlertList.vue b/frontend/src/views/AlertList.vue new file mode 100644 index 00000000..560bde6c --- /dev/null +++ b/frontend/src/views/AlertList.vue @@ -0,0 +1,292 @@ + + + diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue new file mode 100644 index 00000000..7d2514d9 --- /dev/null +++ b/frontend/src/views/BudgetManagement.vue @@ -0,0 +1,311 @@ + + + diff --git a/frontend/src/views/CMAKnowledge.vue b/frontend/src/views/CMAKnowledge.vue new file mode 100644 index 00000000..0c066562 --- /dev/null +++ b/frontend/src/views/CMAKnowledge.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/frontend/src/views/CostDashboard.vue b/frontend/src/views/CostDashboard.vue new file mode 100644 index 00000000..ad9404f3 --- /dev/null +++ b/frontend/src/views/CostDashboard.vue @@ -0,0 +1,523 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 00000000..df3de36a --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,575 @@ + + + + + diff --git a/frontend/src/views/DataManage.vue b/frontend/src/views/DataManage.vue new file mode 100644 index 00000000..73528e4f --- /dev/null +++ b/frontend/src/views/DataManage.vue @@ -0,0 +1,169 @@ + + + diff --git a/frontend/src/views/DeviationDashboard.vue b/frontend/src/views/DeviationDashboard.vue new file mode 100644 index 00000000..2dda9c50 --- /dev/null +++ b/frontend/src/views/DeviationDashboard.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/src/views/KPIDetail.vue b/frontend/src/views/KPIDetail.vue new file mode 100644 index 00000000..5c9c9461 --- /dev/null +++ b/frontend/src/views/KPIDetail.vue @@ -0,0 +1,164 @@ + + + diff --git a/frontend/src/views/KPIList.vue b/frontend/src/views/KPIList.vue new file mode 100644 index 00000000..0eee6fe5 --- /dev/null +++ b/frontend/src/views/KPIList.vue @@ -0,0 +1,565 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 00000000..5ae77ee1 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue new file mode 100644 index 00000000..1ee82601 --- /dev/null +++ b/frontend/src/views/MapCanvas.vue @@ -0,0 +1,732 @@ + + + + + diff --git a/frontend/src/views/MapList.vue b/frontend/src/views/MapList.vue new file mode 100644 index 00000000..66e0ce6c --- /dev/null +++ b/frontend/src/views/MapList.vue @@ -0,0 +1,103 @@ + + + diff --git a/frontend/src/views/MapReview.vue b/frontend/src/views/MapReview.vue new file mode 100644 index 00000000..f141d6b8 --- /dev/null +++ b/frontend/src/views/MapReview.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/frontend/src/views/MyDashboard.vue b/frontend/src/views/MyDashboard.vue new file mode 100644 index 00000000..1d632fc5 --- /dev/null +++ b/frontend/src/views/MyDashboard.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/views/NewUserGuide.vue b/frontend/src/views/NewUserGuide.vue new file mode 100644 index 00000000..2cbb8d5f --- /dev/null +++ b/frontend/src/views/NewUserGuide.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/frontend/src/views/NotificationManage.vue b/frontend/src/views/NotificationManage.vue new file mode 100644 index 00000000..641be38d --- /dev/null +++ b/frontend/src/views/NotificationManage.vue @@ -0,0 +1,201 @@ + + + diff --git a/frontend/src/views/OrgManage.vue b/frontend/src/views/OrgManage.vue new file mode 100644 index 00000000..38a1807c --- /dev/null +++ b/frontend/src/views/OrgManage.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/frontend/src/views/PredictDashboard.vue b/frontend/src/views/PredictDashboard.vue new file mode 100644 index 00000000..c3912231 --- /dev/null +++ b/frontend/src/views/PredictDashboard.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/frontend/src/views/RolePermissions.vue b/frontend/src/views/RolePermissions.vue new file mode 100644 index 00000000..15a5c25f --- /dev/null +++ b/frontend/src/views/RolePermissions.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/frontend/src/views/UserManage.vue b/frontend/src/views/UserManage.vue new file mode 100644 index 00000000..131e65a5 --- /dev/null +++ b/frontend/src/views/UserManage.vue @@ -0,0 +1,131 @@ + + + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 00000000..5a52b9c8 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,17 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] }, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "ignoreDeprecations": "6.0", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..c5b79864 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "noEmit": true, + "strict": true, + "ignoreDeprecations": "6.0" + }, + "include": [ + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..96dc7f7c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,59 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + // Element Plus 按需自动导入 + AutoImport({ + resolvers: [ElementPlusResolver()], + }), + Components({ + resolvers: [ElementPlusResolver()], + }), + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5173, + hmr: true, + watch: { + usePolling: false, + }, + proxy: { + '/api': { + target: 'http://localhost:8010', + changeOrigin: true, + }, + }, + }, + build: { + target: 'es2020', + rollupOptions: { + output: { + manualChunks(id: string) { + if (id.includes('node_modules/vue') || id.includes('node_modules/@vue')) return 'vendor-vue' + if (id.includes('node_modules/axios')) return 'vendor-axios' + if (id.includes('node_modules/element-plus') || id.includes('node_modules/@element-plus')) return 'vendor-element' + if (id.includes('node_modules/echarts')) return 'vendor-echarts' + if (id.includes('node_modules/codemirror') || id.includes('node_modules/@codemirror')) return 'vendor-codemirror' + if (id.includes('node_modules/@vueuse') || id.includes('node_modules/pinia') || id.includes('node_modules/vue-router')) return 'vendor-vue-extra' + }, + }, + }, + chunkSizeWarningLimit: 1200, + sourcemap: false, + minify: 'esbuild', + cssMinify: true, + reportCompressedSize: false, + cssCodeSplit: true, + }, +}) diff --git a/gen_report.py b/gen_report.py new file mode 100644 index 00000000..3f557113 --- /dev/null +++ b/gen_report.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""汇总管理会计OS管理能力报告""" +import json, urllib.request + +BASE = "http://127.0.0.1:8010" + +def login(): + data = json.dumps({"username":"admin","password":"admin123"}).encode() + req = urllib.request.Request(BASE + "/api/cma/auth/login", data=data, + headers={"Content-Type":"application/json"}, method="POST") + resp = json.loads(urllib.request.urlopen(req).read()) + return resp["token"] + +def api(path, token): + auth = "Bearer " + token + req = urllib.request.Request(BASE + path, headers={"Authorization": auth}) + resp = json.loads(urllib.request.urlopen(req).read()) + return resp + +token = login() + +report = { + "战略管理": { + "战略地图": { + "状态": "✅ 已实现", + "数据": api("/api/cma/maps", token).get("data", [{}])[0].get("title", "未知"), + "说明": "1张已发布的战略地图,支持创建/编辑/发布", + "接口": ["GET/POST/PUT /api/cma/maps"], + }, + "KPI字典": { + "状态": "✅ 已实现", + "数据数": len(api("/api/cma/kpis", token).get("data", [])), + "维度分布": str(api("/api/cma/dashboard/summary", token).get("dimension_stats", [])), + "说明": "10个KPI覆盖4维度(财务4/客户3/流程2/学习1),含阈值配置", + "接口": ["CRUD /api/cma/kpis", "阈值建议 GET /api/cma/thresholds/suggest/{id}"], + }, + }, + "驾驶舱": { + "多角色视图": { + "状态": "✅ 已实现", + "角色": ["CEO(仅读+审批)", "财务(读写导入导出)", "业务(读写)", "IT(读写删除管理)"], + "模块数": {"ceo":10, "finance":7, "business":4, "it":7}, + }, + "数据展示": { + "状态": "✅ 已实现", + "KPI总数": api("/api/cma/dashboard/summary", token).get("kpi_total"), + "预警数": api("/api/cma/dashboard/summary", token).get("alert_count"), + "布局": "双栏布局(2/3主数据+1/3预测面板),异常高亮+摘要卡片", + }, + "AI分析": { + "状态": "✅ 已实现", + "功能": "驾驶舱AI分析(SSE流式)、单KPI深度分析、问答、计划审核", + "调用": "DeepSeek API", + }, + "预测": { + "状态": "✅ 已实现", + "接口": "GET /api/cma/dashboard/predict — 基于历史数据预测下期", + }, + }, + "数据管理": { + "ERP接入": { + "状态": "✅ 已实现", + "数据源": "1个(测试ERP),通过erp-api-gateway(8300)连接SQL Server", + "引擎": "calc_engine.py 从科目余额表+销售报表计算KPI", + }, + "Excel导入": { + "状态": "✅ 已实现", + "接口": "POST /api/cma/data/import-excel", + }, + "数据源管理": { + "状态": "✅ 已实现", + "接口": "CRUD /api/cma/data/sources", + }, + "定时同步": { + "状态": "✅ 已实现", + "cron": "每日凌晨1:00执行 daily_sync.py (ERP同步→预警检查→预警推送)", + }, + }, + "预警管理": { + "预警规则": { + "状态": "✅ 已实现", + "规则数": len(api("/api/cma/alert-rules", token).get("data", [])), + "说明": "每个KPI配绿/黄/红三级阈值,支持>=/<=/>/ 100: + val = val[:97] + "..." + print(" %-12s %s" % (k, val)) + +print("\n" + "=" * 66) +print(" 总结:共10大能力模块, 除2项P2待完善外, 其余全部完成") +print(" 网址: https://cma.sxbh.ltd") +print("=" * 66) diff --git a/start-dev.sh b/start-dev.sh new file mode 100755 index 00000000..8761b0ca --- /dev/null +++ b/start-dev.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# 管理会计OS — 一键启动开发环境 +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "===== 管理会计OS 开发环境 =====" +echo "" + +# 1. 启动后端 +echo "[1/2] 启动后端 (systemd)..." +cd "$SCRIPT_DIR/backend" +if [ ! -f .env ]; then + cp .env.example .env + echo " 已创建 .env (默认配置)" +fi +systemctl start cma-backend 2>/dev/null || true +sleep 1 +echo " 后端: http://127.0.0.1:8010" +echo " 文档: http://127.0.0.1:8010/docs" + +# 2. 启动前端 +echo "[2/2] 启动前端 (Vite HMR)..." +cd "$SCRIPT_DIR/frontend" +if [ ! -d "node_modules" ]; then + echo " 安装前端依赖..." + pnpm install --no-frozen-lockfile --silent +fi +echo " 前端: http://localhost:5173" +echo "" +echo "===== 启动完成 =====" +echo " 线上: https://cma.sxbh.ltd" +echo " 本地: http://localhost:5173" +echo " 后端: http://127.0.0.1:8010/docs" +echo "" +echo "按 Ctrl+C 停止前端开发服务器" + +exec pnpm dev