commit 3dddd368669901e49f6bd11661c73da4b3f42a7d Author: Hermes CI Fix Date: Thu May 28 17:32:22 2026 +0800 init: 管理会计OS初始代码 包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0 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 00000000..34c847aa Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/__pycache__/auth_middleware.cpython-312.pyc b/backend/app/__pycache__/auth_middleware.cpython-312.pyc new file mode 100644 index 00000000..621ce159 Binary files /dev/null and b/backend/app/__pycache__/auth_middleware.cpython-312.pyc differ diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 00000000..b76598e4 Binary files /dev/null and b/backend/app/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 00000000..be1b3297 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/api/__pycache__/__init__.cpython-312.pyc b/backend/app/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..084293ea Binary files /dev/null and b/backend/app/api/__pycache__/__init__.cpython-312.pyc differ 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 00000000..ae96038b Binary files /dev/null and b/backend/app/api/__pycache__/action_plans.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/ai_analysis.cpython-312.pyc b/backend/app/api/__pycache__/ai_analysis.cpython-312.pyc new file mode 100644 index 00000000..42ba247d Binary files /dev/null and b/backend/app/api/__pycache__/ai_analysis.cpython-312.pyc differ 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 00000000..f0359244 Binary files /dev/null and b/backend/app/api/__pycache__/alert_rules.cpython-312.pyc differ 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 00000000..ac37da5f Binary files /dev/null and b/backend/app/api/__pycache__/alerts.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/alignment.cpython-312.pyc b/backend/app/api/__pycache__/alignment.cpython-312.pyc new file mode 100644 index 00000000..41e2c5a1 Binary files /dev/null and b/backend/app/api/__pycache__/alignment.cpython-312.pyc differ 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 00000000..4452d508 Binary files /dev/null and b/backend/app/api/__pycache__/auth.cpython-312.pyc differ 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 00000000..5710f6b3 Binary files /dev/null and b/backend/app/api/__pycache__/budget.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/cost.cpython-312.pyc b/backend/app/api/__pycache__/cost.cpython-312.pyc new file mode 100644 index 00000000..467b42b4 Binary files /dev/null and b/backend/app/api/__pycache__/cost.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/dashboard.cpython-312.pyc b/backend/app/api/__pycache__/dashboard.cpython-312.pyc new file mode 100644 index 00000000..6811056a Binary files /dev/null and b/backend/app/api/__pycache__/dashboard.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/data.cpython-312.pyc b/backend/app/api/__pycache__/data.cpython-312.pyc new file mode 100644 index 00000000..d2bb8eda Binary files /dev/null and b/backend/app/api/__pycache__/data.cpython-312.pyc differ 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 00000000..a7bcc125 Binary files /dev/null and b/backend/app/api/__pycache__/kpis.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/maps.cpython-312.pyc b/backend/app/api/__pycache__/maps.cpython-312.pyc new file mode 100644 index 00000000..72536439 Binary files /dev/null and b/backend/app/api/__pycache__/maps.cpython-312.pyc differ 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 00000000..c12cedb4 Binary files /dev/null and b/backend/app/api/__pycache__/notifications.cpython-312.pyc differ 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 00000000..d87d1f53 Binary files /dev/null and b/backend/app/api/__pycache__/objectives.cpython-312.pyc differ 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 00000000..8eb0b10b Binary files /dev/null and b/backend/app/api/__pycache__/org.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/permissions.cpython-312.pyc b/backend/app/api/__pycache__/permissions.cpython-312.pyc new file mode 100644 index 00000000..c8eef050 Binary files /dev/null and b/backend/app/api/__pycache__/permissions.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/predict.cpython-312.pyc b/backend/app/api/__pycache__/predict.cpython-312.pyc new file mode 100644 index 00000000..f8804778 Binary files /dev/null and b/backend/app/api/__pycache__/predict.cpython-312.pyc differ 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 00000000..858dce97 Binary files /dev/null and b/backend/app/api/__pycache__/thresholds.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/users.cpython-312.pyc b/backend/app/api/__pycache__/users.cpython-312.pyc new file mode 100644 index 00000000..317a6b7b Binary files /dev/null and b/backend/app/api/__pycache__/users.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/versions.cpython-312.pyc b/backend/app/api/__pycache__/versions.cpython-312.pyc new file mode 100644 index 00000000..7c7ff546 Binary files /dev/null and b/backend/app/api/__pycache__/versions.cpython-312.pyc differ 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 00000000..67047347 Binary files /dev/null and b/backend/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/budget_plan.cpython-312.pyc b/backend/app/models/__pycache__/budget_plan.cpython-312.pyc new file mode 100644 index 00000000..2fd0cfb3 Binary files /dev/null and b/backend/app/models/__pycache__/budget_plan.cpython-312.pyc differ 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 00000000..6906bf67 Binary files /dev/null and b/backend/app/models/__pycache__/cost_model.cpython-312.pyc differ 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 00000000..74699bb3 Binary files /dev/null and b/backend/app/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/utils/__pycache__/cache.cpython-312.pyc b/backend/app/utils/__pycache__/cache.cpython-312.pyc new file mode 100644 index 00000000..8daaf094 Binary files /dev/null and b/backend/app/utils/__pycache__/cache.cpython-312.pyc differ 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 00000000..aab77ddd Binary files /dev/null and b/backend/app/utils/__pycache__/calc_engine.cpython-312.pyc differ diff --git a/backend/app/utils/__pycache__/cost_engine.cpython-312.pyc b/backend/app/utils/__pycache__/cost_engine.cpython-312.pyc new file mode 100644 index 00000000..29ed0fbc Binary files /dev/null and b/backend/app/utils/__pycache__/cost_engine.cpython-312.pyc differ 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 00000000..95b86a8c Binary files /dev/null and b/backend/app/utils/__pycache__/deviation_engine.cpython-312.pyc differ 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 00000000..e8c7307a Binary files /dev/null and b/backend/app/utils/__pycache__/notifier.cpython-312.pyc differ 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 00000000..de0015bb Binary files /dev/null and b/backend/app/utils/__pycache__/predict_engine.cpython-312.pyc differ diff --git a/backend/app/utils/cache.py b/backend/app/utils/cache.py new file mode 100644 index 00000000..ca645cb9 --- /dev/null +++ b/backend/app/utils/cache.py @@ -0,0 +1,100 @@ +"""Redis 缓存工具类 — 管理会计OS""" +import json +import hashlib +import logging +from typing import Any, Optional + +logger = logging.getLogger("cma.cache") + +try: + import redis as redis_lib + _client = redis_lib.Redis( + host="127.0.0.1", + port=6379, + db=1, + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=3, + ) + _client.ping() + _available = True + logger.info("Redis 缓存已连接 (db=1)") +except Exception as e: + _client = None + _available = False + logger.warning(f"Redis 不可用,回退到无缓存模式: {e}") + + +def _make_key(module: str, key: str) -> 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 00000000..0a33a6cf Binary files /dev/null and b/backend/scripts/__pycache__/ai_brief.cpython-312.pyc differ diff --git a/backend/scripts/__pycache__/alert_generator.cpython-312.pyc b/backend/scripts/__pycache__/alert_generator.cpython-312.pyc new file mode 100644 index 00000000..92e0ed1b Binary files /dev/null and b/backend/scripts/__pycache__/alert_generator.cpython-312.pyc differ 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 00000000..2a263f2a Binary files /dev/null and b/backend/scripts/__pycache__/erp_sync.cpython-312.pyc differ diff --git a/backend/scripts/ai_brief.py b/backend/scripts/ai_brief.py new file mode 100644 index 00000000..3c9a0cc3 --- /dev/null +++ b/backend/scripts/ai_brief.py @@ -0,0 +1,201 @@ +""" +定时AI经营简报 — 管理会计OS +每天凌晨自动生成经营分析报告,推送至企微 +在 daily_sync.py 之后运行 +""" +import sys +import os +import json +import logging +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv('/root/cma-management/backend/.env') + +from app.database import get_session_local +from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan +import httpx + +logger = logging.getLogger("cma.ai_brief") + + +async def call_deepseek(prompt: str, system_prompt: str = None) -> 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