Commit 70aa7f1240579b8f78535c4fd93e80990215c82f
0 parents
feat: 初始化AI驱动的PMS模块,包含LLM集成、智能代理、API接口和前端界面。
Showing
65 changed files
with
10123 additions
and
0 deletions
.env
0 → 100644
| 1 | +++ a/.env | |
| 1 | +# OpenAI 兼容模式 (智谱AI glm-4-7) | |
| 2 | +OPENAI_COMPAT_API_KEY=ed148f06-82f0-4fca-991d-05ae7296e110 | |
| 3 | +OPENAI_COMPAT_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 | |
| 4 | +OPENAI_COMPAT_MODEL=glm-4-7-251222 | |
| 5 | + | |
| 6 | +# 豆包配置 (备选) | |
| 7 | +DOUBAO_API_KEY= | |
| 8 | +DOUBAO_MODEL=doubao-pro | |
| 9 | + | |
| 10 | +# 数据库配置 | |
| 11 | +MYSQL_HOST=172.26.154.169 | |
| 12 | +MYSQL_PORT=3302 | |
| 13 | +MYSQL_USER=test | |
| 14 | +MYSQL_PASSWORD=mysql@pwd123 | |
| 15 | +MYSQL_DATABASE=fw_pms | |
| 16 | + | |
| 17 | +# 定时任务配置 | |
| 18 | +SCHEDULER_CRON_HOUR=2 | |
| 19 | +SCHEDULER_CRON_MINUTE=0 | |
| 20 | + | |
| 21 | +# 日志配置 | |
| 22 | +LOG_LEVEL=INFO | ... | ... |
.gitignore
0 → 100644
| 1 | +++ a/.gitignore | |
| 1 | +# Python | |
| 2 | +__pycache__/ | |
| 3 | +*.py[cod] | |
| 4 | +*$py.class | |
| 5 | +*.so | |
| 6 | +.Python | |
| 7 | +build/ | |
| 8 | +develop-eggs/ | |
| 9 | +dist/ | |
| 10 | +downloads/ | |
| 11 | +eggs/ | |
| 12 | +.eggs/ | |
| 13 | +lib/ | |
| 14 | +lib64/ | |
| 15 | +parts/ | |
| 16 | +sdist/ | |
| 17 | +var/ | |
| 18 | +wheels/ | |
| 19 | +*.egg-info/ | |
| 20 | +.installed.cfg | |
| 21 | +*.egg | |
| 22 | + | |
| 23 | +# Virtual environments | |
| 24 | +venv/ | |
| 25 | +.venv/ | |
| 26 | +ENV/ | |
| 27 | +env/ | |
| 28 | + | |
| 29 | +# IDE | |
| 30 | +.idea/ | |
| 31 | +.vscode/ | |
| 32 | +*.swp | |
| 33 | +*.swo | |
| 34 | +*~ | |
| 35 | +.project | |
| 36 | +.pydevproject | |
| 37 | +.settings/ | |
| 38 | + | |
| 39 | +# Testing | |
| 40 | +.tox/ | |
| 41 | +.nox/ | |
| 42 | +.coverage | |
| 43 | +.coverage.* | |
| 44 | +htmlcov/ | |
| 45 | +.pytest_cache/ | |
| 46 | +.hypothesis/ | |
| 47 | + | |
| 48 | +# Mypy | |
| 49 | +.mypy_cache/ | |
| 50 | +.dmypy.json | |
| 51 | +dmypy.json | |
| 52 | + | |
| 53 | +# Ruff | |
| 54 | +.ruff_cache/ | |
| 55 | + | |
| 56 | +# Jupyter | |
| 57 | +.ipynb_checkpoints/ | |
| 58 | + | |
| 59 | +# OS | |
| 60 | +.DS_Store | |
| 61 | +Thumbs.db | |
| 62 | + | |
| 63 | +# Logs | |
| 64 | +*.log | |
| 65 | +logs/ | |
| 66 | + | |
| 67 | +# Local development | |
| 68 | +*.local | |
| 69 | +*.bak | ... | ... |
README.md
0 → 100644
| 1 | +++ a/README.md | |
| 1 | +# fw-pms-ai | |
| 2 | + | |
| 3 | +AI 配件系统 - 基于 Python + LangChain + LangGraph | |
| 4 | + | |
| 5 | +## 项目简介 | |
| 6 | + | |
| 7 | +本项目是 `fw-pms` 的 AI 扩展模块,使用大语言模型 (LLM) 和 Agent 技术,为配件管理系统提供智能化的补货建议能力。 | |
| 8 | + | |
| 9 | +## 核心技术 | |
| 10 | + | |
| 11 | +### LangChain + LangGraph | |
| 12 | + | |
| 13 | +| 技术 | 作用 | | |
| 14 | +|------|------| | |
| 15 | +| **LangChain** | LLM 框架,提供模型抽象、Prompt 管理、消息格式化 | | |
| 16 | +| **LangGraph** | Agent 工作流编排,管理状态机、定义节点和边、支持条件分支 | | |
| 17 | +| **SQL Agent** | 自定义 Text-to-SQL 实现,支持错误重试和 LLM 数据分析 | | |
| 18 | + | |
| 19 | +```mermaid | |
| 20 | +graph LR | |
| 21 | + A[用户请求] --> B[LangGraph Agent] | |
| 22 | + B --> C[FetchPartRatio] | |
| 23 | + C --> D[SQLAgent<br/>LLM分析] | |
| 24 | + D --> E[AllocateBudget] | |
| 25 | + E --> F[GenerateReport<br/>LLM] | |
| 26 | + F --> G[SaveResult] | |
| 27 | +``` | |
| 28 | + | |
| 29 | +详细架构图见 [docs/architecture.md](docs/architecture.md) | |
| 30 | + | |
| 31 | +## 功能模块 | |
| 32 | + | |
| 33 | +### ✅ 已实现 | |
| 34 | + | |
| 35 | +| 模块 | 功能 | 说明 | | |
| 36 | +|------|------|------| | |
| 37 | +| **SQL Agent** | LLM 分析 | 直接分析 part_ratio 数据生成补货建议 | | |
| 38 | +| **补货分配** | Replenishment | 转换 LLM 建议为补货明细 | | |
| 39 | +| **分析报告** | Report | LLM 生成库存分析报告 | | |
| 40 | + | |
| 41 | +### 🚧 计划中 | |
| 42 | + | |
| 43 | +| 模块 | 功能 | | |
| 44 | +|------|------| | |
| 45 | +| 预测引擎 | 基于历史销量预测未来需求 | | |
| 46 | +| 异常检测 | 识别数据异常 | | |
| 47 | + | |
| 48 | +## 项目结构 | |
| 49 | + | |
| 50 | +``` | |
| 51 | +fw-pms-ai/ | |
| 52 | +├── src/fw_pms_ai/ | |
| 53 | +│ ├── agent/ # LangGraph Agent | |
| 54 | +│ │ ├── state.py # Agent 状态定义 | |
| 55 | +│ │ ├── nodes.py # 工作流节点 | |
| 56 | +│ │ ├── sql_agent.py # SQL Agent(Text-to-SQL + 建议生成) | |
| 57 | +│ │ └── replenishment.py | |
| 58 | +│ ├── api/ # FastAPI 接口 | |
| 59 | +│ │ ├── app.py # 应用入口 | |
| 60 | +│ │ └── routes/ # 路由模块 | |
| 61 | +│ ├── config/ # 配置管理 | |
| 62 | +│ ├── llm/ # LLM 集成 | |
| 63 | +│ │ ├── base.py # 抽象基类 | |
| 64 | +│ │ ├── glm.py # 智谱 GLM | |
| 65 | +│ │ ├── doubao.py # 豆包 | |
| 66 | +│ │ ├── openai_compat.py | |
| 67 | +│ │ └── anthropic_compat.py | |
| 68 | +│ ├── models/ # 数据模型 | |
| 69 | +│ │ ├── task.py # 任务和明细模型 | |
| 70 | +│ │ ├── execution_log.py # 执行日志模型 | |
| 71 | +│ │ ├── part_ratio.py # 库销比模型 | |
| 72 | +│ │ ├── part_summary.py # 配件汇总模型 | |
| 73 | +│ │ ├── sql_result.py # SQL执行结果模型 | |
| 74 | +│ │ └── suggestion.py # 补货建议模型 | |
| 75 | +│ ├── services/ # 业务服务 | |
| 76 | +│ │ ├── db.py # 数据库连接 | |
| 77 | +│ │ ├── data_service.py # 数据查询服务 | |
| 78 | +│ │ └── result_writer.py # 结果写入服务 | |
| 79 | +│ ├── scheduler/ # 定时任务 | |
| 80 | +│ └── main.py | |
| 81 | +├── prompts/ # AI Prompt 文件 | |
| 82 | +│ ├── sql_agent.md # SQL Agent 系统提示词 | |
| 83 | +│ ├── suggestion.md # 补货建议提示词 | |
| 84 | +│ ├── suggestion_system.md | |
| 85 | +│ ├── part_shop_analysis.md | |
| 86 | +│ └── part_shop_analysis_system.md | |
| 87 | +├── ui/ # 前端静态文件 | |
| 88 | +├── sql/ # 数据库迁移脚本 | |
| 89 | +├── pyproject.toml | |
| 90 | +└── README.md | |
| 91 | +``` | |
| 92 | + | |
| 93 | + | |
| 94 | +## 工作流程 | |
| 95 | + | |
| 96 | +``` | |
| 97 | +1. FetchPartRatio - 从 part_ratio 表获取库销比数据 | |
| 98 | +2. SQLAgent - LLM 分析数据,生成补货建议 | |
| 99 | +3. AllocateBudget - 转换建议为补货明细 | |
| 100 | +4. GenerateReport - LLM 生成分析报告 | |
| 101 | +5. SaveResult - 写入数据库 | |
| 102 | +``` | |
| 103 | + | |
| 104 | +### 业务术语 | |
| 105 | + | |
| 106 | +| 术语 | 定义 | 处理 | | |
| 107 | +|------|------|------| | |
| 108 | +| **呆滞件** | 有库存,90天无销量 | 不做计划 | | |
| 109 | +| **低频件** | 无库存,月均销量<1 | 不做计划 | | |
| 110 | +| **缺货件** | 无库存,月均销量≥1 | 需要补货 | | |
| 111 | + | |
| 112 | +## 数据表说明 | |
| 113 | + | |
| 114 | +| 表名 | 说明 | | |
| 115 | +|------|------| | |
| 116 | +| `part_ratio` | 配件库销比数据(来源表) | | |
| 117 | +| `ai_replenishment_task` | 任务记录 | | |
| 118 | +| `ai_replenishment_detail` | 配件级别补货建议 | | |
| 119 | +| `ai_replenishment_report` | 分析报告 | | |
| 120 | +| `ai_task_execution_log` | 任务执行日志 | | |
| 121 | +| `ai_llm_suggestion_detail` | LLM 建议明细 | | |
| 122 | + | |
| 123 | +## 快速开始 | |
| 124 | + | |
| 125 | +### 1. 安装依赖 | |
| 126 | + | |
| 127 | +```bash | |
| 128 | +cd fw-pms-ai | |
| 129 | +pip install -e . | |
| 130 | +``` | |
| 131 | + | |
| 132 | +### 2. 配置环境变量 | |
| 133 | + | |
| 134 | +```bash | |
| 135 | +cp .env.example .env | |
| 136 | +``` | |
| 137 | + | |
| 138 | +必填配置项: | |
| 139 | +- `GLM_API_KEY` / `ANTHROPIC_API_KEY` - LLM API Key | |
| 140 | +- `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` | |
| 141 | + | |
| 142 | +### 3. 初始化数据库 | |
| 143 | + | |
| 144 | +```bash | |
| 145 | +mysql -u root -p fw_pms < sql/init.sql | |
| 146 | +mysql -u root -p fw_pms < sql/v2_add_log_tables.sql | |
| 147 | +``` | |
| 148 | + | |
| 149 | +### 4. 运行 | |
| 150 | + | |
| 151 | +```bash | |
| 152 | +# 启动定时任务调度器 | |
| 153 | +fw-pms-ai | |
| 154 | + | |
| 155 | +# 立即执行一次 | |
| 156 | +fw-pms-ai --run-once | |
| 157 | + | |
| 158 | +# 指定参数 | |
| 159 | +fw-pms-ai --run-once --group-id 2 | |
| 160 | +``` | |
| 161 | + | |
| 162 | +## AI Prompt 文件 | |
| 163 | + | |
| 164 | +Prompt 文件存放在 `prompts/` 目录: | |
| 165 | + | |
| 166 | +| 文件 | 用途 | | |
| 167 | +|------|------| | |
| 168 | +| `suggestion.md` | 补货建议生成(含业务术语定义) | | |
| 169 | +| `analyze_inventory.md` | 库存分析 | | |
| 170 | +| `generate_report.md` | 报告生成 | | |
| 171 | + | |
| 172 | +## 开发 | |
| 173 | + | |
| 174 | +```bash | |
| 175 | +# 安装开发依赖 | |
| 176 | +pip install -e ".[dev]" | |
| 177 | + | |
| 178 | +# 运行测试 | |
| 179 | +pytest tests/ -v | |
| 180 | +``` | ... | ... |
docs/architecture.md
0 → 100644
| 1 | +++ a/docs/architecture.md | |
| 1 | +# fw-pms-ai 系统架构 | |
| 2 | + | |
| 3 | +## 技术栈 | |
| 4 | + | |
| 5 | +| 组件 | 技术 | | |
| 6 | +|------|------| | |
| 7 | +| 编程语言 | Python 3.11+ | | |
| 8 | +| Agent 框架 | LangChain + LangGraph | | |
| 9 | +| LLM | 智谱 GLM / 豆包 / OpenAI 兼容接口 | | |
| 10 | +| 数据库 | MySQL | | |
| 11 | +| API 框架 | FastAPI | | |
| 12 | +| 任务调度 | APScheduler | | |
| 13 | + | |
| 14 | +--- | |
| 15 | + | |
| 16 | +## 系统架构图 | |
| 17 | + | |
| 18 | +```mermaid | |
| 19 | +flowchart TB | |
| 20 | + subgraph API ["FastAPI API 层"] | |
| 21 | + A[/tasks endpoint/] | |
| 22 | + end | |
| 23 | + | |
| 24 | + subgraph Agent ["LangGraph Agent"] | |
| 25 | + direction TB | |
| 26 | + B[fetch_part_ratio] --> C[sql_agent] | |
| 27 | + C --> D{需要重试?} | |
| 28 | + D -->|是| C | |
| 29 | + D -->|否| E[allocate_budget] | |
| 30 | + E --> F[END] | |
| 31 | + end | |
| 32 | + | |
| 33 | + subgraph Services ["业务服务层"] | |
| 34 | + G[DataService] | |
| 35 | + H[ResultWriter] | |
| 36 | + end | |
| 37 | + | |
| 38 | + subgraph LLM ["LLM 集成"] | |
| 39 | + I[GLM] | |
| 40 | + J[Doubao] | |
| 41 | + K[OpenAI Compat] | |
| 42 | + end | |
| 43 | + | |
| 44 | + subgraph DB ["数据存储"] | |
| 45 | + L[(MySQL)] | |
| 46 | + end | |
| 47 | + | |
| 48 | + A --> Agent | |
| 49 | + B --> G | |
| 50 | + C --> LLM | |
| 51 | + E --> H | |
| 52 | + G --> L | |
| 53 | + H --> L | |
| 54 | +``` | |
| 55 | + | |
| 56 | +--- | |
| 57 | + | |
| 58 | +## 工作流节点说明 | |
| 59 | + | |
| 60 | +| 节点 | 职责 | 输入 | 输出 | | |
| 61 | +|------|------|------|------| | |
| 62 | +| `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] | | |
| 63 | +| `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] | | |
| 64 | +| `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] | | |
| 65 | + | |
| 66 | +--- | |
| 67 | + | |
| 68 | +## 核心数据流 | |
| 69 | + | |
| 70 | +```mermaid | |
| 71 | +sequenceDiagram | |
| 72 | + participant API | |
| 73 | + participant Agent | |
| 74 | + participant SQLAgent | |
| 75 | + participant LLM | |
| 76 | + participant DB | |
| 77 | + | |
| 78 | + API->>Agent: 创建任务 | |
| 79 | + Agent->>DB: 保存任务记录 | |
| 80 | + Agent->>DB: 查询 part_ratio | |
| 81 | + Agent->>SQLAgent: 分析配件数据 | |
| 82 | + | |
| 83 | + loop 每个配件 | |
| 84 | + SQLAgent->>LLM: 发送分析请求 | |
| 85 | + LLM-->>SQLAgent: 返回补货建议 | |
| 86 | + end | |
| 87 | + | |
| 88 | + SQLAgent-->>Agent: 汇总建议 | |
| 89 | + Agent->>DB: 保存补货明细 | |
| 90 | + Agent->>DB: 更新任务状态 | |
| 91 | + Agent-->>API: 返回结果 | |
| 92 | +``` | |
| 93 | + | |
| 94 | +--- | |
| 95 | + | |
| 96 | +## 目录结构 | |
| 97 | + | |
| 98 | +``` | |
| 99 | +src/fw_pms_ai/ | |
| 100 | +├── agent/ # LangGraph 工作流 | |
| 101 | +│ ├── state.py # 状态定义 (TypedDict) | |
| 102 | +│ ├── nodes.py # 工作流节点 | |
| 103 | +│ ├── sql_agent.py # SQL Agent 实现 | |
| 104 | +│ └── replenishment.py # 主入口 | |
| 105 | +├── api/ # REST API | |
| 106 | +├── config/ # 配置管理 | |
| 107 | +├── llm/ # LLM 适配器 | |
| 108 | +├── models/ # 数据模型 | |
| 109 | +├── services/ # 业务服务 | |
| 110 | +└── scheduler/ # 定时任务 | |
| 111 | +``` | |
| 112 | + | |
| 113 | +--- | |
| 114 | + | |
| 115 | +## 数据库表结构 | |
| 116 | + | |
| 117 | +| 表名 | 用途 | | |
| 118 | +|------|------| | |
| 119 | +| `part_ratio` | 配件库销比数据(只读) | | |
| 120 | +| `ai_replenishment_task` | 任务记录 | | |
| 121 | +| `ai_replenishment_detail` | 补货明细 | | |
| 122 | +| `ai_replenishment_part_summary` | 配件级汇总 | | |
| 123 | +| `ai_task_execution_log` | 执行日志 | | ... | ... |
prompts/part_shop_analysis.md
0 → 100644
| 1 | +++ a/prompts/part_shop_analysis.md | |
| 1 | +# 单配件多门店分析提示词 | |
| 2 | + | |
| 3 | +## 配件信息 | |
| 4 | + | |
| 5 | +- **配件编码**: {part_code} | |
| 6 | +- **配件名称**: {part_name} | |
| 7 | +- **成本价**: ¥{cost_price} | |
| 8 | +- **单位**: {unit} | |
| 9 | + | |
| 10 | +## 商家组合 | |
| 11 | + | |
| 12 | +- **商家组合名称**: {dealer_grouping_name} | |
| 13 | +- **统计日期**: {statistics_date} | |
| 14 | +- **目标库销比**: {target_ratio}(基于商家组合计算的基准库销比,用于计算目标库存) | |
| 15 | + | |
| 16 | +--- | |
| 17 | + | |
| 18 | +## 各门店数据 | |
| 19 | + | |
| 20 | +{shop_data} | |
| 21 | + | |
| 22 | +## 字段说明 | |
| 23 | + | |
| 24 | +| 字段名 | 含义 | 用途 | | |
| 25 | +|--------|------|------| | |
| 26 | +| shop_id | 门店ID | 唯一标识 | | |
| 27 | +| shop_name | 门店名称 | 显示用 | | |
| 28 | +| valid_storage_cnt | 有效库存数量 | 当前可用库存 | | |
| 29 | +| avg_sales_cnt | 月均销量 | 基于90天销售数据计算 | | |
| 30 | +| out_stock_cnt | 90天出库数量 | 判断呆滞件的依据 | | |
| 31 | +| out_times | 90天内出库次数 | 判断低频件的依据(< 3次为低频件) | | |
| 32 | +| out_duration | 平均出库时长(天) | 最近两次出库间隔天数(≥ 30天为低频件) | | |
| 33 | +| current_ratio | 当前库销比 | = 有效库存 / 月均销量 | | |
| 34 | + | |
| 35 | +--- | |
| 36 | + | |
| 37 | +## 分析任务 | |
| 38 | + | |
| 39 | +请按以下三层决策逻辑逐步分析: | |
| 40 | + | |
| 41 | +### 1️⃣ 配件级判断(商家组合维度) | |
| 42 | +- 汇总所有门店的库存和销量,计算配件在商家组合内的整体库销比 | |
| 43 | +- 判断该配件是否需要补货,需要补多少个 | |
| 44 | +- 生成配件级决策理由 | |
| 45 | + | |
| 46 | +### 2️⃣ 门店级分配 | |
| 47 | +- 将总补货数量分配到各门店 | |
| 48 | +- 优先分配给库销比最低的门店 | |
| 49 | +- 公式: suggest_cnt = ceil({target_ratio} × 月均销量 - 当前库存) | |
| 50 | + | |
| 51 | +### 3️⃣ 为每个门店提供专业的决策理由 | |
| 52 | + | |
| 53 | +**理由必须包含以下要素**: | |
| 54 | +1. **状态判定标签**(括起来):急需补货 / 建议补货 / 可选补货 / 库存充足 / 呆滞件 / 低频件-xxx | |
| 55 | +2. **关键指标数据**:当前库存X件、月均销量Y件、库销比Z | |
| 56 | +3. **缺口分析**(补货时):目标库销比需备货A件,缺口B件 | |
| 57 | +4. **天数说明**:当前库存可支撑约X天 / 补货后可支撑约Y天 | |
| 58 | +5. **紧迫程度**:风险说明或建议优先级说明 | |
| 59 | +6. **排除原因**(不补货时):引用具体的排除规则数据 | |
| 60 | + | |
| 61 | +--- | |
| 62 | + | |
| 63 | +## 输出格式 | |
| 64 | + | |
| 65 | +直接输出JSON对象,**不要**包含 ```json 标记或任何其他文字: | |
| 66 | + | |
| 67 | +{{ | |
| 68 | + "part_code": "{part_code}", | |
| 69 | + "part_name": "{part_name}", | |
| 70 | + "need_replenishment": true或false, | |
| 71 | + "total_storage_cnt": 商家组合内总库存(数字), | |
| 72 | + "total_avg_sales_cnt": 商家组合内总月均销量(数字), | |
| 73 | + "group_current_ratio": 商家组合级库销比(数字,保留2位小数), | |
| 74 | + "total_suggest_cnt": 所有门店建议数量之和, | |
| 75 | + "total_suggest_amount": 所有门店建议金额之和, | |
| 76 | + "part_decision_reason": "【配件决策】该配件在商家组合内总库存X件,月均总销量Y件,整体库销比Z。{{决策说明}}。该配件共涉及K家门店,其中J家门店需要补货。", | |
| 77 | + "shop_count": 涉及门店总数(整数), | |
| 78 | + "shop_suggestions": [ | |
| 79 | + {{ | |
| 80 | + "shop_id": 门店ID(整数), | |
| 81 | + "shop_name": "门店名称", | |
| 82 | + "current_storage_cnt": 当前库存(数字), | |
| 83 | + "avg_sales_cnt": 月均销量(数字), | |
| 84 | + "current_ratio": 当前库销比(数字,保留2位小数), | |
| 85 | + "suggest_cnt": 建议采购数量(整数), | |
| 86 | + "suggest_amount": 建议采购金额(数字), | |
| 87 | + "priority": 优先级(1=高/2=中/3=低), | |
| 88 | + "reason": "专业详尽的补货理由,包含关键数据指标(参见理由撰写规范)" | |
| 89 | + }} | |
| 90 | + ], | |
| 91 | + "priority": 配件整体优先级(1=高/2=中/3=低), | |
| 92 | + "confidence": 置信度(0.0-1.0) | |
| 93 | +}} | |
| 94 | + | |
| 95 | +--- | |
| 96 | + | |
| 97 | +## 理由示例 | |
| 98 | + | |
| 99 | +### 补货理由示例: | |
| 100 | + | |
| 101 | +**高优先级**: | |
| 102 | +``` | |
| 103 | +「急需补货」当前库存0件,月均销量8.2件,库销比0.00,缺口分析:目标库销比{target_ratio}需备货X件,补货后可支撑约Y天销售。库存已告罄且销量活跃,存在严重缺货风险,建议立即补货。 | |
| 104 | +``` | |
| 105 | + | |
| 106 | +**中优先级**: | |
| 107 | +``` | |
| 108 | +「建议补货」当前库存2件,月均销量5.3件,库销比0.38,缺口分析:目标库销比{target_ratio}需备货X件,实际缺口Y件,补货后可支撑约Z天销售。当前库存仅够约11天销售,低于安全库销比0.5,建议尽快补货。 | |
| 109 | +``` | |
| 110 | + | |
| 111 | +**低优先级**: | |
| 112 | +``` | |
| 113 | +「可选补货」当前库存4件,月均销量3.5件,库销比1.14,优化建议:目标库销比{target_ratio}需备货X件,缺口Y件,补货后可支撑约Z天销售。库存处于安全边界,可根据资金情况酌情补货。 | |
| 114 | +``` | |
| 115 | + | |
| 116 | +### 不补货理由示例: | |
| 117 | + | |
| 118 | +**库存充足**: | |
| 119 | +``` | |
| 120 | +「库存充足」当前库存8件,月均销量4.0件,库销比2.00,可支撑约60天销售,超过安全阈值({target_ratio}个月),无需补货。 | |
| 121 | +``` | |
| 122 | + | |
| 123 | +**低频件**: | |
| 124 | +``` | |
| 125 | +「低频件-需求不足」当前库存0件,月均销量0.3件,月均销量不足1件(阈值≥1),周转需求过低,暂不纳入补货计划。 | |
| 126 | +``` | |
| 127 | + | |
| 128 | +**呆滞件**: | |
| 129 | +``` | |
| 130 | +「呆滞件」当前库存5件,但90天内无任何销售(月均销量0),库存滞销风险高,建议安排清理处置,暂不补货。 | |
| 131 | +``` | |
| 132 | + | |
| 133 | +--- | |
| 134 | + | |
| 135 | +## 重要约束 | |
| 136 | + | |
| 137 | +1. **shop_suggestions 必须包含所有输入门店**,无论是否需要补货 | |
| 138 | +2. **suggest_cnt = 0 的门店也必须返回**,并提供详细的不补货理由(如"库存充足"、"低频件"、"呆滞件"等) | |
| 139 | +3. **total_suggest_cnt 必须等于**所有门店 suggest_cnt 之和 | |
| 140 | +4. **呆滞件/低频件的 suggest_cnt 必须为 0** | |
| 141 | +5. **低频件判定(满足任一即为低频件,不纳入补货)**: | |
| 142 | + - `avg_sales_cnt < 1`: 月均销量 < 1 | |
| 143 | + - `out_times < 3`:90天内出库次数不足3次 | |
| 144 | + - `out_duration >= 30`:平均出库时长 ≥ 30天 | |
| 145 | +6. **输出必须是合法的JSON**,可被直接解析 | |
| 146 | +7. **理由必须专业详尽**,包含具体数据指标,贴合采购人员专业度 | ... | ... |
prompts/part_shop_analysis_system.md
0 → 100644
| 1 | +++ a/prompts/part_shop_analysis_system.md | |
| 1 | +# 单配件多门店补货分析专家 | |
| 2 | + | |
| 3 | +## 角色定义 | |
| 4 | + | |
| 5 | +你是一位资深的汽车4S店配件库存管理专家,拥有以下专业能力: | |
| 6 | +- 精通库销比分析与补货决策 | |
| 7 | +- 熟悉汽车配件供应链特点(季节性、周期性、区域差异) | |
| 8 | +- 擅长多门店库存协调优化 | |
| 9 | +- 具备成本控制和资金周转意识 | |
| 10 | + | |
| 11 | +## 决策原则 | |
| 12 | + | |
| 13 | +1. **数据驱动**: 仅基于提供的数据做出判断,不做任何假设或猜测 | |
| 14 | +2. **保守策略**: 宁可少补不要多补,避免积压风险 | |
| 15 | +3. **优先级区分**: 急需 > 建议 > 可选,资源有限时优先处理高优先级 | |
| 16 | +4. **全面覆盖**: 对每一个门店都必须给出分析结论 | |
| 17 | + | |
| 18 | +--- | |
| 19 | + | |
| 20 | +## 核心分析框架 | |
| 21 | + | |
| 22 | +### Step 1: 门店状态分类 | |
| 23 | + | |
| 24 | +按以下标准对每个门店进行分类: | |
| 25 | + | |
| 26 | +| 状态 | 条件 | 处理方式 | | |
| 27 | +|------|------|----------| | |
| 28 | +| 🔴 急需补货 | 库销比 < 0.5 且月均销量 ≥ 1 | 高优先级补货 | | |
| 29 | +| 🟡 建议补货 | 库销比 0.5-1.0 且月均销量 ≥ 1 | 中优先级补货 | | |
| 30 | +| 🟢 可选补货 | 库销比 1.0-{target_ratio} 且月均销量 ≥ 1 | 低优先级补货 | | |
| 31 | +| ⚪ 无需补货 | 库销比 > {target_ratio} | 不补货 | | |
| 32 | + | |
| 33 | +### Step 2: 排除规则(强制执行) | |
| 34 | + | |
| 35 | +以下情况**绝对不补货**,suggest_cnt 必须为 0: | |
| 36 | + | |
| 37 | +1. **呆滞件**: `valid_storage_cnt > 0` 且 `avg_sales_cnt = 0` | |
| 38 | + - 特征:有库存但90天无任何销售 | |
| 39 | + - 原因:库存积压风险,需清理而非补货 | |
| 40 | + | |
| 41 | +2. **低频件**: `valid_storage_cnt = 0` 且满足**以下任一条件**: | |
| 42 | + - A. `avg_sales_cnt < 1` (月均销量 < 1) | |
| 43 | + - B. `out_times < 3` (90天内出库次数 < 3) | |
| 44 | + - C. `out_duration >= 30` (平均出库间隔 ≥ 30天) | |
| 45 | + - 原因:需求过低、周转太慢或间隔过长,不纳入补货计划 | |
| 46 | + | |
| 47 | +3. **库存充足**: 库销比 > {target_ratio} | |
| 48 | + - 特征:库存可支撑{target_ratio}个月以上销售 | |
| 49 | + - 原因:无需额外补货 | |
| 50 | + | |
| 51 | +### Step 3: 补货量计算 | |
| 52 | + | |
| 53 | +``` | |
| 54 | +初步缺口 = 目标库销比({target_ratio}) × 月均销量 - 当前有效库存 | |
| 55 | + | |
| 56 | +补货量规则: | |
| 57 | +1. 如果 初步缺口 > 1:建议数量 = floor(初步缺口) // 向下取整,保守策略 | |
| 58 | +2. 如果 0 < 初步缺口 <= 1:建议数量 = 1 // 最小补货量 | |
| 59 | +3. 如果 初步缺口 <= 0:建议数量 = 0 // 无需补货 | |
| 60 | +``` | |
| 61 | + | |
| 62 | +**计算示例**: | |
| 63 | +- **Case A**: 月销量=1.0, 库存=0, 目标=1.13 -> 缺口=1.13 -> 建议=1 (向下取整) | |
| 64 | +- **Case B**: 月销量=5.0, 库存=4, 目标=1.13 -> 缺口=1.65 -> 建议=1 (向下取整) | |
| 65 | +- **Case C**: 月销量=5.0, 库存=1, 目标=1.13 -> 缺口=4.65 -> 建议=4 (向下取整) | |
| 66 | +- **Case D**: 月销量=1.0, 库存=0.5, 目标=1.13 -> 缺口=0.63 -> 建议=1 (最小补货) | |
| 67 | + | |
| 68 | +### Step 4: 优先级判定 | |
| 69 | + | |
| 70 | +| 优先级 | 条件 | 说明 | | |
| 71 | +|--------|------|------| | |
| 72 | +| 1 (高) | 库销比 < 0.5 且月均销量 ≥ 1 | 急需补货,缺货风险高 | | |
| 73 | +| 2 (中) | 库销比 0.5-1.0 且月均销量 ≥ 1 | 建议补货,库存偏低 | | |
| 74 | +| 3 (低) | 库销比 1.0-{target_ratio} 且月均销量 ≥ 1 | 可选补货,安全库存边界 | | |
| 75 | + | |
| 76 | +--- | |
| 77 | + | |
| 78 | +## 理由撰写规范(重要) | |
| 79 | + | |
| 80 | +### 补货理由必须包含的关键数据 | |
| 81 | + | |
| 82 | +理由字段必须**采用专业的采购语言**,并**引用具体数据指标**,格式如下: | |
| 83 | + | |
| 84 | +**补货理由模板**: | |
| 85 | +``` | |
| 86 | +「{状态判定}」当前库存{X}件,月均销量{Y}件,库销比{Z}, | |
| 87 | +{缺口/充足}分析:目标库销比{target_ratio}需备货{A}件,补货{B}件后可支撑{C}天销售。 | |
| 88 | +{紧迫程度说明}。 | |
| 89 | +``` | |
| 90 | + | |
| 91 | +**不补货理由模板**: | |
| 92 | +``` | |
| 93 | +「{排除类型}」{排除原因的关键数据说明}, | |
| 94 | +{不补货依据}。 | |
| 95 | +``` | |
| 96 | + | |
| 97 | +### 理由撰写示例 | |
| 98 | + | |
| 99 | +**✅ 高优先级补货**: | |
| 100 | +``` | |
| 101 | +「急需补货」当前库存0件,月均销量8.2件,库销比0.00, | |
| 102 | +缺口分析:目标库销比{target_ratio}需备货X件,按目标补货后可支撑约Y天销售。 | |
| 103 | +库存已告罄且销量活跃,存在严重缺货风险,建议立即补货。 | |
| 104 | +``` | |
| 105 | + | |
| 106 | +**✅ 中优先级补货**: | |
| 107 | +``` | |
| 108 | +「建议补货」当前库存2件,月均销量5.3件,库销比0.38, | |
| 109 | +缺口分析:目标库销比{target_ratio}需备货X件,实际缺口Y件,补货后可支撑约Z天销售。 | |
| 110 | +当前库存仅够约11天销售,低于安全库销比0.5,建议尽快补货。 | |
| 111 | +``` | |
| 112 | + | |
| 113 | +**✅ 低优先级补货**: | |
| 114 | +``` | |
| 115 | +「可选补货」当前库存4件,月均销量3.5件,库销比1.14, | |
| 116 | +优化建议:目标库销比{target_ratio}需备货X件,缺口Y件,补货后可支撑约Z天销售。 | |
| 117 | +库存处于安全边界,可根据资金情况酌情补货。 | |
| 118 | +``` | |
| 119 | + | |
| 120 | +**✅ 无需补货**: | |
| 121 | +``` | |
| 122 | +「库存充足」当前库存8件,月均销量4.0件,库销比2.00, | |
| 123 | +可支撑约60天销售,超过安全阈值({target_ratio}个月),无需补货。 | |
| 124 | +``` | |
| 125 | + | |
| 126 | +**✅ 低频件排除**: | |
| 127 | +``` | |
| 128 | +「低频件-出库次数不足」90天内仅出库2次(阈值≥3次), | |
| 129 | +出库间隔约36天,周转频率过低,暂不纳入补货计划。 | |
| 130 | +``` | |
| 131 | + | |
| 132 | +**✅ 呆滞件排除**: | |
| 133 | +``` | |
| 134 | +「呆滞件」当前库存5件,但90天内无任何出库记录, | |
| 135 | +库存滞销风险高,建议安排清理处置,暂不补货。 | |
| 136 | +``` | |
| 137 | + | |
| 138 | +**✅ 低需求排除**: | |
| 139 | +``` | |
| 140 | +「低频件-需求不足」当前库存0件,月均销量0.3件, | |
| 141 | +月均销量不足1件(阈值≥1件),需求过低不值得备货。 | |
| 142 | +``` | |
| 143 | + | |
| 144 | +--- | |
| 145 | + | |
| 146 | +## 输出要求 | |
| 147 | + | |
| 148 | +1. **纯JSON输出**: 只输出JSON对象,不要有任何其他文字、解释或代码块标记 | |
| 149 | +2. **完整覆盖**: 对输入的每个门店都必须在shop_suggestions中体现(无论是否需要补货) | |
| 150 | +3. **理由专业详尽**: reason字段必须按照上述模板撰写,包含关键数据指标 | |
| 151 | + | |
| 152 | +## 输出质量自检 | |
| 153 | + | |
| 154 | +输出前请确认: | |
| 155 | +- ✅ 输出是否为纯JSON,无 ```json 包裹? | |
| 156 | +- ✅ total_suggest_cnt 是否等于所有门店 suggest_cnt 之和? | |
| 157 | +- ✅ 呆滞件/低频件的 suggest_cnt 是否为 0? | |
| 158 | +- ✅ 低频件规则检查:`out_times < 3` 或 `out_duration >= 30` 的是否 suggest_cnt = 0? | |
| 159 | +- ✅ shop_suggestions 是否包含了所有输入门店(无论是否补货)? | |
| 160 | +- ✅ suggest_cnt = 0 的门店是否有详细的不补货理由? | |
| 161 | +- ✅ 每个 reason 是否包含具体数据(库存、销量、库销比、天数等)? | |
| 162 | +- ✅ reason 是否采用专业采购语言,不过于简化? | ... | ... |
prompts/sql_agent.md
0 → 100644
| 1 | +++ a/prompts/sql_agent.md | |
| 1 | +# SQL Agent 系统提示词 | |
| 2 | + | |
| 3 | +## 角色定义 | |
| 4 | + | |
| 5 | +你是一位专业的数据库分析专家,精通 MySQL 查询优化和汽车配件库存管理数据模型。 | |
| 6 | + | |
| 7 | +### 核心能力 | |
| 8 | + | |
| 9 | +1. **SQL编写**: 生成高效、安全的MySQL查询语句 | |
| 10 | +2. **数据建模**: 理解配件库销比数据结构 | |
| 11 | +3. **性能优化**: 避免全表扫描,合理使用索引 | |
| 12 | + | |
| 13 | +--- | |
| 14 | + | |
| 15 | +## 任务说明 | |
| 16 | + | |
| 17 | +根据用户需求生成正确的 SQL 查询语句,从 part_ratio 表获取配件库销比数据。 | |
| 18 | + | |
| 19 | +--- | |
| 20 | + | |
| 21 | +## 数据表结构 | |
| 22 | + | |
| 23 | +```sql | |
| 24 | +CREATE TABLE part_ratio ( | |
| 25 | + id BIGINT PRIMARY KEY, | |
| 26 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 27 | + brand_id BIGINT NOT NULL COMMENT '品牌ID', | |
| 28 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 29 | + dealer_grouping_id BIGINT COMMENT '商家组合ID', | |
| 30 | + supplier_id BIGINT COMMENT '供应商ID', | |
| 31 | + supplier_name VARCHAR(500) COMMENT '供应商名称', | |
| 32 | + area_id BIGINT NOT NULL COMMENT '区域ID', | |
| 33 | + area_name VARCHAR(500) NOT NULL COMMENT '区域名称', | |
| 34 | + shop_id BIGINT NOT NULL COMMENT '库房ID', | |
| 35 | + shop_name VARCHAR(500) NOT NULL COMMENT '库房名称', | |
| 36 | + part_id BIGINT NOT NULL COMMENT '配件ID', | |
| 37 | + part_code VARCHAR(500) NOT NULL COMMENT '配件编码', | |
| 38 | + part_name VARCHAR(500) COMMENT '配件名称', | |
| 39 | + unit VARCHAR(50) COMMENT '单位', | |
| 40 | + cost_price DECIMAL(14,2) COMMENT '成本价', | |
| 41 | + in_stock_unlocked_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '在库未锁数量', | |
| 42 | + has_plan_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '已计划数量', | |
| 43 | + on_the_way_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '在途数量', | |
| 44 | + out_stock_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '出库数量(90天)', | |
| 45 | + buy_cnt INT DEFAULT 0 COMMENT '客户订件数', | |
| 46 | + transfer_cnt INT DEFAULT 0 COMMENT '主动调拨在途数量', | |
| 47 | + gen_transfer_cnt INT DEFAULT 0 COMMENT '自动调拨在途数量', | |
| 48 | + storage_locked_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '库存锁定数量', | |
| 49 | + out_stock_ongoing_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '出库中数量', | |
| 50 | + stock_age INT DEFAULT 0 COMMENT '库龄(天)', | |
| 51 | + out_times INT COMMENT '出库次数', | |
| 52 | + part_biz_type TINYINT COMMENT '配件业务类型: 1=配件 2=装饰', | |
| 53 | + statistics_date VARCHAR(50) NOT NULL COMMENT '统计日期(yyyy-MM-dd)' | |
| 54 | +); | |
| 55 | +``` | |
| 56 | + | |
| 57 | +--- | |
| 58 | + | |
| 59 | +## 核心计算公式 | |
| 60 | + | |
| 61 | +| 指标 | 公式 | 说明 | | |
| 62 | +|------|------|------| | |
| 63 | +| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt` | 可用库存总量 | | |
| 64 | +| 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 | | |
| 65 | +| 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 | | |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +## 输出格式 | |
| 70 | + | |
| 71 | +仅输出JSON对象,**不要**包含其他文字或代码块标记: | |
| 72 | + | |
| 73 | +{ | |
| 74 | + "sql": "SELECT ...", | |
| 75 | + "explanation": "SQL说明" | |
| 76 | +} | |
| 77 | + | |
| 78 | +--- | |
| 79 | + | |
| 80 | +## 约束条件 | |
| 81 | + | |
| 82 | +1. **MySQL 5.x 兼容**: 不使用窗口函数(MySQL 5.x 不支持) | |
| 83 | +2. **必须过滤条件**: | |
| 84 | + - `statistics_date = 'xxxx-xx-xx'` | |
| 85 | + - `part_biz_type = 1`(仅配件) | |
| 86 | +3. **排序规则**: 按库销比升序(优先处理库销比低的) | |
| 87 | +4. **只允许 SELECT**: 禁止 INSERT/UPDATE/DELETE | ... | ... |
prompts/suggestion.md
0 → 100644
| 1 | +++ a/prompts/suggestion.md | |
| 1 | +# 补货建议生成提示词 | |
| 2 | + | |
| 3 | +基于以下配件库销比数据,分析并生成补货建议。 | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 商家组合信息 | |
| 8 | + | |
| 9 | +| 项目 | 数值 | | |
| 10 | +|------|------| | |
| 11 | +| 商家组合ID | {dealer_grouping_id} | | |
| 12 | +| 商家组合名称 | {dealer_grouping_name} | | |
| 13 | +| 统计日期 | {statistics_date} | | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 配件数据 | |
| 18 | + | |
| 19 | +{part_data} | |
| 20 | + | |
| 21 | +--- | |
| 22 | + | |
| 23 | +## 字段说明 | |
| 24 | + | |
| 25 | +| 字段名 | 含义 | 计算公式/说明 | | |
| 26 | +|--------|------|---------------| | |
| 27 | +| valid_storage_cnt | 有效库存数量 | 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途 | | |
| 28 | +| avg_sales_cnt | 月均销量 | (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 | | |
| 29 | +| cost_price | 成本价 | 单件采购成本 | | |
| 30 | +| current_ratio | 当前库销比 | 有效库存 / 月均销量 | | |
| 31 | +| out_stock_cnt | 90天出库数量 | 用于判断呆滞件 | | |
| 32 | + | |
| 33 | +--- | |
| 34 | + | |
| 35 | +## 业务术语定义 | |
| 36 | + | |
| 37 | +### 🚫 呆滞件(不补货) | |
| 38 | + | |
| 39 | +- **判定条件**: `valid_storage_cnt > 0` 且 `out_stock_cnt = 0` | |
| 40 | +- **特征**: 有库存但90天无任何出库 | |
| 41 | +- **处理**: 不做计划,应考虑清理 | |
| 42 | + | |
| 43 | +### 🚫 低频件(不补货) | |
| 44 | + | |
| 45 | +- **判定条件**: `valid_storage_cnt = 0` 且 `avg_sales_cnt < 1` | |
| 46 | +- **特征**: 无库存且月均销量不足1件 | |
| 47 | +- **处理**: 不做计划,需求过低 | |
| 48 | + | |
| 49 | +### ✅ 缺货件(优先补货) | |
| 50 | + | |
| 51 | +- **判定条件**: `valid_storage_cnt = 0` 且 `avg_sales_cnt >= 1` | |
| 52 | +- **特征**: 无库存但有稳定需求 | |
| 53 | +- **处理**: 高优先级补货 | |
| 54 | + | |
| 55 | +--- | |
| 56 | + | |
| 57 | +## 分析任务 | |
| 58 | + | |
| 59 | +请按以下步骤执行: | |
| 60 | + | |
| 61 | +1. **识别并排除** 呆滞件和低频件(这些配件不应出现在输出中) | |
| 62 | +2. **逐个分析** 剩余配件的库存状况 | |
| 63 | +3. **计算补货量** 使用公式: `建议数量 = ceil(目标库销比 × 月均销量 - 当前库存)` | |
| 64 | +4. **确定优先级** 基于库销比和销量水平 | |
| 65 | +5. **撰写理由** 为每个建议提供数据支撑的决策依据 | |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +## 输出格式 | |
| 70 | + | |
| 71 | +直接输出JSON数组,**不要**包含 ```json 标记: | |
| 72 | + | |
| 73 | +[ | |
| 74 | + {{ | |
| 75 | + "shop_id": 库房ID(整数), | |
| 76 | + "shop_name": "库房名称", | |
| 77 | + "part_code": "配件编码", | |
| 78 | + "part_name": "配件名称", | |
| 79 | + "unit": "单位", | |
| 80 | + "cost_price": 成本价(数字), | |
| 81 | + "current_storage_cnt": 当前库存(数字), | |
| 82 | + "avg_sales_cnt": 月均销量(数字), | |
| 83 | + "current_ratio": 当前库销比(数字), | |
| 84 | + "suggest_cnt": 建议采购数量(整数), | |
| 85 | + "suggest_amount": 建议采购金额(数字), | |
| 86 | + "suggestion_reason": "详细的补货依据和决策理由", | |
| 87 | + "priority": 优先级(1=高/2=中/3=低), | |
| 88 | + "confidence": 置信度(0.0-1.0) | |
| 89 | + }} | |
| 90 | +] | |
| 91 | + | |
| 92 | +--- | |
| 93 | + | |
| 94 | +## 重要约束 | |
| 95 | + | |
| 96 | +1. **仅输出需要补货的配件**(suggest_cnt > 0) | |
| 97 | +2. **呆滞件和低频件不应出现在输出中** | |
| 98 | +3. **suggest_amount = suggest_cnt × cost_price** | |
| 99 | +4. **输出必须是合法的JSON数组** | ... | ... |
prompts/suggestion_system.md
0 → 100644
| 1 | +++ a/prompts/suggestion_system.md | |
| 1 | +# 补货建议分析系统提示词 | |
| 2 | + | |
| 3 | +## 角色定义 | |
| 4 | + | |
| 5 | +你是一位资深的汽车配件库存管理专家,专注于库销比分析和补货决策。 | |
| 6 | + | |
| 7 | +### 核心能力 | |
| 8 | + | |
| 9 | +1. **数据分析**: 精准解读库销比、销量、库存等指标 | |
| 10 | +2. **风险识别**: 识别缺货风险、积压风险、呆滞风险 | |
| 11 | +3. **决策优化**: 在资金有限时合理分配补货优先级 | |
| 12 | +4. **成本意识**: 平衡库存周转与服务水平 | |
| 13 | + | |
| 14 | +--- | |
| 15 | + | |
| 16 | +## 决策标准 | |
| 17 | + | |
| 18 | +### 需要补货的情况 | |
| 19 | + | |
| 20 | +| 优先级 | 条件 | 说明 | | |
| 21 | +|--------|------|------| | |
| 22 | +| 高 | 库销比 < 0.5 且月均销量 ≥ 1 | 缺货风险高,急需补货 | | |
| 23 | +| 中 | 库销比 0.5-1.0 且月均销量 ≥ 1 | 库存偏低,建议补货 | | |
| 24 | +| 低 | 库销比 1.0-1.5 且月均销量 ≥ 1 | 安全边界,可选补货 | | |
| 25 | + | |
| 26 | +### 不补货的情况 | |
| 27 | + | |
| 28 | +- **呆滞件**: 有库存但90天无销量(out_stock_cnt = 0) | |
| 29 | +- **低频件**: 无库存且月均销量 < 1 | |
| 30 | +- **库存充足**: 库销比 > 1.5 | |
| 31 | + | |
| 32 | +--- | |
| 33 | + | |
| 34 | +## 输出要求 | |
| 35 | + | |
| 36 | +1. **仅输出JSON格式**,不包含任何解释性文字 | |
| 37 | +2. **基于数据决策**,不做假设或推测 | |
| 38 | +3. **理由具体明确**,引用数据支撑结论 | |
| 39 | +4. **计算准确无误**,建议金额 = 建议数量 × 成本价 | ... | ... |
pyproject.toml
0 → 100644
| 1 | +++ a/pyproject.toml | |
| 1 | +[build-system] | |
| 2 | +requires = ["hatchling"] | |
| 3 | +build-backend = "hatchling.build" | |
| 4 | + | |
| 5 | +[project] | |
| 6 | +name = "fw-pms-ai" | |
| 7 | +version = "0.1.0" | |
| 8 | +description = "AI 补货建议系统 - 基于 LangChain + LangGraph" | |
| 9 | +readme = "README.md" | |
| 10 | +requires-python = ">=3.11" | |
| 11 | +license = "MIT" | |
| 12 | +authors = [ | |
| 13 | + { name = "FeeWee", email = "dev@feewee.cn" } | |
| 14 | +] | |
| 15 | +dependencies = [ | |
| 16 | + # LangChain 生态 | |
| 17 | + "langchain>=0.3.0", | |
| 18 | + "langgraph>=0.2.0", | |
| 19 | + "langchain-core>=0.3.0", | |
| 20 | + | |
| 21 | + # LLM 集成 | |
| 22 | + "zhipuai>=2.0.0", | |
| 23 | + | |
| 24 | + # 定时任务 | |
| 25 | + "apscheduler>=3.10.0", | |
| 26 | + | |
| 27 | + # 数据库 | |
| 28 | + "mysql-connector-python>=8.0.0", | |
| 29 | + "sqlalchemy>=2.0.0", | |
| 30 | + | |
| 31 | + # 配置管理 | |
| 32 | + "pydantic>=2.0.0", | |
| 33 | + "pydantic-settings>=2.0.0", | |
| 34 | + "python-dotenv>=1.0.0", | |
| 35 | + | |
| 36 | + # 工具库 | |
| 37 | + "httpx>=0.25.0", | |
| 38 | + "tenacity>=8.0.0", | |
| 39 | + | |
| 40 | + # Web API | |
| 41 | + "fastapi>=0.109.0", | |
| 42 | + "uvicorn[standard]>=0.27.0", | |
| 43 | +] | |
| 44 | + | |
| 45 | +[project.optional-dependencies] | |
| 46 | +dev = [ | |
| 47 | + "pytest>=7.0.0", | |
| 48 | + "pytest-asyncio>=0.21.0", | |
| 49 | + "black>=23.0.0", | |
| 50 | + "ruff>=0.1.0", | |
| 51 | +] | |
| 52 | + | |
| 53 | +[project.scripts] | |
| 54 | +fw-pms-ai = "fw_pms_ai.main:main" | |
| 55 | + | |
| 56 | +[tool.hatch.build.targets.wheel] | |
| 57 | +packages = ["src/fw_pms_ai"] | |
| 58 | + | |
| 59 | +[tool.black] | |
| 60 | +line-length = 100 | |
| 61 | +target-version = ["py311"] | |
| 62 | + | |
| 63 | +[tool.ruff] | |
| 64 | +line-length = 100 | |
| 65 | +target-version = "py311" | ... | ... |
sql/final_schema.sql
0 → 100644
| 1 | +++ a/sql/final_schema.sql | |
| 1 | +-- ============================================================================ | |
| 2 | +-- AI 补货建议系统 - 表结构 | |
| 3 | +-- ============================================================================ | |
| 4 | +-- 版本: 1.0.0 | |
| 5 | +-- 更新日期: 2026-01-31 | |
| 6 | +-- ============================================================================ | |
| 7 | + | |
| 8 | +-- 1. AI补货任务表 | |
| 9 | +DROP TABLE IF EXISTS ai_replenishment_task; | |
| 10 | +CREATE TABLE ai_replenishment_task ( | |
| 11 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 12 | + task_no VARCHAR(32) NOT NULL UNIQUE COMMENT '任务编号(AI-开头)', | |
| 13 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 14 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 15 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 16 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 17 | + plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '计划采购金额(预算)', | |
| 18 | + actual_amount DECIMAL(14,2) DEFAULT 0 COMMENT '实际分配金额', | |
| 19 | + part_count INT DEFAULT 0 COMMENT '分配配件数量', | |
| 20 | + base_ratio DECIMAL(10,4) COMMENT '基准库销比', | |
| 21 | + status TINYINT DEFAULT 0 COMMENT '状态: 0-运行中 1-成功 2-失败', | |
| 22 | + error_message TEXT COMMENT '错误信息', | |
| 23 | + llm_provider VARCHAR(32) COMMENT 'LLM提供商', | |
| 24 | + llm_model VARCHAR(64) COMMENT 'LLM模型名称', | |
| 25 | + llm_total_tokens INT DEFAULT 0 COMMENT 'LLM总Token数', | |
| 26 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 27 | + start_time DATETIME COMMENT '任务开始时间', | |
| 28 | + end_time DATETIME COMMENT '任务结束时间', | |
| 29 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 30 | + INDEX idx_group_date (group_id, statistics_date), | |
| 31 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 32 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货任务表-记录每次补货建议任务执行'; | |
| 33 | + | |
| 34 | +-- 2. AI补货建议明细表 | |
| 35 | +DROP TABLE IF EXISTS ai_replenishment_detail; | |
| 36 | +CREATE TABLE ai_replenishment_detail ( | |
| 37 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 38 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 39 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 40 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 41 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 42 | + shop_id BIGINT NOT NULL COMMENT '库房ID', | |
| 43 | + shop_name VARCHAR(128) COMMENT '库房名称', | |
| 44 | + part_code VARCHAR(64) NOT NULL COMMENT '配件编码', | |
| 45 | + part_name VARCHAR(256) COMMENT '配件名称', | |
| 46 | + unit VARCHAR(32) COMMENT '单位', | |
| 47 | + cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价', | |
| 48 | + current_ratio DECIMAL(10,4) COMMENT '当前库销比', | |
| 49 | + base_ratio DECIMAL(10,4) COMMENT '基准库销比', | |
| 50 | + post_plan_ratio DECIMAL(10,4) COMMENT '计划后预计库销比', | |
| 51 | + valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量', | |
| 52 | + avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)', | |
| 53 | + suggest_cnt INT DEFAULT 0 COMMENT '建议采购数量', | |
| 54 | + suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议采购金额', | |
| 55 | + suggestion_reason TEXT COMMENT '补货建议理由', | |
| 56 | + priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低', | |
| 57 | + llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度', | |
| 58 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 59 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 60 | + INDEX idx_task_no (task_no), | |
| 61 | + INDEX idx_shop_part (shop_id, part_code) | |
| 62 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议明细表-存储每次任务的配件分配结果'; | |
| 63 | + | |
| 64 | +-- 3. AI补货分析报告表 | |
| 65 | +DROP TABLE IF EXISTS ai_replenishment_report; | |
| 66 | +CREATE TABLE ai_replenishment_report ( | |
| 67 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 68 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 69 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 70 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 71 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 72 | + total_storage_amount DECIMAL(16,2) COMMENT '总库存金额', | |
| 73 | + total_sales_amount DECIMAL(16,2) COMMENT '总销售金额(90天)', | |
| 74 | + overall_ratio DECIMAL(10,4) COMMENT '整体库销比', | |
| 75 | + target_ratio DECIMAL(10,4) COMMENT '目标库销比', | |
| 76 | + total_part_count INT DEFAULT 0 COMMENT '配件总数', | |
| 77 | + shortage_part_count INT DEFAULT 0 COMMENT '缺货配件数', | |
| 78 | + overstock_part_count INT DEFAULT 0 COMMENT '超标配件数', | |
| 79 | + normal_part_count INT DEFAULT 0 COMMENT '正常配件数', | |
| 80 | + stagnant_part_count INT DEFAULT 0 COMMENT '呆滞配件数', | |
| 81 | + suggest_total_amount DECIMAL(14,2) COMMENT '建议采购总金额', | |
| 82 | + suggest_part_count INT DEFAULT 0 COMMENT '建议采购配件数', | |
| 83 | + top_priority_parts TEXT COMMENT '重点关注配件(JSON数组)', | |
| 84 | + report_content JSON COMMENT '结构化报告内容(JSON)', | |
| 85 | + llm_provider VARCHAR(32) COMMENT 'LLM提供商', | |
| 86 | + llm_model VARCHAR(64) COMMENT 'LLM模型', | |
| 87 | + generation_tokens INT DEFAULT 0 COMMENT '生成Token数', | |
| 88 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 89 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 90 | + INDEX idx_task_no (task_no), | |
| 91 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 92 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货分析报告表-存储LLM生成的分析报告'; | |
| 93 | + | |
| 94 | +-- 4. AI任务执行日志表 | |
| 95 | +DROP TABLE IF EXISTS ai_task_execution_log; | |
| 96 | +CREATE TABLE ai_task_execution_log ( | |
| 97 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 98 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 99 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 100 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 101 | + brand_grouping_name VARCHAR(128) COMMENT '品牌组合名称', | |
| 102 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 103 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 104 | + step_name VARCHAR(64) NOT NULL COMMENT '步骤名称', | |
| 105 | + step_order INT DEFAULT 0 COMMENT '步骤顺序', | |
| 106 | + status TINYINT DEFAULT 0 COMMENT '状态: 0-进行中 1-成功 2-失败 3-跳过', | |
| 107 | + input_data TEXT COMMENT '输入数据(JSON)', | |
| 108 | + output_data TEXT COMMENT '输出数据(JSON)', | |
| 109 | + error_message TEXT COMMENT '错误信息', | |
| 110 | + retry_count INT DEFAULT 0 COMMENT '重试次数', | |
| 111 | + sql_query TEXT COMMENT 'SQL查询语句(如有)', | |
| 112 | + llm_prompt TEXT COMMENT 'LLM提示词(如有)', | |
| 113 | + llm_response TEXT COMMENT 'LLM响应(如有)', | |
| 114 | + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', | |
| 115 | + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', | |
| 116 | + start_time DATETIME COMMENT '开始时间', | |
| 117 | + end_time DATETIME COMMENT '结束时间', | |
| 118 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 119 | + INDEX idx_task_no (task_no), | |
| 120 | + INDEX idx_group_date (group_id, create_time), | |
| 121 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 122 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI任务执行日志表-记录每个步骤的执行详情'; | |
| 123 | + | |
| 124 | +-- 5. AI补货配件汇总表 | |
| 125 | +DROP TABLE IF EXISTS ai_replenishment_part_summary; | |
| 126 | +CREATE TABLE ai_replenishment_part_summary ( | |
| 127 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 128 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 129 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 130 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 131 | + part_code VARCHAR(64) NOT NULL COMMENT '配件编码', | |
| 132 | + part_name VARCHAR(256) COMMENT '配件名称', | |
| 133 | + unit VARCHAR(32) COMMENT '单位', | |
| 134 | + cost_price DECIMAL(14,2) DEFAULT 0.00 COMMENT '成本价', | |
| 135 | + total_storage_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总库存数量', | |
| 136 | + total_avg_sales_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总月均销量', | |
| 137 | + group_current_ratio DECIMAL(10,4) COMMENT '商家组合级库销比', | |
| 138 | + total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量', | |
| 139 | + total_suggest_amount DECIMAL(14,2) DEFAULT 0.00 COMMENT '总建议金额', | |
| 140 | + shop_count INT DEFAULT 0 COMMENT '涉及门店数', | |
| 141 | + need_replenishment_shop_count INT DEFAULT 0 COMMENT '需要补货的门店数', | |
| 142 | + part_decision_reason TEXT COMMENT '配件级补货决策理由', | |
| 143 | + priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1=高, 2=中, 3=低', | |
| 144 | + llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度', | |
| 145 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 146 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 147 | + INDEX idx_task_no (task_no), | |
| 148 | + INDEX idx_part_code (part_code), | |
| 149 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 150 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议-配件汇总表'; | |
| 151 | + | ... | ... |
sql/init.sql
0 → 100644
| 1 | +++ a/sql/init.sql | |
| 1 | +-- AI 补货建议系统表结构 | |
| 2 | +-- 版本: 2.0 | |
| 3 | +-- 更新: 2026-01-27 | |
| 4 | + | |
| 5 | +-- 1. AI预计划明细表 | |
| 6 | +DROP TABLE IF EXISTS ai_pre_plan_detail; | |
| 7 | +CREATE TABLE ai_pre_plan_detail ( | |
| 8 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 9 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 10 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 11 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 12 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 13 | + dealer_id BIGINT COMMENT '商家ID', | |
| 14 | + dealer_name VARCHAR(128) COMMENT '商家名称', | |
| 15 | + area_id BIGINT COMMENT '区域ID', | |
| 16 | + area_name VARCHAR(128) COMMENT '区域名称', | |
| 17 | + shop_id BIGINT NOT NULL COMMENT '库房ID', | |
| 18 | + shop_name VARCHAR(128) COMMENT '库房名称', | |
| 19 | + part_id BIGINT COMMENT '配件ID', | |
| 20 | + part_code VARCHAR(64) NOT NULL COMMENT '配件编码', | |
| 21 | + part_name VARCHAR(256) COMMENT '配件名称', | |
| 22 | + unit VARCHAR(32) COMMENT '单位', | |
| 23 | + cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价', | |
| 24 | + base_ratio DECIMAL(10,4) COMMENT '基准库销比', | |
| 25 | + current_ratio DECIMAL(10,4) COMMENT '当前库销比', | |
| 26 | + valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量(在库未锁+在途+计划数+调拨在途)', | |
| 27 | + avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)', | |
| 28 | + target_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '目标库存数量', | |
| 29 | + plan_cnt INT DEFAULT 0 COMMENT '建议计划数量', | |
| 30 | + plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议计划金额', | |
| 31 | + part_biz_type TINYINT DEFAULT 1 COMMENT '配件业务类型: 1-配件 2-装饰', | |
| 32 | + statistics_date VARCHAR(16) NOT NULL COMMENT '统计日期(yyyy-MM-dd)', | |
| 33 | + yn TINYINT DEFAULT 1 COMMENT '是否有效: 1-是 0-否', | |
| 34 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 35 | + INDEX idx_group_date (group_id, statistics_date), | |
| 36 | + INDEX idx_dealer_grouping (dealer_grouping_id), | |
| 37 | + INDEX idx_shop (shop_id) | |
| 38 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI预计划明细表-存储每日计算的配件补货建议'; | |
| 39 | + | |
| 40 | +-- 2. AI补货任务表 | |
| 41 | +DROP TABLE IF EXISTS ai_replenishment_task; | |
| 42 | +CREATE TABLE ai_replenishment_task ( | |
| 43 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 44 | + task_no VARCHAR(32) NOT NULL UNIQUE COMMENT '任务编号(AI-开头)', | |
| 45 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 46 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 47 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 48 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 49 | + plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '计划采购金额(预算)', | |
| 50 | + actual_amount DECIMAL(14,2) DEFAULT 0 COMMENT '实际分配金额', | |
| 51 | + part_count INT DEFAULT 0 COMMENT '分配配件数量', | |
| 52 | + base_ratio DECIMAL(10,4) COMMENT '基准库销比', | |
| 53 | + status TINYINT DEFAULT 0 COMMENT '状态: 0-运行中 1-成功 2-失败', | |
| 54 | + error_message TEXT COMMENT '错误信息', | |
| 55 | + llm_provider VARCHAR(32) COMMENT 'LLM提供商', | |
| 56 | + llm_model VARCHAR(64) COMMENT 'LLM模型名称', | |
| 57 | + llm_total_tokens INT DEFAULT 0 COMMENT 'LLM总Token数', | |
| 58 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 59 | + start_time DATETIME COMMENT '任务开始时间', | |
| 60 | + end_time DATETIME COMMENT '任务结束时间', | |
| 61 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 62 | + INDEX idx_group_date (group_id, statistics_date), | |
| 63 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 64 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货任务表-记录每次补货建议任务执行'; | |
| 65 | + | |
| 66 | +-- 3. AI补货建议明细表 | |
| 67 | +DROP TABLE IF EXISTS ai_replenishment_detail; | |
| 68 | +CREATE TABLE ai_replenishment_detail ( | |
| 69 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 70 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 71 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 72 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 73 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 74 | + shop_id BIGINT NOT NULL COMMENT '库房ID', | |
| 75 | + shop_name VARCHAR(128) COMMENT '库房名称', | |
| 76 | + part_code VARCHAR(64) NOT NULL COMMENT '配件编码', | |
| 77 | + part_name VARCHAR(256) COMMENT '配件名称', | |
| 78 | + unit VARCHAR(32) COMMENT '单位', | |
| 79 | + cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价', | |
| 80 | + current_ratio DECIMAL(10,4) COMMENT '当前库销比', | |
| 81 | + base_ratio DECIMAL(10,4) COMMENT '基准库销比', | |
| 82 | + post_plan_ratio DECIMAL(10,4) COMMENT '计划后预计库销比', | |
| 83 | + valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量', | |
| 84 | + avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)', | |
| 85 | + suggest_cnt INT DEFAULT 0 COMMENT '建议采购数量', | |
| 86 | + suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议采购金额', | |
| 87 | + suggestion_reason TEXT COMMENT '补货建议理由', | |
| 88 | + priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低', | |
| 89 | + llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度', | |
| 90 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 91 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 92 | + INDEX idx_task_no (task_no), | |
| 93 | + INDEX idx_shop_part (shop_id, part_code) | |
| 94 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议明细表-存储每次任务的配件分配结果'; | |
| 95 | + | |
| 96 | +-- 4. AI补货报告表 | |
| 97 | +DROP TABLE IF EXISTS ai_replenishment_report; | |
| 98 | +CREATE TABLE ai_replenishment_report ( | |
| 99 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 100 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 101 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 102 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 103 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 104 | + total_storage_amount DECIMAL(16,2) COMMENT '总库存金额', | |
| 105 | + total_sales_amount DECIMAL(16,2) COMMENT '总销售金额(90天)', | |
| 106 | + overall_ratio DECIMAL(10,4) COMMENT '整体库销比', | |
| 107 | + target_ratio DECIMAL(10,4) COMMENT '目标库销比', | |
| 108 | + total_part_count INT DEFAULT 0 COMMENT '配件总数', | |
| 109 | + shortage_part_count INT DEFAULT 0 COMMENT '缺货配件数', | |
| 110 | + overstock_part_count INT DEFAULT 0 COMMENT '超标配件数', | |
| 111 | + normal_part_count INT DEFAULT 0 COMMENT '正常配件数', | |
| 112 | + stagnant_part_count INT DEFAULT 0 COMMENT '呆滞配件数', | |
| 113 | + suggest_total_amount DECIMAL(14,2) COMMENT '建议采购总金额', | |
| 114 | + suggest_part_count INT DEFAULT 0 COMMENT '建议采购配件数', | |
| 115 | + top_priority_parts TEXT COMMENT '重点关注配件(JSON数组)', | |
| 116 | + report_title VARCHAR(256) COMMENT '报告标题', | |
| 117 | + executive_summary TEXT COMMENT '执行摘要', | |
| 118 | + inventory_analysis TEXT COMMENT '库存分析', | |
| 119 | + risk_assessment TEXT COMMENT '风险评估', | |
| 120 | + purchase_recommendations TEXT COMMENT '采购建议', | |
| 121 | + optimization_suggestions TEXT COMMENT '优化建议', | |
| 122 | + full_report_markdown LONGTEXT COMMENT '完整报告(Markdown)', | |
| 123 | + llm_provider VARCHAR(32) COMMENT 'LLM提供商', | |
| 124 | + llm_model VARCHAR(64) COMMENT 'LLM模型', | |
| 125 | + generation_tokens INT DEFAULT 0 COMMENT '生成Token数', | |
| 126 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 127 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 128 | + INDEX idx_task_no (task_no), | |
| 129 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 130 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货分析报告表-存储LLM生成的分析报告'; | ... | ... |
sql/v2_add_log_tables.sql
0 → 100644
| 1 | +++ a/sql/v2_add_log_tables.sql | |
| 1 | +-- AI 任务执行日志表 | |
| 2 | +-- 版本: 1.0 | |
| 3 | +-- 创建日期: 2026-01-28 | |
| 4 | + | |
| 5 | +-- 任务执行日志表 | |
| 6 | +CREATE TABLE IF NOT EXISTS ai_task_execution_log ( | |
| 7 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 8 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 9 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 10 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 11 | + brand_grouping_name VARCHAR(128) COMMENT '品牌组合名称', | |
| 12 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 13 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 14 | + step_name VARCHAR(64) NOT NULL COMMENT '步骤名称', | |
| 15 | + step_order INT DEFAULT 0 COMMENT '步骤顺序', | |
| 16 | + status TINYINT DEFAULT 0 COMMENT '状态: 0-进行中 1-成功 2-失败 3-跳过', | |
| 17 | + input_data TEXT COMMENT '输入数据(JSON)', | |
| 18 | + output_data TEXT COMMENT '输出数据(JSON)', | |
| 19 | + error_message TEXT COMMENT '错误信息', | |
| 20 | + retry_count INT DEFAULT 0 COMMENT '重试次数', | |
| 21 | + sql_query TEXT COMMENT 'SQL查询语句(如有)', | |
| 22 | + llm_prompt TEXT COMMENT 'LLM提示词(如有)', | |
| 23 | + llm_response TEXT COMMENT 'LLM响应(如有)', | |
| 24 | + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', | |
| 25 | + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', | |
| 26 | + start_time DATETIME COMMENT '开始时间', | |
| 27 | + end_time DATETIME COMMENT '结束时间', | |
| 28 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 29 | + INDEX idx_task_no (task_no), | |
| 30 | + INDEX idx_group_date (group_id, create_time), | |
| 31 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 32 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI任务执行日志表-记录每个步骤的执行详情'; | |
| 33 | + | |
| 34 | +-- LLM补货建议明细表(商家组合维度) | |
| 35 | +CREATE TABLE IF NOT EXISTS ai_llm_suggestion_detail ( | |
| 36 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 37 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 38 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 39 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 40 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 41 | + shop_id BIGINT NOT NULL COMMENT '库房ID', | |
| 42 | + shop_name VARCHAR(128) COMMENT '库房名称', | |
| 43 | + part_code VARCHAR(64) NOT NULL COMMENT '配件编码', | |
| 44 | + part_name VARCHAR(256) COMMENT '配件名称', | |
| 45 | + unit VARCHAR(32) COMMENT '单位', | |
| 46 | + cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价', | |
| 47 | + current_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '当前库存数量', | |
| 48 | + avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)', | |
| 49 | + current_ratio DECIMAL(10,4) COMMENT '当前库销比', | |
| 50 | + suggest_cnt INT DEFAULT 0 COMMENT 'LLM建议采购数量', | |
| 51 | + suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT 'LLM建议采购金额', | |
| 52 | + suggestion_reason TEXT COMMENT 'LLM建议依据/理由', | |
| 53 | + priority INT DEFAULT 0 COMMENT '优先级(1-高 2-中 3-低)', | |
| 54 | + llm_confidence DECIMAL(5,2) COMMENT 'LLM置信度(0-1)', | |
| 55 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 56 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 57 | + INDEX idx_task_no (task_no), | |
| 58 | + INDEX idx_dealer_grouping (dealer_grouping_id), | |
| 59 | + INDEX idx_shop_part (shop_id, part_code) | |
| 60 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM补货建议明细表-存储LLM生成的商家组合维度配件补货建议及依据'; | ... | ... |
sql/v3_add_suggestion_reason.sql
0 → 100644
sql/v4_cleanup_unused_tables.sql
0 → 100644
sql/v4_report_json_structure.sql
0 → 100644
| 1 | +++ a/sql/v4_report_json_structure.sql | |
| 1 | +-- 报告结构化重构 - 将 Markdown 字段改为 JSON | |
| 2 | +-- 版本: 4.0 | |
| 3 | +-- 日期: 2026-01-30 | |
| 4 | + | |
| 5 | +-- 1. 新增 JSON 字段 | |
| 6 | +ALTER TABLE ai_replenishment_report | |
| 7 | +ADD COLUMN report_content JSON COMMENT '结构化报告内容(JSON)' AFTER top_priority_parts; | |
| 8 | + | |
| 9 | +-- 2. 删除旧的 Markdown 字段 | |
| 10 | +ALTER TABLE ai_replenishment_report | |
| 11 | +DROP COLUMN report_title, | |
| 12 | +DROP COLUMN executive_summary, | |
| 13 | +DROP COLUMN inventory_analysis, | |
| 14 | +DROP COLUMN risk_assessment, | |
| 15 | +DROP COLUMN purchase_recommendations, | |
| 16 | +DROP COLUMN optimization_suggestions, | |
| 17 | +DROP COLUMN full_report_markdown; | ... | ... |
sql/v5_add_priority_and_confidence.sql
0 → 100644
| 1 | +++ a/sql/v5_add_priority_and_confidence.sql | |
| 1 | +-- 5. 添加优先级和置信度字段 | |
| 2 | +-- 时间: 2026-01-30 | |
| 3 | + | |
| 4 | +ALTER TABLE ai_replenishment_detail | |
| 5 | +ADD COLUMN priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低' AFTER suggestion_reason, | |
| 6 | +ADD COLUMN llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度' AFTER priority; | ... | ... |
sql/v6_add_part_summary.sql
0 → 100644
| 1 | +++ a/sql/v6_add_part_summary.sql | |
| 1 | +-- v6: 新增配件汇总表 | |
| 2 | +-- 用于存储配件在商家组合维度的汇总补货建议 | |
| 3 | + | |
| 4 | +CREATE TABLE IF NOT EXISTS ai_replenishment_part_summary ( | |
| 5 | + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', | |
| 6 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 7 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 8 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 9 | + part_code VARCHAR(64) NOT NULL COMMENT '配件编码', | |
| 10 | + part_name VARCHAR(256) COMMENT '配件名称', | |
| 11 | + unit VARCHAR(32) COMMENT '单位', | |
| 12 | + cost_price DECIMAL(14,2) DEFAULT 0.00 COMMENT '成本价', | |
| 13 | + | |
| 14 | + -- 商家组合级别汇总数据 | |
| 15 | + total_storage_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总库存数量', | |
| 16 | + total_avg_sales_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总月均销量', | |
| 17 | + group_current_ratio DECIMAL(10,4) COMMENT '商家组合级库销比', | |
| 18 | + | |
| 19 | + -- 补货建议汇总 | |
| 20 | + total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量', | |
| 21 | + total_suggest_amount DECIMAL(14,2) DEFAULT 0.00 COMMENT '总建议金额', | |
| 22 | + shop_count INT DEFAULT 0 COMMENT '涉及门店数', | |
| 23 | + need_replenishment_shop_count INT DEFAULT 0 COMMENT '需要补货的门店数', | |
| 24 | + | |
| 25 | + -- LLM分析结果 | |
| 26 | + part_decision_reason TEXT COMMENT '配件级补货决策理由', | |
| 27 | + priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1=高, 2=中, 3=低', | |
| 28 | + llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度', | |
| 29 | + | |
| 30 | + -- 元数据 | |
| 31 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 32 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 33 | + | |
| 34 | + INDEX idx_task_no (task_no), | |
| 35 | + INDEX idx_part_code (part_code), | |
| 36 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 37 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议-配件汇总表'; | ... | ... |
sql/v7_add_low_frequency_count.sql
0 → 100644
sql/v8_analysis_report.sql
0 → 100644
| 1 | +++ a/sql/v8_analysis_report.sql | |
| 1 | +-- v8_analysis_report.sql | |
| 2 | +-- 分析报告表,支持按品牌组合分组的分析数据持久化 | |
| 3 | +-- 分析内容由 LLM 生成 | |
| 4 | + | |
| 5 | +CREATE TABLE IF NOT EXISTS ai_analysis_report ( | |
| 6 | + id BIGINT AUTO_INCREMENT PRIMARY KEY, | |
| 7 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 8 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 9 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 10 | + brand_grouping_id BIGINT DEFAULT NULL COMMENT '品牌组合ID,NULL表示全部品牌', | |
| 11 | + brand_grouping_name VARCHAR(256) DEFAULT NULL COMMENT '品牌组合名称', | |
| 12 | + | |
| 13 | + -- 风险管控统计(数量) | |
| 14 | + total_count INT DEFAULT 0 COMMENT '配件总数', | |
| 15 | + shortage_count INT DEFAULT 0 COMMENT '缺货数量', | |
| 16 | + overstock_count INT DEFAULT 0 COMMENT '超标数量', | |
| 17 | + normal_count INT DEFAULT 0 COMMENT '正常数量', | |
| 18 | + low_frequency_count INT DEFAULT 0 COMMENT '低频数量', | |
| 19 | + stagnant_count INT DEFAULT 0 COMMENT '呆滞数量', | |
| 20 | + | |
| 21 | + -- 风险管控统计(金额) | |
| 22 | + total_amount DECIMAL(16,2) DEFAULT 0 COMMENT '总金额', | |
| 23 | + shortage_amount DECIMAL(16,2) DEFAULT 0 COMMENT '缺货金额', | |
| 24 | + overstock_amount DECIMAL(16,2) DEFAULT 0 COMMENT '超标金额', | |
| 25 | + normal_amount DECIMAL(16,2) DEFAULT 0 COMMENT '正常金额', | |
| 26 | + low_frequency_amount DECIMAL(16,2) DEFAULT 0 COMMENT '低频金额', | |
| 27 | + stagnant_amount DECIMAL(16,2) DEFAULT 0 COMMENT '呆滞金额', | |
| 28 | + | |
| 29 | + -- LLM 生成的分析内容 | |
| 30 | + overview_analysis TEXT COMMENT '核心经营综述(LLM生成)', | |
| 31 | + risk_analysis TEXT COMMENT '风险管控分析(LLM生成)', | |
| 32 | + action_plan TEXT COMMENT '行动计划分析(LLM生成)', | |
| 33 | + report_content JSON COMMENT '结构化报告内容', | |
| 34 | + | |
| 35 | + llm_provider VARCHAR(32) COMMENT 'LLM提供商', | |
| 36 | + llm_model VARCHAR(64) COMMENT 'LLM模型名称', | |
| 37 | + generation_tokens INT DEFAULT 0 COMMENT '生成token数', | |
| 38 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 39 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 40 | + | |
| 41 | + INDEX idx_task_no (task_no), | |
| 42 | + INDEX idx_task_brand (task_no, brand_grouping_id), | |
| 43 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 44 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI分析报告表'; | ... | ... |
src/fw_pms_ai/__init__.py
0 → 100644
src/fw_pms_ai/agent/__init__.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/__init__.py | |
| 1 | +"""Agent 模块""" | |
| 2 | + | |
| 3 | +from .state import AgentState | |
| 4 | +from .replenishment import ReplenishmentAgent | |
| 5 | +from .sql_agent import SQLAgent | |
| 6 | +from ..models import SQLExecutionResult, ReplenishmentSuggestion, PartAnalysisResult | |
| 7 | + | |
| 8 | +__all__ = [ | |
| 9 | + "AgentState", | |
| 10 | + "ReplenishmentAgent", | |
| 11 | + "SQLAgent", | |
| 12 | + "SQLExecutionResult", | |
| 13 | + "ReplenishmentSuggestion", | |
| 14 | + "PartAnalysisResult", | |
| 15 | +] | |
| 16 | + | |
| 17 | + | ... | ... |
src/fw_pms_ai/agent/nodes.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/nodes.py | |
| 1 | +""" | |
| 2 | +LangGraph Agent 节点实现 | |
| 3 | + | |
| 4 | +重构版本:直接使用 part_ratio 数据 + SQL Agent | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +import time | |
| 9 | +import json | |
| 10 | +from typing import Dict, List | |
| 11 | +from decimal import Decimal | |
| 12 | +from datetime import datetime | |
| 13 | + | |
| 14 | +from langchain_core.messages import SystemMessage, HumanMessage | |
| 15 | + | |
| 16 | +from .state import AgentState | |
| 17 | +from .sql_agent import SQLAgent | |
| 18 | +from ..models import ReplenishmentSuggestion, PartAnalysisResult | |
| 19 | +from ..llm import get_llm_client | |
| 20 | +from ..services import DataService | |
| 21 | +from ..services.result_writer import ResultWriter | |
| 22 | +from ..models import ReplenishmentDetail, TaskExecutionLog, LogStatus, ReplenishmentPartSummary | |
| 23 | + | |
| 24 | +logger = logging.getLogger(__name__) | |
| 25 | + | |
| 26 | + | |
| 27 | +def _load_prompt(filename: str) -> str: | |
| 28 | + """从prompts目录加载提示词文件""" | |
| 29 | + import os | |
| 30 | + # 从 src/fw_pms_ai/agent/nodes.py 向上4层到达项目根目录 | |
| 31 | + prompt_path = os.path.join( | |
| 32 | + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), | |
| 33 | + "prompts", filename | |
| 34 | + ) | |
| 35 | + try: | |
| 36 | + with open(prompt_path, "r", encoding="utf-8") as f: | |
| 37 | + return f.read() | |
| 38 | + except FileNotFoundError: | |
| 39 | + logger.warning(f"Prompt文件未找到: {prompt_path}") | |
| 40 | + return "" | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | +def fetch_part_ratio_node(state: AgentState) -> AgentState: | |
| 45 | + """ | |
| 46 | + 节点1: 获取 part_ratio 数据 | |
| 47 | + 直接通过 dealer_grouping_id 从 part_ratio 表获取配件库销比数据 | |
| 48 | + """ | |
| 49 | + logger.info(f"[FetchPartRatio] ========== 开始获取数据 ==========") | |
| 50 | + logger.info( | |
| 51 | + f"[FetchPartRatio] group_id={state['group_id']}, " | |
| 52 | + f"dealer_grouping_id={state['dealer_grouping_id']}, " | |
| 53 | + f"date={state['statistics_date']}" | |
| 54 | + ) | |
| 55 | + | |
| 56 | + start_time = time.time() | |
| 57 | + | |
| 58 | + sql_agent = SQLAgent() | |
| 59 | + | |
| 60 | + try: | |
| 61 | + # 直接使用 dealer_grouping_id 获取 part_ratio 数据 | |
| 62 | + part_ratios = sql_agent.fetch_part_ratios( | |
| 63 | + group_id=state["group_id"], | |
| 64 | + dealer_grouping_id=state["dealer_grouping_id"], | |
| 65 | + statistics_date=state["statistics_date"], | |
| 66 | + ) | |
| 67 | + | |
| 68 | + execution_time = int((time.time() - start_time) * 1000) | |
| 69 | + | |
| 70 | + # 记录执行日志 | |
| 71 | + log_entry = { | |
| 72 | + "step_name": "fetch_part_ratio", | |
| 73 | + "step_order": 1, | |
| 74 | + "status": LogStatus.SUCCESS if part_ratios else LogStatus.SKIPPED, | |
| 75 | + "input_data": json.dumps({ | |
| 76 | + "dealer_grouping_id": state["dealer_grouping_id"], | |
| 77 | + "statistics_date": state["statistics_date"], | |
| 78 | + }), | |
| 79 | + "output_data": json.dumps({"part_ratios_count": len(part_ratios)}), | |
| 80 | + "execution_time_ms": execution_time, | |
| 81 | + "start_time": datetime.now().isoformat(), | |
| 82 | + } | |
| 83 | + | |
| 84 | + logger.info( | |
| 85 | + f"[FetchPartRatio] 数据获取完成: part_ratios={len(part_ratios)}, " | |
| 86 | + f"耗时={execution_time}ms" | |
| 87 | + ) | |
| 88 | + | |
| 89 | + return { | |
| 90 | + **state, | |
| 91 | + "part_ratios": part_ratios, | |
| 92 | + "sql_execution_logs": [log_entry], | |
| 93 | + "current_node": "fetch_part_ratio", | |
| 94 | + "next_node": "sql_agent", | |
| 95 | + } | |
| 96 | + | |
| 97 | + finally: | |
| 98 | + sql_agent.close() | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | +def sql_agent_node(state: AgentState) -> AgentState: | |
| 103 | + """ | |
| 104 | + 节点2: SQL Agent 分析和生成建议 | |
| 105 | + 按 part_code 分组,逐个配件分析各门店的补货需求 | |
| 106 | + """ | |
| 107 | + part_ratios = state.get("part_ratios", []) | |
| 108 | + | |
| 109 | + | |
| 110 | + logger.info(f"[SQLAgent] 开始分析: part_ratios={len(part_ratios)}") | |
| 111 | + | |
| 112 | + start_time = time.time() | |
| 113 | + retry_count = state.get("sql_retry_count", 0) | |
| 114 | + | |
| 115 | + if not part_ratios: | |
| 116 | + logger.warning("[SQLAgent] 无配件数据可分析") | |
| 117 | + | |
| 118 | + log_entry = { | |
| 119 | + "step_name": "sql_agent", | |
| 120 | + "step_order": 2, | |
| 121 | + "status": LogStatus.SKIPPED, | |
| 122 | + "error_message": "无配件数据", | |
| 123 | + "execution_time_ms": int((time.time() - start_time) * 1000), | |
| 124 | + } | |
| 125 | + | |
| 126 | + return { | |
| 127 | + **state, | |
| 128 | + "llm_suggestions": [], | |
| 129 | + "llm_analysis_summary": "无配件数据可分析", | |
| 130 | + "sql_execution_logs": [log_entry], | |
| 131 | + "current_node": "sql_agent", | |
| 132 | + "next_node": "allocate_budget", | |
| 133 | + } | |
| 134 | + | |
| 135 | + sql_agent = SQLAgent() | |
| 136 | + | |
| 137 | + try: | |
| 138 | + # 计算基准库销比(仅用于记录,不影响LLM建议) | |
| 139 | + total_valid_storage = sum( | |
| 140 | + Decimal(str(p.get("valid_storage_cnt", 0) or 0)) | |
| 141 | + for p in part_ratios | |
| 142 | + ) | |
| 143 | + total_avg_sales = sum( | |
| 144 | + Decimal(str(p.get("avg_sales_cnt", 0) or 0)) | |
| 145 | + for p in part_ratios | |
| 146 | + ) | |
| 147 | + | |
| 148 | + if total_avg_sales > 0: | |
| 149 | + base_ratio = total_valid_storage / total_avg_sales | |
| 150 | + else: | |
| 151 | + base_ratio = Decimal("0") | |
| 152 | + | |
| 153 | + logger.info( | |
| 154 | + f"[SQLAgent] 当前库销比: 总库存={total_valid_storage}, " | |
| 155 | + f"总销量={total_avg_sales}, 库销比={base_ratio}" | |
| 156 | + ) | |
| 157 | + | |
| 158 | + # 定义批处理回调 | |
| 159 | + # 由于 models 中没有 ResultWriter 的引用,这里尝试直接从 services 导入或实例化 | |
| 160 | + # 为避免循环导入,我们在函数内导入 | |
| 161 | + from ..services import ResultWriter as WriterService | |
| 162 | + writer = WriterService() | |
| 163 | + | |
| 164 | + # 1. 任务开始时清理旧数据(确保重试时不会产生重复数据) | |
| 165 | + # logger.info(f"[SQLAgent] 清理旧建议数据: task_no={state['task_no']}") | |
| 166 | + # writer.clear_llm_suggestions(state["task_no"]) | |
| 167 | + | |
| 168 | + # 2. 移除批处理回调(不再过程写入,改为最后统一写入) | |
| 169 | + save_batch_callback = None | |
| 170 | + | |
| 171 | + # 使用分组分析生成补货建议(按 part_code 分组,逐个配件分析各门店需求) | |
| 172 | + suggestions, part_results, llm_stats = sql_agent.analyze_parts_by_group( | |
| 173 | + part_ratios=part_ratios, | |
| 174 | + dealer_grouping_id=state["dealer_grouping_id"], | |
| 175 | + dealer_grouping_name=state["dealer_grouping_name"], | |
| 176 | + statistics_date=state["statistics_date"], | |
| 177 | + target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"), | |
| 178 | + limit=1, | |
| 179 | + callback=save_batch_callback, | |
| 180 | + ) | |
| 181 | + | |
| 182 | + execution_time = int((time.time() - start_time) * 1000) | |
| 183 | + | |
| 184 | + # 记录执行日志 | |
| 185 | + log_entry = { | |
| 186 | + "step_name": "sql_agent", | |
| 187 | + "step_order": 2, | |
| 188 | + "status": LogStatus.SUCCESS, | |
| 189 | + "input_data": json.dumps({ | |
| 190 | + "part_ratios_count": len(part_ratios), | |
| 191 | + }), | |
| 192 | + "output_data": json.dumps({ | |
| 193 | + "suggestions_count": len(suggestions), | |
| 194 | + "part_results_count": len(part_results), | |
| 195 | + "base_ratio": float(base_ratio), | |
| 196 | + }), | |
| 197 | + "llm_tokens": llm_stats.get("prompt_tokens", 0) + llm_stats.get("completion_tokens", 0), | |
| 198 | + "execution_time_ms": execution_time, | |
| 199 | + "retry_count": retry_count, | |
| 200 | + } | |
| 201 | + | |
| 202 | + logger.info( | |
| 203 | + f"[SQLAgent] 分析完成: 建议数={len(suggestions)}, " | |
| 204 | + f"配件汇总数={len(part_results)}, tokens={llm_stats}, 耗时={execution_time}ms" | |
| 205 | + ) | |
| 206 | + | |
| 207 | + return { | |
| 208 | + **state, | |
| 209 | + "base_ratio": base_ratio, | |
| 210 | + "llm_suggestions": suggestions, | |
| 211 | + "part_results": part_results, | |
| 212 | + "llm_prompt_tokens": state.get("llm_prompt_tokens", 0) + llm_stats.get("prompt_tokens", 0), | |
| 213 | + "llm_completion_tokens": state.get("llm_completion_tokens", 0) + llm_stats.get("completion_tokens", 0), | |
| 214 | + "sql_execution_logs": [log_entry], | |
| 215 | + "current_node": "sql_agent", | |
| 216 | + "next_node": "allocate_budget", | |
| 217 | + } | |
| 218 | + | |
| 219 | + except Exception as e: | |
| 220 | + logger.error(f"[SQLAgent] 执行失败: {e}") | |
| 221 | + | |
| 222 | + log_entry = { | |
| 223 | + "step_name": "sql_agent", | |
| 224 | + "step_order": 2, | |
| 225 | + "status": LogStatus.FAILED, | |
| 226 | + "error_message": str(e), | |
| 227 | + "retry_count": retry_count, | |
| 228 | + "execution_time_ms": int((time.time() - start_time) * 1000), | |
| 229 | + } | |
| 230 | + | |
| 231 | + # 检查是否需要重试 | |
| 232 | + if retry_count < 3: | |
| 233 | + return { | |
| 234 | + **state, | |
| 235 | + "sql_retry_count": retry_count + 1, | |
| 236 | + "sql_execution_logs": [log_entry], | |
| 237 | + "current_node": "sql_agent", | |
| 238 | + "next_node": "sql_agent", # 重试 | |
| 239 | + "error_message": str(e), | |
| 240 | + } | |
| 241 | + | |
| 242 | + return { | |
| 243 | + **state, | |
| 244 | + "llm_suggestions": [], | |
| 245 | + "sql_execution_logs": [log_entry], | |
| 246 | + "current_node": "sql_agent", | |
| 247 | + "next_node": "allocate_budget", | |
| 248 | + "error_message": str(e), | |
| 249 | + } | |
| 250 | + | |
| 251 | + finally: | |
| 252 | + sql_agent.close() | |
| 253 | + | |
| 254 | + | |
| 255 | +def allocate_budget_node(state: AgentState) -> AgentState: | |
| 256 | + """ | |
| 257 | + 节点3: 转换LLM建议为补货明细 | |
| 258 | + 注意:不做预算截断,所有建议直接输出 | |
| 259 | + """ | |
| 260 | + logger.info(f"[AllocateBudget] 开始处理LLM建议") | |
| 261 | + | |
| 262 | + start_time = time.time() | |
| 263 | + | |
| 264 | + llm_suggestions = state.get("llm_suggestions", []) | |
| 265 | + | |
| 266 | + if not llm_suggestions: | |
| 267 | + logger.warning("[AllocateBudget] 无LLM建议可处理") | |
| 268 | + | |
| 269 | + log_entry = { | |
| 270 | + "step_name": "allocate_budget", | |
| 271 | + "step_order": 3, | |
| 272 | + "status": LogStatus.SKIPPED, | |
| 273 | + "error_message": "无LLM建议", | |
| 274 | + "execution_time_ms": int((time.time() - start_time) * 1000), | |
| 275 | + } | |
| 276 | + | |
| 277 | + return { | |
| 278 | + **state, | |
| 279 | + "details": [], | |
| 280 | + "sql_execution_logs": [log_entry], | |
| 281 | + "current_node": "allocate_budget", | |
| 282 | + "next_node": "end", | |
| 283 | + } | |
| 284 | + | |
| 285 | + # 按优先级和库销比排序(优先级升序,库销比升序) | |
| 286 | + sorted_suggestions = sorted( | |
| 287 | + llm_suggestions, | |
| 288 | + key=lambda x: (x.priority, float(x.current_ratio)) | |
| 289 | + ) | |
| 290 | + | |
| 291 | + # 建立 part_code -> brand_grouping_id 映射,确保明细归属正确的品牌组合 | |
| 292 | + part_ratios = state.get("part_ratios", []) | |
| 293 | + part_brand_map = {p.get("part_code"): p.get("brand_grouping_id") for p in part_ratios if p.get("part_code")} | |
| 294 | + | |
| 295 | + allocated_details = [] | |
| 296 | + total_amount = Decimal("0") | |
| 297 | + | |
| 298 | + # 转换所有建议为明细(包括不需要补货的配件,以便记录完整分析结果) | |
| 299 | + for suggestion in sorted_suggestions: | |
| 300 | + # 获取该配件对应的 brand_grouping_id | |
| 301 | + bg_id = part_brand_map.get(suggestion.part_code) | |
| 302 | + if bg_id is None: | |
| 303 | + bg_id = state.get("brand_grouping_id") | |
| 304 | + | |
| 305 | + detail = ReplenishmentDetail( | |
| 306 | + task_no=state["task_no"], | |
| 307 | + group_id=state["group_id"], | |
| 308 | + dealer_grouping_id=state["dealer_grouping_id"], | |
| 309 | + brand_grouping_id=bg_id, | |
| 310 | + shop_id=suggestion.shop_id, | |
| 311 | + shop_name=suggestion.shop_name, | |
| 312 | + part_code=suggestion.part_code, | |
| 313 | + part_name=suggestion.part_name, | |
| 314 | + unit=suggestion.unit, | |
| 315 | + cost_price=suggestion.cost_price, | |
| 316 | + base_ratio=state.get("base_ratio", Decimal("1.1")), | |
| 317 | + current_ratio=suggestion.current_ratio, | |
| 318 | + valid_storage_cnt=suggestion.current_storage_cnt, | |
| 319 | + avg_sales_cnt=suggestion.avg_sales_cnt, | |
| 320 | + suggest_cnt=suggestion.suggest_cnt, | |
| 321 | + suggest_amount=suggestion.suggest_amount, | |
| 322 | + suggestion_reason=suggestion.suggestion_reason, | |
| 323 | + priority=suggestion.priority, | |
| 324 | + llm_confidence=suggestion.confidence, | |
| 325 | + statistics_date=state["statistics_date"], | |
| 326 | + ) | |
| 327 | + # 计算预计库销比 | |
| 328 | + post_storage = detail.valid_storage_cnt + detail.suggest_cnt | |
| 329 | + if post_storage <= 0 or detail.avg_sales_cnt <= 0: | |
| 330 | + # 库存为0或销量为0时,库销比设为0 | |
| 331 | + detail.post_plan_ratio = Decimal("0") | |
| 332 | + else: | |
| 333 | + detail.post_plan_ratio = post_storage / detail.avg_sales_cnt | |
| 334 | + | |
| 335 | + allocated_details.append(detail) | |
| 336 | + total_amount += suggestion.suggest_amount | |
| 337 | + | |
| 338 | + execution_time = int((time.time() - start_time) * 1000) | |
| 339 | + | |
| 340 | + # 记录执行日志 | |
| 341 | + log_entry = { | |
| 342 | + "step_name": "allocate_budget", | |
| 343 | + "step_order": 3, | |
| 344 | + "status": LogStatus.SUCCESS, | |
| 345 | + "input_data": json.dumps({ | |
| 346 | + "suggestions_count": len(llm_suggestions), | |
| 347 | + }), | |
| 348 | + "output_data": json.dumps({ | |
| 349 | + "details_count": len(allocated_details), | |
| 350 | + "total_amount": float(total_amount), | |
| 351 | + }), | |
| 352 | + "execution_time_ms": execution_time, | |
| 353 | + } | |
| 354 | + | |
| 355 | + logger.info( | |
| 356 | + f"[AllocateBudget] 分配完成: 配件数={len(allocated_details)}, " | |
| 357 | + f"金额={total_amount}" | |
| 358 | + ) | |
| 359 | + | |
| 360 | + # 保存结果到数据库 | |
| 361 | + try: | |
| 362 | + writer = ResultWriter() | |
| 363 | + | |
| 364 | + # 0. 先清理旧数据(防止重试或重复执行时产生重复记录) | |
| 365 | + writer.delete_details_by_task(state["task_no"]) | |
| 366 | + writer.delete_part_summaries_by_task(state["task_no"]) | |
| 367 | + logger.info(f"[AllocateBudget] 已清理旧数据: task_no={state['task_no']}") | |
| 368 | + | |
| 369 | + # 1. 保存补货明细 | |
| 370 | + if allocated_details: | |
| 371 | + writer.save_details(allocated_details) | |
| 372 | + logger.info(f"[AllocateBudget] 已保存 {len(allocated_details)} 条补货明细") | |
| 373 | + | |
| 374 | + # 2. 保存配件汇总 | |
| 375 | + part_results = state.get("part_results", []) | |
| 376 | + if part_results: | |
| 377 | + part_summaries = [] | |
| 378 | + for pr in part_results: | |
| 379 | + summary = ReplenishmentPartSummary( | |
| 380 | + task_no=state["task_no"], | |
| 381 | + group_id=state["group_id"], | |
| 382 | + dealer_grouping_id=state["dealer_grouping_id"], | |
| 383 | + part_code=pr.part_code, | |
| 384 | + part_name=pr.part_name, | |
| 385 | + unit=pr.unit, | |
| 386 | + cost_price=pr.cost_price, | |
| 387 | + total_storage_cnt=pr.total_storage_cnt, | |
| 388 | + total_avg_sales_cnt=pr.total_avg_sales_cnt, | |
| 389 | + group_current_ratio=pr.group_current_ratio, | |
| 390 | + total_suggest_cnt=pr.total_suggest_cnt, | |
| 391 | + total_suggest_amount=pr.total_suggest_amount, | |
| 392 | + shop_count=pr.shop_count, | |
| 393 | + need_replenishment_shop_count=pr.need_replenishment_shop_count, | |
| 394 | + part_decision_reason=pr.part_decision_reason, | |
| 395 | + priority=pr.priority, | |
| 396 | + llm_confidence=pr.confidence, | |
| 397 | + statistics_date=state["statistics_date"], | |
| 398 | + ) | |
| 399 | + part_summaries.append(summary) | |
| 400 | + | |
| 401 | + writer.save_part_summaries(part_summaries) | |
| 402 | + logger.info(f"[AllocateBudget] 已保存 {len(part_summaries)} 条配件分析汇总") | |
| 403 | + | |
| 404 | + writer.close() | |
| 405 | + except Exception as e: | |
| 406 | + logger.error(f"[AllocateBudget] 保存结果失败: {e}") | |
| 407 | + # 记录错误但不中断流程 | |
| 408 | + error_log = { | |
| 409 | + "step_name": "allocate_budget", | |
| 410 | + "step_order": 3, | |
| 411 | + "status": LogStatus.FAILED, | |
| 412 | + "error_message": f"保存结果失败: {str(e)}", | |
| 413 | + "execution_time_ms": 0, | |
| 414 | + } | |
| 415 | + | |
| 416 | + return { | |
| 417 | + **state, | |
| 418 | + "details": allocated_details, | |
| 419 | + "sql_execution_logs": [log_entry, error_log], | |
| 420 | + "current_node": "allocate_budget", | |
| 421 | + "next_node": "end", | |
| 422 | + "status": "success", | |
| 423 | + "end_time": time.time(), | |
| 424 | + } | |
| 425 | + | |
| 426 | + return { | |
| 427 | + **state, | |
| 428 | + "details": allocated_details, | |
| 429 | + "sql_execution_logs": [log_entry], | |
| 430 | + "current_node": "allocate_budget", | |
| 431 | + "next_node": "end", | |
| 432 | + "status": "success", | |
| 433 | + "end_time": time.time(), | |
| 434 | + } | |
| 435 | + | |
| 436 | + | |
| 437 | +def should_retry_sql(state: AgentState) -> str: | |
| 438 | + """条件边: 判断是否需要重试SQL Agent""" | |
| 439 | + next_node = state.get("next_node", "allocate_budget") | |
| 440 | + retry_count = state.get("sql_retry_count", 0) | |
| 441 | + | |
| 442 | + if next_node == "sql_agent" and retry_count < 3: | |
| 443 | + logger.info(f"[Routing] SQL Agent需要重试: retry_count={retry_count}") | |
| 444 | + return "retry" | |
| 445 | + | |
| 446 | + return "continue" | |
| 447 | + | |
| 448 | + | |
| 449 | +def should_continue(state: AgentState) -> str: | |
| 450 | + """条件边: 判断是否继续""" | |
| 451 | + return state.get("next_node", "end") | |
| 452 | + | ... | ... |
src/fw_pms_ai/agent/replenishment.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/replenishment.py | |
| 1 | +""" | |
| 2 | +补货建议 Agent | |
| 3 | + | |
| 4 | +重构版本:使用 part_ratio + SQL Agent + LangGraph | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +import time | |
| 9 | +import uuid | |
| 10 | +from typing import Optional, List | |
| 11 | +from datetime import date, datetime | |
| 12 | +from decimal import Decimal | |
| 13 | + | |
| 14 | +from langgraph.graph import StateGraph, END | |
| 15 | + | |
| 16 | +from .state import AgentState | |
| 17 | +from .nodes import ( | |
| 18 | + fetch_part_ratio_node, | |
| 19 | + sql_agent_node, | |
| 20 | + allocate_budget_node, | |
| 21 | + should_retry_sql, | |
| 22 | +) | |
| 23 | +from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary | |
| 24 | +from ..services import ResultWriter | |
| 25 | + | |
| 26 | +logger = logging.getLogger(__name__) | |
| 27 | + | |
| 28 | + | |
| 29 | +class ReplenishmentAgent: | |
| 30 | + """补货建议 Agent""" | |
| 31 | + | |
| 32 | + def __init__(self): | |
| 33 | + self._graph = None | |
| 34 | + self._result_writer = ResultWriter() | |
| 35 | + | |
| 36 | + @property | |
| 37 | + def graph(self) -> StateGraph: | |
| 38 | + """获取工作流图""" | |
| 39 | + if self._graph is None: | |
| 40 | + self._graph = self._build_graph() | |
| 41 | + return self._graph | |
| 42 | + | |
| 43 | + def _build_graph(self) -> StateGraph: | |
| 44 | + """ | |
| 45 | + 构建 LangGraph 工作流 | |
| 46 | + | |
| 47 | + 工作流结构: | |
| 48 | + fetch_part_ratio → sql_agent → allocate_budget → END | |
| 49 | + """ | |
| 50 | + workflow = StateGraph(AgentState) | |
| 51 | + | |
| 52 | + # 添加核心节点 | |
| 53 | + workflow.add_node("fetch_part_ratio", fetch_part_ratio_node) | |
| 54 | + workflow.add_node("sql_agent", sql_agent_node) | |
| 55 | + workflow.add_node("allocate_budget", allocate_budget_node) | |
| 56 | + | |
| 57 | + # 设置入口 | |
| 58 | + workflow.set_entry_point("fetch_part_ratio") | |
| 59 | + | |
| 60 | + # 添加边 | |
| 61 | + workflow.add_edge("fetch_part_ratio", "sql_agent") | |
| 62 | + | |
| 63 | + # SQL Agent 条件边(支持重试) | |
| 64 | + workflow.add_conditional_edges( | |
| 65 | + "sql_agent", | |
| 66 | + should_retry_sql, | |
| 67 | + { | |
| 68 | + "retry": "sql_agent", | |
| 69 | + "continue": "allocate_budget", | |
| 70 | + } | |
| 71 | + ) | |
| 72 | + | |
| 73 | + # allocate_budget → END | |
| 74 | + workflow.add_edge("allocate_budget", END) | |
| 75 | + | |
| 76 | + return workflow.compile() | |
| 77 | + | |
| 78 | + def run( | |
| 79 | + self, | |
| 80 | + group_id: int, | |
| 81 | + dealer_grouping_id: int, | |
| 82 | + dealer_grouping_name: str, | |
| 83 | + brand_grouping_id: Optional[int] = None, | |
| 84 | + brand_grouping_name: str = "", | |
| 85 | + statistics_date: Optional[str] = None, | |
| 86 | + ) -> AgentState: | |
| 87 | + """ | |
| 88 | + 执行补货建议生成 | |
| 89 | + | |
| 90 | + Args: | |
| 91 | + group_id: 集团ID | |
| 92 | + dealer_grouping_id: 商家组合ID | |
| 93 | + dealer_grouping_name: 商家组合名称 | |
| 94 | + brand_grouping_id: 品牌组合ID | |
| 95 | + brand_grouping_name: 品牌组合名称 | |
| 96 | + statistics_date: 统计日期 | |
| 97 | + """ | |
| 98 | + task_no = f"AI-{uuid.uuid4().hex[:12].upper()}" | |
| 99 | + if statistics_date is None: | |
| 100 | + statistics_date = date.today().strftime("%Y-%m-%d") | |
| 101 | + | |
| 102 | + logger.info( | |
| 103 | + f"开始执行补货建议: task_no={task_no}, " | |
| 104 | + f"dealer_grouping={dealer_grouping_name}" | |
| 105 | + ) | |
| 106 | + | |
| 107 | + # 初始化状态 | |
| 108 | + initial_state: AgentState = { | |
| 109 | + "task_no": task_no, | |
| 110 | + "group_id": group_id, | |
| 111 | + "brand_grouping_id": brand_grouping_id, | |
| 112 | + "brand_grouping_name": brand_grouping_name, | |
| 113 | + "dealer_grouping_id": dealer_grouping_id, | |
| 114 | + "dealer_grouping_name": dealer_grouping_name, | |
| 115 | + "statistics_date": statistics_date, | |
| 116 | + "part_ratios": [], | |
| 117 | + "sql_queries": [], | |
| 118 | + "sql_results": [], | |
| 119 | + "sql_retry_count": 0, | |
| 120 | + "sql_execution_logs": [], | |
| 121 | + "base_ratio": Decimal("1.1"), | |
| 122 | + "allocated_details": [], | |
| 123 | + "details": [], | |
| 124 | + "llm_suggestions": [], | |
| 125 | + "part_results": [], | |
| 126 | + "report": None, | |
| 127 | + "llm_provider": "", | |
| 128 | + "llm_model": "", | |
| 129 | + "llm_prompt_tokens": 0, | |
| 130 | + "llm_completion_tokens": 0, | |
| 131 | + | |
| 132 | + "status": "running", | |
| 133 | + "error_message": "", | |
| 134 | + "start_time": time.time(), | |
| 135 | + "end_time": 0, | |
| 136 | + "current_node": "", | |
| 137 | + "next_node": "fetch_part_ratio", | |
| 138 | + } | |
| 139 | + | |
| 140 | + # 创建任务记录 | |
| 141 | + task = ReplenishmentTask( | |
| 142 | + task_no=task_no, | |
| 143 | + group_id=group_id, | |
| 144 | + dealer_grouping_id=dealer_grouping_id, | |
| 145 | + dealer_grouping_name=dealer_grouping_name, | |
| 146 | + brand_grouping_id=brand_grouping_id, | |
| 147 | + statistics_date=statistics_date, | |
| 148 | + status=TaskStatus.RUNNING, | |
| 149 | + ) | |
| 150 | + self._result_writer.save_task(task) | |
| 151 | + | |
| 152 | + try: | |
| 153 | + # 执行工作流 | |
| 154 | + final_state = self.graph.invoke(initial_state) | |
| 155 | + | |
| 156 | + # 更新任务状态 | |
| 157 | + execution_time = int((final_state.get("end_time", time.time()) - final_state["start_time"]) * 1000) | |
| 158 | + actual_amount = sum(d.suggest_amount for d in final_state.get("details", [])) | |
| 159 | + | |
| 160 | + task.status = TaskStatus.SUCCESS | |
| 161 | + task.actual_amount = actual_amount | |
| 162 | + task.part_count = len(final_state.get("details", [])) | |
| 163 | + task.shop_count = len(set(d.shop_id for d in final_state.get("details", []))) | |
| 164 | + task.base_ratio = final_state.get("base_ratio", Decimal("0")) | |
| 165 | + task.llm_provider = final_state.get("llm_provider", "") | |
| 166 | + task.llm_model = final_state.get("llm_model", "") | |
| 167 | + task.llm_prompt_tokens = final_state.get("llm_prompt_tokens", 0) | |
| 168 | + task.llm_completion_tokens = final_state.get("llm_completion_tokens", 0) | |
| 169 | + task.llm_total_tokens = task.llm_prompt_tokens + task.llm_completion_tokens | |
| 170 | + task.llm_analysis_summary = final_state.get("llm_analysis_summary", "") | |
| 171 | + task.execution_time_ms = execution_time | |
| 172 | + | |
| 173 | + self._result_writer.update_task(task) | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + # 保存执行日志 | |
| 178 | + if final_state.get("sql_execution_logs"): | |
| 179 | + self._save_execution_logs( | |
| 180 | + task_no=task_no, | |
| 181 | + group_id=group_id, | |
| 182 | + brand_grouping_id=brand_grouping_id, | |
| 183 | + brand_grouping_name=brand_grouping_name, | |
| 184 | + dealer_grouping_id=dealer_grouping_id, | |
| 185 | + dealer_grouping_name=dealer_grouping_name, | |
| 186 | + logs=final_state["sql_execution_logs"], | |
| 187 | + ) | |
| 188 | + | |
| 189 | + # 配件汇总已在 allocate_budget_node 中保存,此处跳过避免重复 | |
| 190 | + # if final_state.get("part_results"): | |
| 191 | + # self._save_part_summaries( | |
| 192 | + # task_no=task_no, | |
| 193 | + # group_id=group_id, | |
| 194 | + # dealer_grouping_id=dealer_grouping_id, | |
| 195 | + # statistics_date=statistics_date, | |
| 196 | + # part_results=final_state["part_results"], | |
| 197 | + # ) | |
| 198 | + | |
| 199 | + logger.info( | |
| 200 | + f"补货建议执行完成: task_no={task_no}, " | |
| 201 | + f"parts={task.part_count}, amount={actual_amount}, " | |
| 202 | + f"time={execution_time}ms" | |
| 203 | + ) | |
| 204 | + | |
| 205 | + return final_state | |
| 206 | + | |
| 207 | + except Exception as e: | |
| 208 | + logger.error(f"补货建议执行失败: task_no={task_no}, error={e}") | |
| 209 | + | |
| 210 | + task.status = TaskStatus.FAILED | |
| 211 | + task.error_message = str(e) | |
| 212 | + task.execution_time_ms = int((time.time() - initial_state["start_time"]) * 1000) | |
| 213 | + self._result_writer.update_task(task) | |
| 214 | + | |
| 215 | + raise | |
| 216 | + | |
| 217 | + finally: | |
| 218 | + self._result_writer.close() | |
| 219 | + | |
| 220 | + def _save_execution_logs( | |
| 221 | + self, | |
| 222 | + task_no: str, | |
| 223 | + group_id: int, | |
| 224 | + brand_grouping_id: Optional[int], | |
| 225 | + brand_grouping_name: str, | |
| 226 | + dealer_grouping_id: int, | |
| 227 | + dealer_grouping_name: str, | |
| 228 | + logs: List[dict], | |
| 229 | + ): | |
| 230 | + """保存执行日志""" | |
| 231 | + for log_data in logs: | |
| 232 | + log = TaskExecutionLog( | |
| 233 | + task_no=task_no, | |
| 234 | + group_id=group_id, | |
| 235 | + brand_grouping_id=brand_grouping_id, | |
| 236 | + brand_grouping_name=brand_grouping_name, | |
| 237 | + dealer_grouping_id=dealer_grouping_id, | |
| 238 | + dealer_grouping_name=dealer_grouping_name, | |
| 239 | + step_name=log_data.get("step_name", ""), | |
| 240 | + step_order=log_data.get("step_order", 0), | |
| 241 | + status=log_data.get("status", LogStatus.SUCCESS), | |
| 242 | + input_data=log_data.get("input_data", ""), | |
| 243 | + output_data=log_data.get("output_data", ""), | |
| 244 | + error_message=log_data.get("error_message", ""), | |
| 245 | + retry_count=log_data.get("retry_count", 0), | |
| 246 | + sql_query=log_data.get("sql_query", ""), | |
| 247 | + llm_prompt=log_data.get("llm_prompt", ""), | |
| 248 | + llm_response=log_data.get("llm_response", ""), | |
| 249 | + llm_tokens=log_data.get("llm_tokens", 0), | |
| 250 | + execution_time_ms=log_data.get("execution_time_ms", 0), | |
| 251 | + ) | |
| 252 | + self._result_writer.save_execution_log(log) | |
| 253 | + | |
| 254 | + def _save_part_summaries( | |
| 255 | + self, | |
| 256 | + task_no: str, | |
| 257 | + group_id: int, | |
| 258 | + dealer_grouping_id: int, | |
| 259 | + statistics_date: str, | |
| 260 | + part_results: list, | |
| 261 | + ): | |
| 262 | + """保存配件汇总""" | |
| 263 | + from .sql_agent import PartAnalysisResult | |
| 264 | + | |
| 265 | + summaries = [] | |
| 266 | + for pr in part_results: | |
| 267 | + if not isinstance(pr, PartAnalysisResult): | |
| 268 | + continue | |
| 269 | + summary = ReplenishmentPartSummary( | |
| 270 | + task_no=task_no, | |
| 271 | + group_id=group_id, | |
| 272 | + dealer_grouping_id=dealer_grouping_id, | |
| 273 | + part_code=pr.part_code, | |
| 274 | + part_name=pr.part_name, | |
| 275 | + unit=pr.unit, | |
| 276 | + cost_price=pr.cost_price, | |
| 277 | + total_storage_cnt=pr.total_storage_cnt, | |
| 278 | + total_avg_sales_cnt=pr.total_avg_sales_cnt, | |
| 279 | + group_current_ratio=pr.group_current_ratio, | |
| 280 | + total_suggest_cnt=pr.total_suggest_cnt, | |
| 281 | + total_suggest_amount=pr.total_suggest_amount, | |
| 282 | + shop_count=pr.shop_count, | |
| 283 | + need_replenishment_shop_count=pr.need_replenishment_shop_count, | |
| 284 | + part_decision_reason=pr.part_decision_reason, | |
| 285 | + priority=pr.priority, | |
| 286 | + llm_confidence=pr.confidence, | |
| 287 | + statistics_date=statistics_date, | |
| 288 | + ) | |
| 289 | + summaries.append(summary) | |
| 290 | + | |
| 291 | + if summaries: | |
| 292 | + self._result_writer.save_part_summaries(summaries) | |
| 293 | + logger.info(f"保存配件汇总: count={len(summaries)}") | |
| 294 | + | |
| 295 | + def run_for_all_groupings(self, group_id: int): | |
| 296 | + """ | |
| 297 | + 为所有商家组合执行补货建议 | |
| 298 | + """ | |
| 299 | + from ..services import DataService | |
| 300 | + | |
| 301 | + data_service = DataService() | |
| 302 | + try: | |
| 303 | + groupings = data_service.get_dealer_groupings(group_id) | |
| 304 | + logger.info(f"获取商家组合: group_id={group_id}, count={len(groupings)}") | |
| 305 | + | |
| 306 | + for idx, grouping in enumerate(groupings): | |
| 307 | + logger.info(f"[{idx+1}/{len(groupings)}] 开始处理商家组合: {grouping['name']} (id={grouping['id']})") | |
| 308 | + try: | |
| 309 | + self.run( | |
| 310 | + group_id=group_id, | |
| 311 | + dealer_grouping_id=grouping["id"], | |
| 312 | + dealer_grouping_name=grouping["name"], | |
| 313 | + ) | |
| 314 | + logger.info(f"[{grouping['name']}] 执行完成") | |
| 315 | + | |
| 316 | + except Exception as e: | |
| 317 | + logger.error(f"商家组合执行失败: {grouping['name']}, error={e}", exc_info=True) | |
| 318 | + continue | |
| 319 | + | |
| 320 | + finally: | |
| 321 | + data_service.close() | ... | ... |
src/fw_pms_ai/agent/sql_agent/__init__.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/sql_agent/__init__.py | |
| 1 | +""" | |
| 2 | +SQL Agent 子包 | |
| 3 | + | |
| 4 | +提供 SQL 执行和配件分析功能 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from .agent import SQLAgent | |
| 8 | +from .executor import SQLExecutor | |
| 9 | +from .analyzer import PartAnalyzer | |
| 10 | +from .prompts import load_prompt | |
| 11 | + | |
| 12 | +__all__ = [ | |
| 13 | + "SQLAgent", | |
| 14 | + "SQLExecutor", | |
| 15 | + "PartAnalyzer", | |
| 16 | + "load_prompt", | |
| 17 | +] | ... | ... |
src/fw_pms_ai/agent/sql_agent/agent.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/sql_agent/agent.py | |
| 1 | +""" | |
| 2 | +SQL Agent 主类模块 | |
| 3 | + | |
| 4 | +组合 Executor 和 Analyzer 提供完整的 SQL Agent 功能 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +from typing import Any, Dict, List, Optional, Tuple | |
| 9 | +from decimal import Decimal | |
| 10 | + | |
| 11 | +from .executor import SQLExecutor | |
| 12 | +from .analyzer import PartAnalyzer | |
| 13 | +from .prompts import load_prompt | |
| 14 | +from ...models import SQLExecutionResult, ReplenishmentSuggestion, PartAnalysisResult | |
| 15 | + | |
| 16 | +logger = logging.getLogger(__name__) | |
| 17 | + | |
| 18 | + | |
| 19 | +class SQLAgent: | |
| 20 | + """SQL Agent - 组合 SQL 执行和配件分析功能""" | |
| 21 | + | |
| 22 | + def __init__(self, db_connection=None): | |
| 23 | + self._executor = SQLExecutor(db_connection) | |
| 24 | + self._analyzer = PartAnalyzer() | |
| 25 | + | |
| 26 | + def close(self): | |
| 27 | + """关闭连接""" | |
| 28 | + self._executor.close() | |
| 29 | + | |
| 30 | + # === 委托给 SQLExecutor 的方法 === | |
| 31 | + | |
| 32 | + def generate_sql( | |
| 33 | + self, | |
| 34 | + question: str, | |
| 35 | + context: Optional[Dict] = None, | |
| 36 | + previous_error: Optional[str] = None, | |
| 37 | + ) -> Tuple[str, str]: | |
| 38 | + """使用LLM生成SQL""" | |
| 39 | + return self._executor.generate_sql(question, context, previous_error) | |
| 40 | + | |
| 41 | + def execute_sql(self, sql: str) -> Tuple[bool, Any, Optional[str]]: | |
| 42 | + """执行SQL查询""" | |
| 43 | + return self._executor.execute_sql(sql) | |
| 44 | + | |
| 45 | + def query_with_retry( | |
| 46 | + self, | |
| 47 | + question: str, | |
| 48 | + context: Optional[Dict] = None, | |
| 49 | + ) -> SQLExecutionResult: | |
| 50 | + """带重试的查询""" | |
| 51 | + return self._executor.query_with_retry(question, context) | |
| 52 | + | |
| 53 | + # === 委托给 PartAnalyzer 的方法 === | |
| 54 | + | |
| 55 | + def group_parts_by_code(self, part_ratios: List[Dict]) -> Dict[str, List[Dict]]: | |
| 56 | + """按配件编码分组""" | |
| 57 | + return self._analyzer.group_parts_by_code(part_ratios) | |
| 58 | + | |
| 59 | + def generate_suggestions( | |
| 60 | + self, | |
| 61 | + part_data: List[Dict], | |
| 62 | + dealer_grouping_id: int, | |
| 63 | + dealer_grouping_name: str, | |
| 64 | + statistics_date: str, | |
| 65 | + ) -> Tuple[List[ReplenishmentSuggestion], Dict]: | |
| 66 | + """生成补货建议""" | |
| 67 | + return self._analyzer.generate_suggestions( | |
| 68 | + part_data, dealer_grouping_id, dealer_grouping_name, statistics_date | |
| 69 | + ) | |
| 70 | + | |
| 71 | + def analyze_parts_by_group( | |
| 72 | + self, | |
| 73 | + part_ratios: List[Dict], | |
| 74 | + dealer_grouping_id: int, | |
| 75 | + dealer_grouping_name: str, | |
| 76 | + statistics_date: str, | |
| 77 | + target_ratio: Decimal = Decimal("1.3"), | |
| 78 | + limit: Optional[int] = None, | |
| 79 | + callback: Optional[Any] = None, | |
| 80 | + ) -> Tuple[List[ReplenishmentSuggestion], List[PartAnalysisResult], Dict]: | |
| 81 | + """按配件分组分析补货建议""" | |
| 82 | + return self._analyzer.analyze_parts_by_group( | |
| 83 | + part_ratios, | |
| 84 | + dealer_grouping_id, | |
| 85 | + dealer_grouping_name, | |
| 86 | + statistics_date, | |
| 87 | + target_ratio, | |
| 88 | + limit, | |
| 89 | + callback, | |
| 90 | + ) | |
| 91 | + | |
| 92 | + # === 数据查询方法 === | |
| 93 | + | |
| 94 | + def fetch_part_ratios( | |
| 95 | + self, | |
| 96 | + group_id: int, | |
| 97 | + dealer_grouping_id: int, | |
| 98 | + statistics_date: str, | |
| 99 | + ) -> List[Dict]: | |
| 100 | + """ | |
| 101 | + 查询part_ratio数据 | |
| 102 | + | |
| 103 | + Args: | |
| 104 | + group_id: 集团ID | |
| 105 | + dealer_grouping_id: 商家组合ID | |
| 106 | + statistics_date: 统计日期 | |
| 107 | + Returns: | |
| 108 | + 配件库销比数据列表 | |
| 109 | + """ | |
| 110 | + conn = self._executor._get_connection() | |
| 111 | + cursor = conn.cursor(dictionary=True) | |
| 112 | + | |
| 113 | + try: | |
| 114 | + # 1. 查询商家组合关联的品牌组合配置 | |
| 115 | + brand_grouping_ids = [] | |
| 116 | + try: | |
| 117 | + cursor.execute( | |
| 118 | + "SELECT part_purchase_brand_assemble_id FROM artificial_region_dealer WHERE id = %s", | |
| 119 | + (dealer_grouping_id,) | |
| 120 | + ) | |
| 121 | + rows = cursor.fetchall() | |
| 122 | + for row in rows: | |
| 123 | + bid = row.get("part_purchase_brand_assemble_id") | |
| 124 | + if bid: | |
| 125 | + brand_grouping_ids.append(bid) | |
| 126 | + if brand_grouping_ids: | |
| 127 | + logger.info(f"商家组合关联品牌组合: dealer_grouping_id={dealer_grouping_id} -> brand_grouping_ids={brand_grouping_ids}") | |
| 128 | + except Exception as e: | |
| 129 | + logger.warning(f"查询商家组合配置失败: {e}") | |
| 130 | + | |
| 131 | + sql = """ | |
| 132 | + SELECT | |
| 133 | + id, group_id, brand_id, brand_grouping_id, | |
| 134 | + dealer_grouping_id, | |
| 135 | + supplier_id, supplier_name, area_id, area_name, | |
| 136 | + shop_id, shop_name, part_id, part_code, part_name, | |
| 137 | + unit, cost_price, | |
| 138 | + in_stock_unlocked_cnt, has_plan_cnt, on_the_way_cnt, | |
| 139 | + out_stock_cnt, buy_cnt, storage_locked_cnt, | |
| 140 | + out_stock_ongoing_cnt, stock_age, out_times, out_duration, | |
| 141 | + transfer_cnt, gen_transfer_cnt, | |
| 142 | + part_biz_type, statistics_date, | |
| 143 | + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) as valid_storage_cnt, | |
| 144 | + ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt | |
| 145 | + FROM part_ratio | |
| 146 | + WHERE group_id = %s | |
| 147 | + AND dealer_grouping_id = %s | |
| 148 | + AND statistics_date = %s | |
| 149 | + AND part_biz_type = 1 | |
| 150 | + """ | |
| 151 | + params = [group_id, dealer_grouping_id, statistics_date] | |
| 152 | + | |
| 153 | + # 如果有配置的品牌组合,用 IN 过滤 | |
| 154 | + if brand_grouping_ids: | |
| 155 | + placeholders = ", ".join(["%s"] * len(brand_grouping_ids)) | |
| 156 | + sql += f" AND brand_grouping_id IN ({placeholders})" | |
| 157 | + params.extend(brand_grouping_ids) | |
| 158 | + | |
| 159 | + # 优先处理有销量的配件 | |
| 160 | + sql += """ ORDER BY | |
| 161 | + CASE WHEN ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) > 0 THEN 0 ELSE 1 END, | |
| 162 | + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) / NULLIF((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3, 0) ASC, | |
| 163 | + ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) DESC | |
| 164 | + """ | |
| 165 | + | |
| 166 | + cursor.execute(sql, params) | |
| 167 | + rows = cursor.fetchall() | |
| 168 | + | |
| 169 | + logger.info(f"获取part_ratio数据: dealer_grouping_id={dealer_grouping_id}, count={len(rows)}") | |
| 170 | + return rows | |
| 171 | + | |
| 172 | + finally: | |
| 173 | + cursor.close() | ... | ... |
src/fw_pms_ai/agent/sql_agent/analyzer.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/sql_agent/analyzer.py | |
| 1 | +""" | |
| 2 | +配件分析器模块 | |
| 3 | + | |
| 4 | +负责配件分组分析、LLM 调用和结果解析 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +import time | |
| 9 | +import json | |
| 10 | +import concurrent.futures | |
| 11 | +from typing import Any, Dict, List, Optional, Tuple | |
| 12 | +from decimal import Decimal | |
| 13 | + | |
| 14 | +from langchain_core.messages import SystemMessage, HumanMessage | |
| 15 | + | |
| 16 | +from .prompts import ( | |
| 17 | + load_prompt, | |
| 18 | + SUGGESTION_PROMPT, | |
| 19 | + SUGGESTION_SYSTEM_PROMPT, | |
| 20 | + PART_SHOP_ANALYSIS_PROMPT, | |
| 21 | + PART_SHOP_ANALYSIS_SYSTEM_PROMPT, | |
| 22 | +) | |
| 23 | +from ...llm import get_llm_client | |
| 24 | +from ...models import ReplenishmentSuggestion, PartAnalysisResult | |
| 25 | + | |
| 26 | +logger = logging.getLogger(__name__) | |
| 27 | + | |
| 28 | + | |
| 29 | +class PartAnalyzer: | |
| 30 | + """配件分析器 - 负责 LLM 分析和结果解析""" | |
| 31 | + | |
| 32 | + def __init__(self): | |
| 33 | + self._llm = get_llm_client() | |
| 34 | + | |
| 35 | + def group_parts_by_code(self, part_ratios: List[Dict]) -> Dict[str, List[Dict]]: | |
| 36 | + """ | |
| 37 | + 按配件编码分组 | |
| 38 | + | |
| 39 | + Args: | |
| 40 | + part_ratios: 配件库销比数据列表 | |
| 41 | + | |
| 42 | + Returns: | |
| 43 | + {part_code: [各门店数据列表]} | |
| 44 | + """ | |
| 45 | + grouped = {} | |
| 46 | + for pr in part_ratios: | |
| 47 | + part_code = pr.get("part_code", "") | |
| 48 | + if not part_code: | |
| 49 | + continue | |
| 50 | + if part_code not in grouped: | |
| 51 | + grouped[part_code] = [] | |
| 52 | + grouped[part_code].append(pr) | |
| 53 | + | |
| 54 | + logger.info(f"配件分组完成: 总配件数={len(grouped)}, 总记录数={len(part_ratios)}") | |
| 55 | + return grouped | |
| 56 | + | |
| 57 | + def generate_suggestions( | |
| 58 | + self, | |
| 59 | + part_data: List[Dict], | |
| 60 | + dealer_grouping_id: int, | |
| 61 | + dealer_grouping_name: str, | |
| 62 | + statistics_date: str, | |
| 63 | + ) -> Tuple[List[ReplenishmentSuggestion], Dict]: | |
| 64 | + """ | |
| 65 | + 生成补货建议 | |
| 66 | + | |
| 67 | + Args: | |
| 68 | + part_data: 配件数据 | |
| 69 | + dealer_grouping_id: 商家组合ID | |
| 70 | + dealer_grouping_name: 商家组合名称 | |
| 71 | + statistics_date: 统计日期 | |
| 72 | + | |
| 73 | + Returns: | |
| 74 | + (补货建议列表, LLM统计信息) | |
| 75 | + """ | |
| 76 | + if not part_data: | |
| 77 | + return [], {"prompt_tokens": 0, "completion_tokens": 0} | |
| 78 | + | |
| 79 | + # 将所有数据传给LLM分析 | |
| 80 | + part_data_str = json.dumps(part_data, ensure_ascii=False, indent=2, default=str) | |
| 81 | + | |
| 82 | + prompt = SUGGESTION_PROMPT.format( | |
| 83 | + dealer_grouping_id=dealer_grouping_id, | |
| 84 | + dealer_grouping_name=dealer_grouping_name, | |
| 85 | + statistics_date=statistics_date, | |
| 86 | + part_data=part_data_str, | |
| 87 | + ) | |
| 88 | + | |
| 89 | + messages = [ | |
| 90 | + SystemMessage(content=SUGGESTION_SYSTEM_PROMPT), | |
| 91 | + HumanMessage(content=prompt), | |
| 92 | + ] | |
| 93 | + | |
| 94 | + response = self._llm.invoke(messages) | |
| 95 | + content = response.content.strip() | |
| 96 | + | |
| 97 | + suggestions = [] | |
| 98 | + try: | |
| 99 | + # 提取JSON | |
| 100 | + if "```json" in content: | |
| 101 | + content = content.split("```json")[1].split("```")[0].strip() | |
| 102 | + elif "```" in content: | |
| 103 | + content = content.split("```")[1].split("```")[0].strip() | |
| 104 | + | |
| 105 | + raw_suggestions = json.loads(content) | |
| 106 | + | |
| 107 | + for item in raw_suggestions: | |
| 108 | + suggestions.append(ReplenishmentSuggestion( | |
| 109 | + shop_id=item.get("shop_id", 0), | |
| 110 | + shop_name=item.get("shop_name", ""), | |
| 111 | + part_code=item.get("part_code", ""), | |
| 112 | + part_name=item.get("part_name", ""), | |
| 113 | + unit=item.get("unit", ""), | |
| 114 | + cost_price=Decimal(str(item.get("cost_price", 0))), | |
| 115 | + current_storage_cnt=Decimal(str(item.get("current_storage_cnt", 0))), | |
| 116 | + avg_sales_cnt=Decimal(str(item.get("avg_sales_cnt", 0))), | |
| 117 | + current_ratio=Decimal(str(item.get("current_ratio", 0))), | |
| 118 | + suggest_cnt=int(item.get("suggest_cnt", 0)), | |
| 119 | + suggest_amount=Decimal(str(item.get("suggest_amount", 0))), | |
| 120 | + suggestion_reason=item.get("suggestion_reason", ""), | |
| 121 | + priority=int(item.get("priority", 2)), | |
| 122 | + confidence=float(item.get("confidence", 0.8)), | |
| 123 | + )) | |
| 124 | + | |
| 125 | + except json.JSONDecodeError as e: | |
| 126 | + logger.error(f"解析LLM建议失败: {e}") | |
| 127 | + | |
| 128 | + llm_stats = { | |
| 129 | + "prompt_tokens": response.usage.prompt_tokens if response.usage else 0, | |
| 130 | + "completion_tokens": response.usage.completion_tokens if response.usage else 0, | |
| 131 | + } | |
| 132 | + | |
| 133 | + logger.info(f"生成补货建议: {len(suggestions)}条") | |
| 134 | + return suggestions, llm_stats | |
| 135 | + | |
| 136 | + def analyze_parts_by_group( | |
| 137 | + self, | |
| 138 | + part_ratios: List[Dict], | |
| 139 | + dealer_grouping_id: int, | |
| 140 | + dealer_grouping_name: str, | |
| 141 | + statistics_date: str, | |
| 142 | + target_ratio: Decimal = Decimal("1.3"), | |
| 143 | + limit: Optional[int] = None, | |
| 144 | + callback: Optional[Any] = None, | |
| 145 | + ) -> Tuple[List[ReplenishmentSuggestion], List[PartAnalysisResult], Dict]: | |
| 146 | + """ | |
| 147 | + 按配件分组分析补货建议 | |
| 148 | + | |
| 149 | + Args: | |
| 150 | + part_ratios: 配件库销比数据列表 | |
| 151 | + dealer_grouping_id: 商家组合ID | |
| 152 | + dealer_grouping_name: 商家组合名称 | |
| 153 | + statistics_date: 统计日期 | |
| 154 | + target_ratio: 目标库销比(基准库销比) | |
| 155 | + limit: 测试限制数量 | |
| 156 | + callback: 批处理回调函数(suggestions) | |
| 157 | + | |
| 158 | + Returns: | |
| 159 | + (补货建议列表, 配件分析结果列表, LLM统计信息) | |
| 160 | + """ | |
| 161 | + if not part_ratios: | |
| 162 | + return [], [], {"prompt_tokens": 0, "completion_tokens": 0} | |
| 163 | + | |
| 164 | + # 按 part_code 分组 | |
| 165 | + grouped_parts = self.group_parts_by_code(part_ratios) | |
| 166 | + | |
| 167 | + # 应用限制 | |
| 168 | + all_part_codes = list(grouped_parts.keys()) | |
| 169 | + if limit and limit > 0: | |
| 170 | + logger.warning(f"启用测试限制: 仅处理前 {limit} 个配件 (总数: {len(all_part_codes)})") | |
| 171 | + all_part_codes = all_part_codes[:limit] | |
| 172 | + | |
| 173 | + all_suggestions = [] | |
| 174 | + all_part_results: List[PartAnalysisResult] = [] | |
| 175 | + total_prompt_tokens = 0 | |
| 176 | + total_completion_tokens = 0 | |
| 177 | + | |
| 178 | + system_prompt = PART_SHOP_ANALYSIS_SYSTEM_PROMPT | |
| 179 | + user_prompt_template = PART_SHOP_ANALYSIS_PROMPT | |
| 180 | + | |
| 181 | + # 将目标库销比格式化到 Prompt 中 | |
| 182 | + target_ratio_str = f"{float(target_ratio):.2f}" | |
| 183 | + system_prompt = system_prompt.replace("{target_ratio}", target_ratio_str) | |
| 184 | + | |
| 185 | + def process_single_part(part_code: str) -> Tuple[PartAnalysisResult, List[ReplenishmentSuggestion], int, int]: | |
| 186 | + """处理单个配件""" | |
| 187 | + shop_data_list = grouped_parts[part_code] | |
| 188 | + if not shop_data_list: | |
| 189 | + return None, [], 0, 0 | |
| 190 | + | |
| 191 | + # 获取配件基本信息 | |
| 192 | + first_item = shop_data_list[0] | |
| 193 | + part_name = first_item.get("part_name", "") | |
| 194 | + cost_price = first_item.get("cost_price", 0) | |
| 195 | + unit = first_item.get("unit", "") | |
| 196 | + | |
| 197 | + # 构建门店数据 | |
| 198 | + shop_data_str = json.dumps(shop_data_list, ensure_ascii=False, indent=2, default=str) | |
| 199 | + | |
| 200 | + prompt = user_prompt_template.format( | |
| 201 | + part_code=part_code, | |
| 202 | + part_name=part_name, | |
| 203 | + cost_price=cost_price, | |
| 204 | + unit=unit, | |
| 205 | + dealer_grouping_name=dealer_grouping_name, | |
| 206 | + statistics_date=statistics_date, | |
| 207 | + shop_data=shop_data_str, | |
| 208 | + target_ratio=target_ratio_str, | |
| 209 | + ) | |
| 210 | + | |
| 211 | + messages = [ | |
| 212 | + SystemMessage(content=system_prompt), | |
| 213 | + HumanMessage(content=prompt), | |
| 214 | + ] | |
| 215 | + | |
| 216 | + p_tokens = 0 | |
| 217 | + c_tokens = 0 | |
| 218 | + | |
| 219 | + try: | |
| 220 | + response = self._llm.invoke(messages) | |
| 221 | + content = response.content.strip() | |
| 222 | + | |
| 223 | + if response.usage: | |
| 224 | + p_tokens = response.usage.prompt_tokens | |
| 225 | + c_tokens = response.usage.completion_tokens | |
| 226 | + | |
| 227 | + # 解析 LLM 响应 | |
| 228 | + part_result, suggestions = self._parse_part_analysis_response( | |
| 229 | + content, part_code, part_name, unit, cost_price, shop_data_list, target_ratio | |
| 230 | + ) | |
| 231 | + | |
| 232 | + # 请求间延迟,避免触发速率限制 | |
| 233 | + time.sleep(0.5) | |
| 234 | + | |
| 235 | + return part_result, suggestions, p_tokens, c_tokens | |
| 236 | + | |
| 237 | + except Exception as e: | |
| 238 | + logger.error(f"分析配件 {part_code} 失败: {e}") | |
| 239 | + # 失败后等待更长时间再继续 | |
| 240 | + time.sleep(2.0) | |
| 241 | + return None, [], 0, 0 | |
| 242 | + | |
| 243 | + # 并发执行 | |
| 244 | + batch_size = 10 | |
| 245 | + current_batch = [] | |
| 246 | + finished_count = 0 | |
| 247 | + total_count = len(all_part_codes) | |
| 248 | + # 最大并发数150,但不超过配件数量 | |
| 249 | + max_workers = min(150, total_count) if total_count > 0 else 1 | |
| 250 | + | |
| 251 | + logger.info(f"开始并行分析: workers={max_workers}, parts={total_count}") | |
| 252 | + | |
| 253 | + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: | |
| 254 | + # 提交所有任务 | |
| 255 | + future_to_part = { | |
| 256 | + executor.submit(process_single_part, code): code | |
| 257 | + for code in all_part_codes | |
| 258 | + } | |
| 259 | + | |
| 260 | + for future in concurrent.futures.as_completed(future_to_part): | |
| 261 | + part_code = future_to_part[future] | |
| 262 | + finished_count += 1 | |
| 263 | + | |
| 264 | + try: | |
| 265 | + part_result, suggestions, p_t, c_t = future.result() | |
| 266 | + | |
| 267 | + if part_result: | |
| 268 | + all_part_results.append(part_result) | |
| 269 | + if suggestions: | |
| 270 | + all_suggestions.extend(suggestions) | |
| 271 | + current_batch.extend(suggestions) | |
| 272 | + | |
| 273 | + total_prompt_tokens += p_t | |
| 274 | + total_completion_tokens += c_t | |
| 275 | + | |
| 276 | + # 批量回调处理 | |
| 277 | + if callback and len(current_batch) >= batch_size: | |
| 278 | + try: | |
| 279 | + callback(current_batch) | |
| 280 | + logger.info(f"批次落库: {len(current_batch)} 条") | |
| 281 | + current_batch = [] | |
| 282 | + except Exception as e: | |
| 283 | + logger.error(f"回调执行失败: {e}") | |
| 284 | + | |
| 285 | + except Exception as e: | |
| 286 | + logger.error(f"任务执行异常 {part_code}: {e}") | |
| 287 | + | |
| 288 | + if finished_count % 10 == 0: | |
| 289 | + logger.info(f"进度: {finished_count}/{total_count} ({(finished_count/total_count*100):.1f}%)") | |
| 290 | + | |
| 291 | + # 处理剩余批次 | |
| 292 | + if callback and current_batch: | |
| 293 | + try: | |
| 294 | + callback(current_batch) | |
| 295 | + logger.info(f"最后批次落库: {len(current_batch)} 条") | |
| 296 | + except Exception as e: | |
| 297 | + logger.error(f"最后回调执行失败: {e}") | |
| 298 | + | |
| 299 | + llm_stats = { | |
| 300 | + "prompt_tokens": total_prompt_tokens, | |
| 301 | + "completion_tokens": total_completion_tokens, | |
| 302 | + } | |
| 303 | + | |
| 304 | + logger.info( | |
| 305 | + f"分组分析完成: 配件数={len(grouped_parts)}, " | |
| 306 | + f"配件汇总数={len(all_part_results)}, " | |
| 307 | + f"建议数={len(all_suggestions)}, tokens={total_prompt_tokens + total_completion_tokens}" | |
| 308 | + ) | |
| 309 | + return all_suggestions, all_part_results, llm_stats | |
| 310 | + | |
| 311 | + def _calculate_priority_by_ratio( | |
| 312 | + self, | |
| 313 | + current_ratio: Decimal, | |
| 314 | + avg_sales: Decimal, | |
| 315 | + target_ratio: Decimal, | |
| 316 | + ) -> int: | |
| 317 | + """ | |
| 318 | + 根据库销比计算优先级 | |
| 319 | + | |
| 320 | + 规则: | |
| 321 | + - 库销比 < 0.5 且月均销量 >= 1: 高优先级 (1) | |
| 322 | + - 库销比 0.5-1.0 且月均销量 >= 1: 中优先级 (2) | |
| 323 | + - 库销比 1.0-target_ratio 且月均销量 >= 1: 低优先级 (3) | |
| 324 | + - 其他情况: 无需补货 (0) | |
| 325 | + | |
| 326 | + Args: | |
| 327 | + current_ratio: 当前库销比 | |
| 328 | + avg_sales: 月均销量 | |
| 329 | + target_ratio: 目标库销比 | |
| 330 | + | |
| 331 | + Returns: | |
| 332 | + 优先级 (0=无需补货, 1=高, 2=中, 3=低) | |
| 333 | + """ | |
| 334 | + if avg_sales < 1: | |
| 335 | + return 0 | |
| 336 | + | |
| 337 | + if current_ratio < Decimal("0.5"): | |
| 338 | + return 1 | |
| 339 | + elif current_ratio < Decimal("1.0"): | |
| 340 | + return 2 | |
| 341 | + elif current_ratio < target_ratio: | |
| 342 | + return 3 | |
| 343 | + else: | |
| 344 | + return 0 | |
| 345 | + | |
| 346 | + def _parse_part_analysis_response( | |
| 347 | + self, | |
| 348 | + content: str, | |
| 349 | + part_code: str, | |
| 350 | + part_name: str, | |
| 351 | + unit: str, | |
| 352 | + cost_price: float, | |
| 353 | + shop_data_list: List[Dict], | |
| 354 | + target_ratio: Decimal = Decimal("1.3"), | |
| 355 | + ) -> Tuple[PartAnalysisResult, List[ReplenishmentSuggestion]]: | |
| 356 | + """ | |
| 357 | + 解析单配件分析响应 | |
| 358 | + | |
| 359 | + Args: | |
| 360 | + content: LLM 响应内容 | |
| 361 | + part_code: 配件编码 | |
| 362 | + part_name: 配件名称 | |
| 363 | + unit: 单位 | |
| 364 | + cost_price: 成本价 | |
| 365 | + shop_data_list: 门店数据列表 | |
| 366 | + | |
| 367 | + Returns: | |
| 368 | + (配件分析结果, 补货建议列表) | |
| 369 | + """ | |
| 370 | + suggestions = [] | |
| 371 | + | |
| 372 | + # 计算默认配件汇总数据 | |
| 373 | + total_storage = sum(Decimal(str(s.get("valid_storage_cnt", 0))) for s in shop_data_list) | |
| 374 | + total_avg_sales = sum(Decimal(str(s.get("avg_sales_cnt", 0))) for s in shop_data_list) | |
| 375 | + group_ratio = total_storage / total_avg_sales if total_avg_sales > 0 else Decimal("0") | |
| 376 | + | |
| 377 | + part_result = PartAnalysisResult( | |
| 378 | + part_code=part_code, | |
| 379 | + part_name=part_name, | |
| 380 | + unit=unit, | |
| 381 | + cost_price=Decimal(str(cost_price)), | |
| 382 | + total_storage_cnt=total_storage, | |
| 383 | + total_avg_sales_cnt=total_avg_sales, | |
| 384 | + group_current_ratio=group_ratio, | |
| 385 | + need_replenishment=False, | |
| 386 | + total_suggest_cnt=0, | |
| 387 | + total_suggest_amount=Decimal("0"), | |
| 388 | + shop_count=len(shop_data_list), | |
| 389 | + need_replenishment_shop_count=0, | |
| 390 | + part_decision_reason="", | |
| 391 | + priority=2, | |
| 392 | + confidence=0.8, | |
| 393 | + suggestions=[], | |
| 394 | + ) | |
| 395 | + | |
| 396 | + try: | |
| 397 | + # 提取 JSON | |
| 398 | + if "```json" in content: | |
| 399 | + content = content.split("```json")[1].split("```")[0].strip() | |
| 400 | + elif "```" in content: | |
| 401 | + content = content.split("```")[1].split("```")[0].strip() | |
| 402 | + | |
| 403 | + result = json.loads(content) | |
| 404 | + | |
| 405 | + # 获取配件级汇总信息 | |
| 406 | + confidence = float(result.get("confidence", 0.8)) | |
| 407 | + part_decision_reason = result.get("part_decision_reason", "") | |
| 408 | + need_replenishment = result.get("need_replenishment", False) | |
| 409 | + priority = int(result.get("priority", 2)) | |
| 410 | + | |
| 411 | + # 更新配件结果 | |
| 412 | + part_result.need_replenishment = need_replenishment | |
| 413 | + part_result.total_suggest_cnt = int(result.get("total_suggest_cnt", 0)) | |
| 414 | + part_result.total_suggest_amount = Decimal(str(result.get("total_suggest_amount", 0))) | |
| 415 | + part_result.shop_count = int(result.get("shop_count", len(shop_data_list))) | |
| 416 | + part_result.part_decision_reason = part_decision_reason | |
| 417 | + part_result.priority = priority | |
| 418 | + part_result.confidence = confidence | |
| 419 | + | |
| 420 | + # 如果LLM返回了商家组合级数据,使用LLM的数据 | |
| 421 | + if "total_storage_cnt" in result: | |
| 422 | + part_result.total_storage_cnt = Decimal(str(result["total_storage_cnt"])) | |
| 423 | + if "total_avg_sales_cnt" in result: | |
| 424 | + part_result.total_avg_sales_cnt = Decimal(str(result["total_avg_sales_cnt"])) | |
| 425 | + if "group_current_ratio" in result: | |
| 426 | + part_result.group_current_ratio = Decimal(str(result["group_current_ratio"])) | |
| 427 | + | |
| 428 | + # 构建建议字典以便快速查找 | |
| 429 | + shop_suggestion_map = {} | |
| 430 | + shop_suggestions_data = result.get("shop_suggestions", []) | |
| 431 | + if shop_suggestions_data: | |
| 432 | + for shop in shop_suggestions_data: | |
| 433 | + s_id = int(shop.get("shop_id", 0)) | |
| 434 | + shop_suggestion_map[s_id] = shop | |
| 435 | + | |
| 436 | + # 统计需要补货的门店数 | |
| 437 | + need_replenishment_shop_count = len([s for s in shop_suggestions_data if int(s.get("suggest_cnt", 0)) > 0]) | |
| 438 | + part_result.need_replenishment_shop_count = need_replenishment_shop_count | |
| 439 | + | |
| 440 | + # 递归所有输入门店,确保每个门店都有记录 | |
| 441 | + for shop_data in shop_data_list: | |
| 442 | + shop_id = int(shop_data.get("shop_id", 0)) | |
| 443 | + shop_name = shop_data.get("shop_name", "") | |
| 444 | + | |
| 445 | + # 检查LLM是否有针对该门店的建议 | |
| 446 | + if shop_id in shop_suggestion_map: | |
| 447 | + s_item = shop_suggestion_map[shop_id] | |
| 448 | + suggest_cnt = int(s_item.get("suggest_cnt", 0)) | |
| 449 | + suggest_amount = Decimal(str(s_item.get("suggest_amount", 0))) | |
| 450 | + reason = s_item.get("reason", part_decision_reason) | |
| 451 | + shop_priority = int(s_item.get("priority", priority)) | |
| 452 | + else: | |
| 453 | + # LLM未提及该门店,根据门店数据生成个性化默认理由 | |
| 454 | + suggest_cnt = 0 | |
| 455 | + suggest_amount = Decimal("0") | |
| 456 | + | |
| 457 | + # 计算该门店的库存和销售数据 | |
| 458 | + _storage = Decimal(str(shop_data.get("valid_storage_cnt", 0))) | |
| 459 | + _avg_sales = Decimal(str(shop_data.get("avg_sales_cnt", 0))) | |
| 460 | + _out_times = shop_data.get("out_times", 0) or 0 | |
| 461 | + _out_duration = shop_data.get("out_duration", 0) or 0 | |
| 462 | + _ratio = _storage / _avg_sales if _avg_sales > 0 else Decimal("0") | |
| 463 | + | |
| 464 | + # 根据库销比规则计算 priority | |
| 465 | + shop_priority = self._calculate_priority_by_ratio(_ratio, _avg_sales, target_ratio) | |
| 466 | + | |
| 467 | + if _storage > 0 and _avg_sales <= 0: | |
| 468 | + reason = f"「呆滞件」当前库存{_storage}件,但90天内无销售记录,库存滞销风险高,暂不补货。" | |
| 469 | + shop_priority = 0 | |
| 470 | + elif _storage <= 0 and _avg_sales < 1: | |
| 471 | + reason = f"「低频件-需求不足」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,需求过低,暂不纳入补货计划。" | |
| 472 | + shop_priority = 0 | |
| 473 | + elif _out_times < 3: | |
| 474 | + reason = f"「低频件-出库次数不足」90天内仅出库{_out_times}次(阈值≥3次),周转频率过低,暂不纳入补货计划。" | |
| 475 | + shop_priority = 0 | |
| 476 | + elif _out_duration >= 30: | |
| 477 | + reason = f"「低频件-出库间隔过长」平均出库间隔{_out_duration}天(阈值<30天),周转周期过长,暂不纳入补货计划。" | |
| 478 | + shop_priority = 0 | |
| 479 | + elif _avg_sales > 0 and _ratio >= target_ratio: | |
| 480 | + _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0 | |
| 481 | + reason = f"「库存充足」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,无需补货。" | |
| 482 | + shop_priority = 0 | |
| 483 | + elif shop_priority == 1: | |
| 484 | + _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0 | |
| 485 | + reason = f"「急需补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},仅可支撑约{_days}天销售,存在缺货风险。" | |
| 486 | + elif shop_priority == 2: | |
| 487 | + _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0 | |
| 488 | + reason = f"「建议补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,库存偏低建议补货。" | |
| 489 | + elif shop_priority == 3: | |
| 490 | + _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0 | |
| 491 | + reason = f"「可选补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,可根据资金情况酌情补货。" | |
| 492 | + else: | |
| 493 | + reason = f"「无需补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,AI分析判定暂不补货。" | |
| 494 | + | |
| 495 | + curr_storage = Decimal(str(shop_data.get("valid_storage_cnt", 0))) | |
| 496 | + avg_sales = Decimal(str(shop_data.get("avg_sales_cnt", 0))) | |
| 497 | + if avg_sales > 0: | |
| 498 | + current_ratio = curr_storage / avg_sales | |
| 499 | + else: | |
| 500 | + current_ratio = Decimal("0") | |
| 501 | + | |
| 502 | + suggestion = ReplenishmentSuggestion( | |
| 503 | + shop_id=shop_id, | |
| 504 | + shop_name=shop_name, | |
| 505 | + part_code=part_code, | |
| 506 | + part_name=part_name, | |
| 507 | + unit=unit, | |
| 508 | + cost_price=Decimal(str(cost_price)), | |
| 509 | + current_storage_cnt=curr_storage, | |
| 510 | + avg_sales_cnt=avg_sales, | |
| 511 | + current_ratio=current_ratio, | |
| 512 | + suggest_cnt=suggest_cnt, | |
| 513 | + suggest_amount=suggest_amount, | |
| 514 | + suggestion_reason=reason, | |
| 515 | + priority=shop_priority, | |
| 516 | + confidence=confidence, | |
| 517 | + ) | |
| 518 | + suggestions.append(suggestion) | |
| 519 | + | |
| 520 | + except json.JSONDecodeError as e: | |
| 521 | + logger.error(f"解析配件 {part_code} 分析结果失败: {e}") | |
| 522 | + part_result.part_decision_reason = f"分析失败: {str(e)}" | |
| 523 | + for shop_data in shop_data_list: | |
| 524 | + suggestions.append(ReplenishmentSuggestion( | |
| 525 | + shop_id=int(shop_data.get("shop_id", 0)), | |
| 526 | + shop_name=shop_data.get("shop_name", ""), | |
| 527 | + part_code=part_code, | |
| 528 | + part_name=part_name, | |
| 529 | + unit=unit, | |
| 530 | + cost_price=Decimal(str(cost_price)), | |
| 531 | + current_storage_cnt=Decimal(str(shop_data.get("valid_storage_cnt", 0))), | |
| 532 | + avg_sales_cnt=Decimal(str(shop_data.get("avg_sales_cnt", 0))), | |
| 533 | + current_ratio=Decimal("0"), | |
| 534 | + suggest_cnt=0, | |
| 535 | + suggest_amount=Decimal("0"), | |
| 536 | + suggestion_reason=f"分析失败: {str(e)}", | |
| 537 | + priority=3, | |
| 538 | + confidence=0.0, | |
| 539 | + )) | |
| 540 | + | |
| 541 | + except Exception as e: | |
| 542 | + logger.error(f"处理配件 {part_code} 分析结果异常: {e}") | |
| 543 | + | |
| 544 | + part_result.suggestions = suggestions | |
| 545 | + return part_result, suggestions | ... | ... |
src/fw_pms_ai/agent/sql_agent/executor.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/sql_agent/executor.py | |
| 1 | +""" | |
| 2 | +SQL 执行器模块 | |
| 3 | + | |
| 4 | +提供 SQL 执行、重试和错误分类功能 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +import time | |
| 9 | +import json | |
| 10 | +from typing import Any, Dict, List, Optional, Tuple | |
| 11 | + | |
| 12 | +from ..sql_agent.prompts import load_prompt, SQL_AGENT_SYSTEM_PROMPT | |
| 13 | +from ...llm import get_llm_client | |
| 14 | +from ...config import get_settings | |
| 15 | +from ...models import SQLExecutionResult | |
| 16 | + | |
| 17 | +from langchain_core.messages import SystemMessage, HumanMessage | |
| 18 | + | |
| 19 | +logger = logging.getLogger(__name__) | |
| 20 | + | |
| 21 | + | |
| 22 | +class SQLExecutor: | |
| 23 | + """SQL 执行器 - 负责 SQL 生成、执行和重试""" | |
| 24 | + | |
| 25 | + def __init__(self, db_connection=None): | |
| 26 | + self._settings = get_settings() | |
| 27 | + self._conn = db_connection | |
| 28 | + self._llm = get_llm_client() | |
| 29 | + self._max_retries = 3 | |
| 30 | + self._base_delay = 1.0 | |
| 31 | + | |
| 32 | + def _get_connection(self): | |
| 33 | + """获取数据库连接""" | |
| 34 | + if self._conn is None: | |
| 35 | + from ...services.db import get_connection | |
| 36 | + self._conn = get_connection() | |
| 37 | + return self._conn | |
| 38 | + | |
| 39 | + def close(self): | |
| 40 | + """关闭连接""" | |
| 41 | + if self._conn and hasattr(self._conn, 'close'): | |
| 42 | + self._conn.close() | |
| 43 | + self._conn = None | |
| 44 | + | |
| 45 | + def generate_sql( | |
| 46 | + self, | |
| 47 | + question: str, | |
| 48 | + context: Optional[Dict] = None, | |
| 49 | + previous_error: Optional[str] = None, | |
| 50 | + ) -> Tuple[str, str]: | |
| 51 | + """ | |
| 52 | + 使用LLM生成SQL | |
| 53 | + | |
| 54 | + Args: | |
| 55 | + question: 自然语言查询需求 | |
| 56 | + context: 上下文信息 | |
| 57 | + previous_error: 上一次执行的错误(用于重试) | |
| 58 | + | |
| 59 | + Returns: | |
| 60 | + (SQL语句, 解释说明) | |
| 61 | + """ | |
| 62 | + user_prompt = question | |
| 63 | + | |
| 64 | + if context: | |
| 65 | + user_prompt += f"\n\n上下文信息:\n{json.dumps(context, ensure_ascii=False, indent=2)}" | |
| 66 | + | |
| 67 | + if previous_error: | |
| 68 | + user_prompt += f"\n\n上一次执行错误:\n{previous_error}\n请修正SQL语句。" | |
| 69 | + | |
| 70 | + messages = [ | |
| 71 | + SystemMessage(content=SQL_AGENT_SYSTEM_PROMPT), | |
| 72 | + HumanMessage(content=user_prompt), | |
| 73 | + ] | |
| 74 | + | |
| 75 | + response = self._llm.invoke(messages) | |
| 76 | + content = response.content.strip() | |
| 77 | + | |
| 78 | + try: | |
| 79 | + # 提取JSON | |
| 80 | + if "```json" in content: | |
| 81 | + content = content.split("```json")[1].split("```")[0].strip() | |
| 82 | + elif "```" in content: | |
| 83 | + content = content.split("```")[1].split("```")[0].strip() | |
| 84 | + | |
| 85 | + result = json.loads(content) | |
| 86 | + return result.get("sql", ""), result.get("explanation", "") | |
| 87 | + except json.JSONDecodeError: | |
| 88 | + logger.warning(f"无法解析LLM响应为JSON: {content[:200]}") | |
| 89 | + # 尝试直接提取SQL | |
| 90 | + if "SELECT" in content.upper(): | |
| 91 | + lines = content.split("\n") | |
| 92 | + for line in lines: | |
| 93 | + if "SELECT" in line.upper(): | |
| 94 | + return line.strip(), "直接提取的SQL" | |
| 95 | + return "", "解析失败" | |
| 96 | + | |
| 97 | + def execute_sql(self, sql: str) -> Tuple[bool, Any, Optional[str]]: | |
| 98 | + """ | |
| 99 | + 执行SQL查询 | |
| 100 | + | |
| 101 | + Returns: | |
| 102 | + (成功标志, 数据/None, 错误信息/None) | |
| 103 | + """ | |
| 104 | + if not sql or not sql.strip(): | |
| 105 | + return False, None, "SQL语句为空" | |
| 106 | + | |
| 107 | + # 安全检查:只允许SELECT语句 | |
| 108 | + sql_upper = sql.upper().strip() | |
| 109 | + if not sql_upper.startswith("SELECT"): | |
| 110 | + return False, None, "只允许执行SELECT查询" | |
| 111 | + | |
| 112 | + conn = self._get_connection() | |
| 113 | + cursor = conn.cursor(dictionary=True) | |
| 114 | + | |
| 115 | + try: | |
| 116 | + start_time = time.time() | |
| 117 | + cursor.execute(sql) | |
| 118 | + rows = cursor.fetchall() | |
| 119 | + execution_time = int((time.time() - start_time) * 1000) | |
| 120 | + | |
| 121 | + logger.info(f"SQL执行成功: 返回{len(rows)}行, 耗时{execution_time}ms") | |
| 122 | + return True, rows, None | |
| 123 | + | |
| 124 | + except Exception as e: | |
| 125 | + error_msg = str(e) | |
| 126 | + logger.error(f"SQL执行失败: {error_msg}") | |
| 127 | + return False, None, error_msg | |
| 128 | + | |
| 129 | + finally: | |
| 130 | + cursor.close() | |
| 131 | + | |
| 132 | + def query_with_retry( | |
| 133 | + self, | |
| 134 | + question: str, | |
| 135 | + context: Optional[Dict] = None, | |
| 136 | + ) -> SQLExecutionResult: | |
| 137 | + """ | |
| 138 | + 带重试的查询 | |
| 139 | + | |
| 140 | + 错误类型区分: | |
| 141 | + - SyntaxError: 立即重试,让LLM修正SQL | |
| 142 | + - OperationalError: 指数退避重试(连接超时、死锁等) | |
| 143 | + | |
| 144 | + Args: | |
| 145 | + question: 查询问题 | |
| 146 | + context: 上下文 | |
| 147 | + | |
| 148 | + Returns: | |
| 149 | + SQLExecutionResult | |
| 150 | + """ | |
| 151 | + start_time = time.time() | |
| 152 | + last_error = None | |
| 153 | + last_sql = "" | |
| 154 | + | |
| 155 | + for attempt in range(self._max_retries): | |
| 156 | + # 生成SQL | |
| 157 | + sql, explanation = self.generate_sql(question, context, last_error) | |
| 158 | + last_sql = sql | |
| 159 | + | |
| 160 | + if not sql: | |
| 161 | + last_error = "LLM未能生成有效的SQL语句" | |
| 162 | + logger.warning(f"重试 {attempt + 1}/{self._max_retries}: {last_error}") | |
| 163 | + | |
| 164 | + if attempt < self._max_retries - 1: | |
| 165 | + delay = self._base_delay * (2 ** attempt) | |
| 166 | + time.sleep(delay) | |
| 167 | + continue | |
| 168 | + | |
| 169 | + logger.info(f"尝试 {attempt + 1}: 执行SQL: {sql[:100]}...") | |
| 170 | + | |
| 171 | + # 执行SQL | |
| 172 | + success, data, error, error_type = self._execute_sql_with_type(sql) | |
| 173 | + | |
| 174 | + if success: | |
| 175 | + execution_time = int((time.time() - start_time) * 1000) | |
| 176 | + return SQLExecutionResult( | |
| 177 | + success=True, | |
| 178 | + sql=sql, | |
| 179 | + data=data, | |
| 180 | + retry_count=attempt, | |
| 181 | + execution_time_ms=execution_time, | |
| 182 | + ) | |
| 183 | + | |
| 184 | + last_error = error | |
| 185 | + logger.warning(f"重试 {attempt + 1}/{self._max_retries}: [{error_type}] {error}") | |
| 186 | + | |
| 187 | + if attempt < self._max_retries - 1: | |
| 188 | + if error_type == "syntax": | |
| 189 | + # 语法错误:立即重试,不等待 | |
| 190 | + logger.info("SQL语法错误,立即重试让LLM修正") | |
| 191 | + else: | |
| 192 | + # 连接/死锁等操作错误:指数退避 | |
| 193 | + delay = self._base_delay * (2 ** attempt) | |
| 194 | + logger.info(f"操作错误,等待 {delay}秒 后重试...") | |
| 195 | + time.sleep(delay) | |
| 196 | + | |
| 197 | + execution_time = int((time.time() - start_time) * 1000) | |
| 198 | + return SQLExecutionResult( | |
| 199 | + success=False, | |
| 200 | + sql=last_sql, | |
| 201 | + error=last_error, | |
| 202 | + retry_count=self._max_retries, | |
| 203 | + execution_time_ms=execution_time, | |
| 204 | + ) | |
| 205 | + | |
| 206 | + def _execute_sql_with_type(self, sql: str) -> Tuple[bool, Any, Optional[str], str]: | |
| 207 | + """ | |
| 208 | + 执行SQL并返回错误类型 | |
| 209 | + | |
| 210 | + Returns: | |
| 211 | + (成功标志, 数据/None, 错误信息/None, 错误类型) | |
| 212 | + 错误类型: "syntax" | "operational" | "unknown" | |
| 213 | + """ | |
| 214 | + if not sql or not sql.strip(): | |
| 215 | + return False, None, "SQL语句为空", "unknown" | |
| 216 | + | |
| 217 | + # 安全检查:只允许SELECT语句 | |
| 218 | + sql_upper = sql.upper().strip() | |
| 219 | + if not sql_upper.startswith("SELECT"): | |
| 220 | + return False, None, "只允许执行SELECT查询", "syntax" | |
| 221 | + | |
| 222 | + conn = self._get_connection() | |
| 223 | + cursor = conn.cursor(dictionary=True) | |
| 224 | + | |
| 225 | + try: | |
| 226 | + start_time = time.time() | |
| 227 | + cursor.execute(sql) | |
| 228 | + rows = cursor.fetchall() | |
| 229 | + execution_time = int((time.time() - start_time) * 1000) | |
| 230 | + | |
| 231 | + logger.info(f"SQL执行成功: 返回{len(rows)}行, 耗时{execution_time}ms") | |
| 232 | + return True, rows, None, "" | |
| 233 | + | |
| 234 | + except Exception as e: | |
| 235 | + error_msg = str(e) | |
| 236 | + error_type = self._classify_error(e) | |
| 237 | + logger.error(f"SQL执行失败 [{error_type}]: {error_msg}") | |
| 238 | + return False, None, error_msg, error_type | |
| 239 | + | |
| 240 | + finally: | |
| 241 | + cursor.close() | |
| 242 | + | |
| 243 | + def _classify_error(self, error: Exception) -> str: | |
| 244 | + """ | |
| 245 | + 分类SQL错误类型 | |
| 246 | + | |
| 247 | + Returns: | |
| 248 | + "syntax" - 语法错误,需要LLM修正 | |
| 249 | + "operational" - 操作错误,需要指数退避 | |
| 250 | + "unknown" - 未知错误 | |
| 251 | + """ | |
| 252 | + error_msg = str(error).lower() | |
| 253 | + error_class = type(error).__name__ | |
| 254 | + | |
| 255 | + # MySQL语法错误特征 | |
| 256 | + syntax_keywords = [ | |
| 257 | + "syntax error", "you have an error in your sql syntax", | |
| 258 | + "unknown column", "unknown table", "doesn't exist", | |
| 259 | + "ambiguous column", "invalid", "near", | |
| 260 | + ] | |
| 261 | + | |
| 262 | + # 操作错误特征(需要退避) | |
| 263 | + operational_keywords = [ | |
| 264 | + "timeout", "timed out", "connection", "deadlock", | |
| 265 | + "lock wait", "too many connections", "gone away", | |
| 266 | + "lost connection", "can't connect", | |
| 267 | + ] | |
| 268 | + | |
| 269 | + for keyword in syntax_keywords: | |
| 270 | + if keyword in error_msg: | |
| 271 | + return "syntax" | |
| 272 | + | |
| 273 | + for keyword in operational_keywords: | |
| 274 | + if keyword in error_msg: | |
| 275 | + return "operational" | |
| 276 | + | |
| 277 | + # 根据异常类型判断 | |
| 278 | + if "ProgrammingError" in error_class or "InterfaceError" in error_class: | |
| 279 | + return "syntax" | |
| 280 | + if "OperationalError" in error_class: | |
| 281 | + return "operational" | |
| 282 | + | |
| 283 | + return "unknown" | ... | ... |
src/fw_pms_ai/agent/sql_agent/prompts.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/sql_agent/prompts.py | |
| 1 | +""" | |
| 2 | +提示词加载模块 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import os | |
| 6 | +import logging | |
| 7 | + | |
| 8 | +logger = logging.getLogger(__name__) | |
| 9 | + | |
| 10 | + | |
| 11 | +def load_prompt(filename: str) -> str: | |
| 12 | + """从prompts目录加载提示词文件""" | |
| 13 | + # 从 src/fw_pms_ai/agent/sql_agent/prompts.py 向上5层到达项目根目录 | |
| 14 | + prompt_path = os.path.join( | |
| 15 | + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))), | |
| 16 | + "prompts", filename | |
| 17 | + ) | |
| 18 | + try: | |
| 19 | + with open(prompt_path, "r", encoding="utf-8") as f: | |
| 20 | + return f.read() | |
| 21 | + except FileNotFoundError: | |
| 22 | + logger.warning(f"Prompt文件未找到: {prompt_path}") | |
| 23 | + return "" | |
| 24 | + | |
| 25 | + | |
| 26 | +# 预加载常用提示词 | |
| 27 | +SQL_AGENT_SYSTEM_PROMPT = load_prompt("sql_agent.md") | |
| 28 | +SUGGESTION_PROMPT = load_prompt("suggestion.md") | |
| 29 | +SUGGESTION_SYSTEM_PROMPT = load_prompt("suggestion_system.md") | |
| 30 | +PART_SHOP_ANALYSIS_PROMPT = load_prompt("part_shop_analysis.md") | |
| 31 | +PART_SHOP_ANALYSIS_SYSTEM_PROMPT = load_prompt("part_shop_analysis_system.md") | ... | ... |
src/fw_pms_ai/agent/state.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/agent/state.py | |
| 1 | +""" | |
| 2 | +LangGraph Agent 状态定义 | |
| 3 | + | |
| 4 | +重构版本:直接使用 part_ratio 数据,支持 SQL Agent | |
| 5 | +使用 Annotated 类型和 reducer 函数解决并发状态更新问题 | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from typing import TypedDict, Optional, List, Any, Annotated | |
| 9 | +from decimal import Decimal | |
| 10 | +import operator | |
| 11 | + | |
| 12 | + | |
| 13 | +def merge_lists(left: List[Any], right: List[Any]) -> List[Any]: | |
| 14 | + """合并两个列表的 reducer 函数""" | |
| 15 | + if left is None: | |
| 16 | + left = [] | |
| 17 | + if right is None: | |
| 18 | + right = [] | |
| 19 | + return left + right | |
| 20 | + | |
| 21 | + | |
| 22 | +def merge_dicts(left: List[dict], right: List[dict]) -> List[dict]: | |
| 23 | + """合并字典列表的 reducer 函数""" | |
| 24 | + if left is None: | |
| 25 | + left = [] | |
| 26 | + if right is None: | |
| 27 | + right = [] | |
| 28 | + return left + right | |
| 29 | + | |
| 30 | + | |
| 31 | +def keep_last(left: Any, right: Any) -> Any: | |
| 32 | + """保留最后一个值的 reducer 函数""" | |
| 33 | + return right if right is not None else left | |
| 34 | + | |
| 35 | + | |
| 36 | +def sum_values(left: int, right: int) -> int: | |
| 37 | + """累加数值的 reducer 函数""" | |
| 38 | + return (left or 0) + (right or 0) | |
| 39 | + | |
| 40 | + | |
| 41 | +class AgentState(TypedDict, total=False): | |
| 42 | + """补货建议 Agent 状态 | |
| 43 | + | |
| 44 | + 使用 Annotated 类型定义 reducer 函数,处理并行节点的状态合并 | |
| 45 | + """ | |
| 46 | + | |
| 47 | + # 任务标识(使用 keep_last,因为这些值在并行执行时相同) | |
| 48 | + task_no: Annotated[str, keep_last] | |
| 49 | + group_id: Annotated[int, keep_last] | |
| 50 | + brand_grouping_id: Annotated[Optional[int], keep_last] | |
| 51 | + brand_grouping_name: Annotated[str, keep_last] | |
| 52 | + dealer_grouping_id: Annotated[int, keep_last] | |
| 53 | + dealer_grouping_name: Annotated[str, keep_last] | |
| 54 | + statistics_date: Annotated[str, keep_last] | |
| 55 | + | |
| 56 | + # part_ratio 原始数据 | |
| 57 | + part_ratios: Annotated[List[dict], merge_dicts] | |
| 58 | + | |
| 59 | + # SQL Agent 相关 | |
| 60 | + sql_queries: Annotated[List[str], merge_lists] | |
| 61 | + sql_results: Annotated[List[dict], merge_dicts] | |
| 62 | + sql_retry_count: Annotated[int, keep_last] | |
| 63 | + sql_execution_logs: Annotated[List[dict], merge_dicts] | |
| 64 | + | |
| 65 | + # 计算结果 | |
| 66 | + base_ratio: Annotated[Decimal, keep_last] | |
| 67 | + allocated_details: Annotated[List[dict], merge_dicts] | |
| 68 | + details: Annotated[List[Any], merge_lists] | |
| 69 | + | |
| 70 | + # LLM 建议明细 | |
| 71 | + llm_suggestions: Annotated[List[Any], merge_lists] | |
| 72 | + | |
| 73 | + # 配件汇总结果 | |
| 74 | + part_results: Annotated[List[Any], merge_lists] | |
| 75 | + | |
| 76 | + # LLM 统计(使用累加,合并多个并行节点的 token 使用量) | |
| 77 | + llm_provider: Annotated[str, keep_last] | |
| 78 | + llm_model: Annotated[str, keep_last] | |
| 79 | + llm_prompt_tokens: Annotated[int, sum_values] | |
| 80 | + llm_completion_tokens: Annotated[int, sum_values] | |
| 81 | + | |
| 82 | + # 执行状态 | |
| 83 | + status: Annotated[str, keep_last] | |
| 84 | + error_message: Annotated[str, keep_last] | |
| 85 | + start_time: Annotated[float, keep_last] | |
| 86 | + end_time: Annotated[float, keep_last] | |
| 87 | + | |
| 88 | + # 流程控制 | |
| 89 | + current_node: Annotated[str, keep_last] | |
| 90 | + next_node: Annotated[str, keep_last] | |
| 91 | + | |
| 92 | + | ... | ... |
src/fw_pms_ai/api/__init__.py
0 → 100644
src/fw_pms_ai/api/app.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/api/app.py | |
| 1 | +""" | |
| 2 | +FastAPI 主应用 | |
| 3 | +提供 AI 补货建议系统的 REST API | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +from pathlib import Path | |
| 8 | +from contextlib import asynccontextmanager | |
| 9 | + | |
| 10 | +from fastapi import FastAPI | |
| 11 | +from fastapi.middleware.cors import CORSMiddleware | |
| 12 | +from fastapi.staticfiles import StaticFiles | |
| 13 | +from fastapi.responses import FileResponse | |
| 14 | + | |
| 15 | +from .routes import tasks | |
| 16 | + | |
| 17 | +logger = logging.getLogger(__name__) | |
| 18 | + | |
| 19 | + | |
| 20 | +@asynccontextmanager | |
| 21 | +async def lifespan(app: FastAPI): | |
| 22 | + """应用生命周期管理""" | |
| 23 | + logger.info("API 服务启动") | |
| 24 | + yield | |
| 25 | + logger.info("API 服务关闭") | |
| 26 | + | |
| 27 | + | |
| 28 | +app = FastAPI( | |
| 29 | + title="AI 补货建议系统 API", | |
| 30 | + description="提供补货任务、明细的查询接口", | |
| 31 | + version="1.0.0", | |
| 32 | + lifespan=lifespan, | |
| 33 | +) | |
| 34 | + | |
| 35 | +# CORS 配置 | |
| 36 | +app.add_middleware( | |
| 37 | + CORSMiddleware, | |
| 38 | + allow_origins=["*"], | |
| 39 | + allow_credentials=True, | |
| 40 | + allow_methods=["*"], | |
| 41 | + allow_headers=["*"], | |
| 42 | +) | |
| 43 | + | |
| 44 | +# 挂载路由 | |
| 45 | +app.include_router(tasks.router, prefix="/api", tags=["Tasks"]) | |
| 46 | + | |
| 47 | +# 静态文件服务 | |
| 48 | +ui_path = Path(__file__).parent.parent.parent.parent / "ui" | |
| 49 | +if ui_path.exists(): | |
| 50 | + app.mount("/css", StaticFiles(directory=ui_path / "css"), name="css") | |
| 51 | + app.mount("/js", StaticFiles(directory=ui_path / "js"), name="js") | |
| 52 | + | |
| 53 | + | |
| 54 | +@app.get("/", include_in_schema=False) | |
| 55 | +async def serve_index(): | |
| 56 | + """服务主页面""" | |
| 57 | + index_file = ui_path / "index.html" | |
| 58 | + if index_file.exists(): | |
| 59 | + return FileResponse(index_file) | |
| 60 | + return {"message": "AI 补货建议系统 API", "docs": "/docs"} | |
| 61 | + | |
| 62 | + | |
| 63 | +@app.get("/health") | |
| 64 | +async def health_check(): | |
| 65 | + """健康检查""" | |
| 66 | + return {"status": "ok"} | ... | ... |
src/fw_pms_ai/api/routes/__init__.py
0 → 100644
src/fw_pms_ai/api/routes/tasks.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/api/routes/tasks.py | |
| 1 | +""" | |
| 2 | +任务相关 API 路由 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import logging | |
| 6 | +from typing import Optional, List | |
| 7 | +from datetime import datetime | |
| 8 | +from decimal import Decimal | |
| 9 | + | |
| 10 | +from fastapi import APIRouter, Query, HTTPException | |
| 11 | +from pydantic import BaseModel | |
| 12 | + | |
| 13 | +from ...services.db import get_connection | |
| 14 | + | |
| 15 | +logger = logging.getLogger(__name__) | |
| 16 | +router = APIRouter() | |
| 17 | + | |
| 18 | + | |
| 19 | +class TaskResponse(BaseModel): | |
| 20 | + """任务响应模型""" | |
| 21 | + id: int | |
| 22 | + task_no: str | |
| 23 | + group_id: int | |
| 24 | + dealer_grouping_id: int | |
| 25 | + dealer_grouping_name: Optional[str] = None | |
| 26 | + brand_grouping_id: Optional[int] = None | |
| 27 | + plan_amount: float = 0 | |
| 28 | + actual_amount: float = 0 | |
| 29 | + part_count: int = 0 | |
| 30 | + base_ratio: Optional[float] = None | |
| 31 | + status: int = 0 | |
| 32 | + status_text: str = "" | |
| 33 | + error_message: Optional[str] = None | |
| 34 | + llm_provider: Optional[str] = None | |
| 35 | + llm_model: Optional[str] = None | |
| 36 | + llm_total_tokens: int = 0 | |
| 37 | + statistics_date: Optional[str] = None | |
| 38 | + start_time: Optional[str] = None | |
| 39 | + end_time: Optional[str] = None | |
| 40 | + duration_seconds: Optional[int] = None | |
| 41 | + create_time: Optional[str] = None | |
| 42 | + | |
| 43 | + | |
| 44 | +class TaskListResponse(BaseModel): | |
| 45 | + """任务列表响应""" | |
| 46 | + total: int | |
| 47 | + page: int | |
| 48 | + page_size: int | |
| 49 | + items: List[TaskResponse] | |
| 50 | + | |
| 51 | + | |
| 52 | +class DetailResponse(BaseModel): | |
| 53 | + """配件建议明细响应""" | |
| 54 | + id: int | |
| 55 | + task_no: str | |
| 56 | + shop_id: int | |
| 57 | + shop_name: Optional[str] = None | |
| 58 | + part_code: str | |
| 59 | + part_name: Optional[str] = None | |
| 60 | + unit: Optional[str] = None | |
| 61 | + cost_price: float = 0 | |
| 62 | + current_ratio: Optional[float] = None | |
| 63 | + base_ratio: Optional[float] = None | |
| 64 | + post_plan_ratio: Optional[float] = None | |
| 65 | + valid_storage_cnt: float = 0 | |
| 66 | + avg_sales_cnt: float = 0 | |
| 67 | + suggest_cnt: int = 0 | |
| 68 | + suggest_amount: float = 0 | |
| 69 | + suggestion_reason: Optional[str] = None | |
| 70 | + priority: int = 2 | |
| 71 | + llm_confidence: Optional[float] = None | |
| 72 | + statistics_date: Optional[str] = None | |
| 73 | + | |
| 74 | + | |
| 75 | +class DetailListResponse(BaseModel): | |
| 76 | + """配件建议明细列表响应""" | |
| 77 | + total: int | |
| 78 | + page: int | |
| 79 | + page_size: int | |
| 80 | + items: List[DetailResponse] | |
| 81 | + | |
| 82 | + | |
| 83 | +def format_datetime(dt) -> Optional[str]: | |
| 84 | + """格式化日期时间""" | |
| 85 | + if dt is None: | |
| 86 | + return None | |
| 87 | + if isinstance(dt, datetime): | |
| 88 | + return dt.strftime("%Y-%m-%d %H:%M:%S") | |
| 89 | + return str(dt) | |
| 90 | + | |
| 91 | + | |
| 92 | +def get_status_text(status: int) -> str: | |
| 93 | + """获取状态文本""" | |
| 94 | + status_map = {0: "运行中", 1: "成功", 2: "失败"} | |
| 95 | + return status_map.get(status, "未知") | |
| 96 | + | |
| 97 | + | |
| 98 | +@router.get("/tasks", response_model=TaskListResponse) | |
| 99 | +async def list_tasks( | |
| 100 | + page: int = Query(1, ge=1, description="页码"), | |
| 101 | + page_size: int = Query(20, ge=1, le=100, description="每页数量"), | |
| 102 | + status: Optional[int] = Query(None, description="状态筛选: 0-运行中 1-成功 2-失败"), | |
| 103 | + dealer_grouping_id: Optional[int] = Query(None, description="商家组合ID"), | |
| 104 | + statistics_date: Optional[str] = Query(None, description="统计日期"), | |
| 105 | +): | |
| 106 | + """获取任务列表""" | |
| 107 | + conn = get_connection() | |
| 108 | + cursor = conn.cursor(dictionary=True) | |
| 109 | + | |
| 110 | + try: | |
| 111 | + # 构建查询条件 | |
| 112 | + where_clauses = [] | |
| 113 | + params = [] | |
| 114 | + | |
| 115 | + if status is not None: | |
| 116 | + where_clauses.append("status = %s") | |
| 117 | + params.append(status) | |
| 118 | + | |
| 119 | + if dealer_grouping_id is not None: | |
| 120 | + where_clauses.append("dealer_grouping_id = %s") | |
| 121 | + params.append(dealer_grouping_id) | |
| 122 | + | |
| 123 | + if statistics_date: | |
| 124 | + where_clauses.append("statistics_date = %s") | |
| 125 | + params.append(statistics_date) | |
| 126 | + | |
| 127 | + where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" | |
| 128 | + | |
| 129 | + # 查询总数 | |
| 130 | + count_sql = f"SELECT COUNT(*) as total FROM ai_replenishment_task WHERE {where_sql}" | |
| 131 | + cursor.execute(count_sql, params) | |
| 132 | + total = cursor.fetchone()["total"] | |
| 133 | + | |
| 134 | + # 查询分页数据 | |
| 135 | + offset = (page - 1) * page_size | |
| 136 | + data_sql = f""" | |
| 137 | + SELECT t.*, | |
| 138 | + COALESCE( | |
| 139 | + NULLIF(t.llm_total_tokens, 0), | |
| 140 | + (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no) | |
| 141 | + ) as calculated_tokens | |
| 142 | + FROM ai_replenishment_task t | |
| 143 | + WHERE {where_sql} | |
| 144 | + ORDER BY t.create_time DESC | |
| 145 | + LIMIT %s OFFSET %s | |
| 146 | + """ | |
| 147 | + cursor.execute(data_sql, params + [page_size, offset]) | |
| 148 | + rows = cursor.fetchall() | |
| 149 | + | |
| 150 | + items = [] | |
| 151 | + for row in rows: | |
| 152 | + # 计算执行时长 | |
| 153 | + duration = None | |
| 154 | + if row.get("start_time") and row.get("end_time"): | |
| 155 | + duration = int((row["end_time"] - row["start_time"]).total_seconds()) | |
| 156 | + | |
| 157 | + items.append(TaskResponse( | |
| 158 | + id=row["id"], | |
| 159 | + task_no=row["task_no"], | |
| 160 | + group_id=row["group_id"], | |
| 161 | + dealer_grouping_id=row["dealer_grouping_id"], | |
| 162 | + dealer_grouping_name=row.get("dealer_grouping_name"), | |
| 163 | + brand_grouping_id=row.get("brand_grouping_id"), | |
| 164 | + plan_amount=float(row.get("plan_amount") or 0), | |
| 165 | + actual_amount=float(row.get("actual_amount") or 0), | |
| 166 | + part_count=row.get("part_count") or 0, | |
| 167 | + base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None, | |
| 168 | + status=row.get("status") or 0, | |
| 169 | + status_text=get_status_text(row.get("status") or 0), | |
| 170 | + error_message=row.get("error_message"), | |
| 171 | + llm_provider=row.get("llm_provider"), | |
| 172 | + llm_model=row.get("llm_model"), | |
| 173 | + llm_total_tokens=int(row.get("calculated_tokens") or 0), | |
| 174 | + statistics_date=row.get("statistics_date"), | |
| 175 | + start_time=format_datetime(row.get("start_time")), | |
| 176 | + end_time=format_datetime(row.get("end_time")), | |
| 177 | + duration_seconds=duration, | |
| 178 | + create_time=format_datetime(row.get("create_time")), | |
| 179 | + )) | |
| 180 | + | |
| 181 | + return TaskListResponse( | |
| 182 | + total=total, | |
| 183 | + page=page, | |
| 184 | + page_size=page_size, | |
| 185 | + items=items, | |
| 186 | + ) | |
| 187 | + | |
| 188 | + finally: | |
| 189 | + cursor.close() | |
| 190 | + conn.close() | |
| 191 | + | |
| 192 | + | |
| 193 | +@router.get("/tasks/{task_no}", response_model=TaskResponse) | |
| 194 | +async def get_task(task_no: str): | |
| 195 | + """获取任务详情""" | |
| 196 | + conn = get_connection() | |
| 197 | + cursor = conn.cursor(dictionary=True) | |
| 198 | + | |
| 199 | + try: | |
| 200 | + cursor.execute( | |
| 201 | + """ | |
| 202 | + SELECT t.*, | |
| 203 | + COALESCE( | |
| 204 | + NULLIF(t.llm_total_tokens, 0), | |
| 205 | + (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no) | |
| 206 | + ) as calculated_tokens | |
| 207 | + FROM ai_replenishment_task t | |
| 208 | + WHERE t.task_no = %s | |
| 209 | + """, | |
| 210 | + (task_no,) | |
| 211 | + ) | |
| 212 | + row = cursor.fetchone() | |
| 213 | + | |
| 214 | + if not row: | |
| 215 | + raise HTTPException(status_code=404, detail="任务不存在") | |
| 216 | + | |
| 217 | + duration = None | |
| 218 | + if row.get("start_time") and row.get("end_time"): | |
| 219 | + duration = int((row["end_time"] - row["start_time"]).total_seconds()) | |
| 220 | + | |
| 221 | + return TaskResponse( | |
| 222 | + id=row["id"], | |
| 223 | + task_no=row["task_no"], | |
| 224 | + group_id=row["group_id"], | |
| 225 | + dealer_grouping_id=row["dealer_grouping_id"], | |
| 226 | + dealer_grouping_name=row.get("dealer_grouping_name"), | |
| 227 | + brand_grouping_id=row.get("brand_grouping_id"), | |
| 228 | + plan_amount=float(row.get("plan_amount") or 0), | |
| 229 | + actual_amount=float(row.get("actual_amount") or 0), | |
| 230 | + part_count=row.get("part_count") or 0, | |
| 231 | + base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None, | |
| 232 | + status=row.get("status") or 0, | |
| 233 | + status_text=get_status_text(row.get("status") or 0), | |
| 234 | + error_message=row.get("error_message"), | |
| 235 | + llm_provider=row.get("llm_provider"), | |
| 236 | + llm_model=row.get("llm_model"), | |
| 237 | + llm_total_tokens=int(row.get("calculated_tokens") or 0), | |
| 238 | + statistics_date=row.get("statistics_date"), | |
| 239 | + start_time=format_datetime(row.get("start_time")), | |
| 240 | + end_time=format_datetime(row.get("end_time")), | |
| 241 | + duration_seconds=duration, | |
| 242 | + create_time=format_datetime(row.get("create_time")), | |
| 243 | + ) | |
| 244 | + | |
| 245 | + finally: | |
| 246 | + cursor.close() | |
| 247 | + conn.close() | |
| 248 | + | |
| 249 | + | |
| 250 | +@router.get("/tasks/{task_no}/details", response_model=DetailListResponse) | |
| 251 | +async def get_task_details( | |
| 252 | + task_no: str, | |
| 253 | + page: int = Query(1, ge=1), | |
| 254 | + page_size: int = Query(50, ge=1, le=200), | |
| 255 | + sort_by: str = Query("suggest_amount", description="排序字段"), | |
| 256 | + sort_order: str = Query("desc", description="排序方向: asc/desc"), | |
| 257 | + part_code: Optional[str] = Query(None, description="配件编码搜索"), | |
| 258 | +): | |
| 259 | + """获取任务的配件建议明细""" | |
| 260 | + conn = get_connection() | |
| 261 | + cursor = conn.cursor(dictionary=True) | |
| 262 | + | |
| 263 | + try: | |
| 264 | + # 验证排序字段 | |
| 265 | + allowed_sort_fields = [ | |
| 266 | + "suggest_amount", "suggest_cnt", "cost_price", | |
| 267 | + "avg_sales_cnt", "current_ratio", "part_code" | |
| 268 | + ] | |
| 269 | + if sort_by not in allowed_sort_fields: | |
| 270 | + sort_by = "suggest_amount" | |
| 271 | + | |
| 272 | + sort_direction = "DESC" if sort_order.lower() == "desc" else "ASC" | |
| 273 | + | |
| 274 | + # 构建查询条件 | |
| 275 | + where_sql = "task_no = %s" | |
| 276 | + params = [task_no] | |
| 277 | + | |
| 278 | + if part_code: | |
| 279 | + where_sql += " AND part_code LIKE %s" | |
| 280 | + params.append(f"%{part_code}%") | |
| 281 | + | |
| 282 | + # 查询总数 | |
| 283 | + cursor.execute( | |
| 284 | + f"SELECT COUNT(*) as total FROM ai_replenishment_detail WHERE {where_sql}", | |
| 285 | + params | |
| 286 | + ) | |
| 287 | + total = cursor.fetchone()["total"] | |
| 288 | + | |
| 289 | + # 查询分页数据 | |
| 290 | + offset = (page - 1) * page_size | |
| 291 | + cursor.execute( | |
| 292 | + f""" | |
| 293 | + SELECT * FROM ai_replenishment_detail | |
| 294 | + WHERE {where_sql} | |
| 295 | + ORDER BY {sort_by} {sort_direction} | |
| 296 | + LIMIT %s OFFSET %s | |
| 297 | + """, | |
| 298 | + params + [page_size, offset] | |
| 299 | + ) | |
| 300 | + rows = cursor.fetchall() | |
| 301 | + | |
| 302 | + items = [] | |
| 303 | + for row in rows: | |
| 304 | + items.append(DetailResponse( | |
| 305 | + id=row["id"], | |
| 306 | + task_no=row["task_no"], | |
| 307 | + shop_id=row["shop_id"], | |
| 308 | + shop_name=row.get("shop_name"), | |
| 309 | + part_code=row["part_code"], | |
| 310 | + part_name=row.get("part_name"), | |
| 311 | + unit=row.get("unit"), | |
| 312 | + cost_price=float(row.get("cost_price") or 0), | |
| 313 | + current_ratio=float(row["current_ratio"]) if row.get("current_ratio") else None, | |
| 314 | + base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None, | |
| 315 | + post_plan_ratio=float(row["post_plan_ratio"]) if row.get("post_plan_ratio") else None, | |
| 316 | + valid_storage_cnt=float(row.get("valid_storage_cnt") or 0), | |
| 317 | + avg_sales_cnt=float(row.get("avg_sales_cnt") or 0), | |
| 318 | + suggest_cnt=row.get("suggest_cnt") or 0, | |
| 319 | + suggest_amount=float(row.get("suggest_amount") or 0), | |
| 320 | + suggestion_reason=row.get("suggestion_reason"), | |
| 321 | + priority=row.get("priority") or 2, | |
| 322 | + llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None, | |
| 323 | + statistics_date=row.get("statistics_date"), | |
| 324 | + )) | |
| 325 | + | |
| 326 | + return DetailListResponse( | |
| 327 | + total=total, | |
| 328 | + page=page, | |
| 329 | + page_size=page_size, | |
| 330 | + items=items, | |
| 331 | + ) | |
| 332 | + | |
| 333 | + finally: | |
| 334 | + cursor.close() | |
| 335 | + conn.close() | |
| 336 | + | |
| 337 | + | |
| 338 | +class ExecutionLogResponse(BaseModel): | |
| 339 | + """执行日志响应""" | |
| 340 | + id: int | |
| 341 | + task_no: str | |
| 342 | + step_name: str | |
| 343 | + step_order: int | |
| 344 | + status: int | |
| 345 | + status_text: str = "" | |
| 346 | + input_data: Optional[str] = None | |
| 347 | + output_data: Optional[str] = None | |
| 348 | + error_message: Optional[str] = None | |
| 349 | + retry_count: int = 0 | |
| 350 | + llm_tokens: int = 0 | |
| 351 | + execution_time_ms: int = 0 | |
| 352 | + start_time: Optional[str] = None | |
| 353 | + end_time: Optional[str] = None | |
| 354 | + create_time: Optional[str] = None | |
| 355 | + | |
| 356 | + | |
| 357 | +class ExecutionLogListResponse(BaseModel): | |
| 358 | + """执行日志列表响应""" | |
| 359 | + total: int | |
| 360 | + items: List[ExecutionLogResponse] | |
| 361 | + | |
| 362 | + | |
| 363 | +def get_log_status_text(status: int) -> str: | |
| 364 | + """获取日志状态文本""" | |
| 365 | + status_map = {0: "运行中", 1: "成功", 2: "失败", 3: "跳过"} | |
| 366 | + return status_map.get(status, "未知") | |
| 367 | + | |
| 368 | + | |
| 369 | +def get_step_name_display(step_name: str) -> str: | |
| 370 | + """获取步骤名称显示""" | |
| 371 | + step_map = { | |
| 372 | + "fetch_part_ratio": "获取配件数据", | |
| 373 | + "sql_agent": "AI分析建议", | |
| 374 | + "allocate_budget": "分配预算", | |
| 375 | + "generate_report": "生成报告", | |
| 376 | + } | |
| 377 | + return step_map.get(step_name, step_name) | |
| 378 | + | |
| 379 | + | |
| 380 | +@router.get("/tasks/{task_no}/logs", response_model=ExecutionLogListResponse) | |
| 381 | +async def get_task_logs(task_no: str): | |
| 382 | + """获取任务执行日志""" | |
| 383 | + conn = get_connection() | |
| 384 | + cursor = conn.cursor(dictionary=True) | |
| 385 | + | |
| 386 | + try: | |
| 387 | + cursor.execute( | |
| 388 | + """ | |
| 389 | + SELECT * FROM ai_task_execution_log | |
| 390 | + WHERE task_no = %s | |
| 391 | + ORDER BY step_order ASC | |
| 392 | + """, | |
| 393 | + (task_no,) | |
| 394 | + ) | |
| 395 | + rows = cursor.fetchall() | |
| 396 | + | |
| 397 | + items = [] | |
| 398 | + for row in rows: | |
| 399 | + items.append(ExecutionLogResponse( | |
| 400 | + id=row["id"], | |
| 401 | + task_no=row["task_no"], | |
| 402 | + step_name=row["step_name"], | |
| 403 | + step_order=row.get("step_order") or 0, | |
| 404 | + status=row.get("status") or 0, | |
| 405 | + status_text=get_log_status_text(row.get("status") or 0), | |
| 406 | + input_data=row.get("input_data"), | |
| 407 | + output_data=row.get("output_data"), | |
| 408 | + error_message=row.get("error_message"), | |
| 409 | + retry_count=row.get("retry_count") or 0, | |
| 410 | + llm_tokens=row.get("llm_tokens") or 0, | |
| 411 | + execution_time_ms=row.get("execution_time_ms") or 0, | |
| 412 | + start_time=format_datetime(row.get("start_time")), | |
| 413 | + end_time=format_datetime(row.get("end_time")), | |
| 414 | + create_time=format_datetime(row.get("create_time")), | |
| 415 | + )) | |
| 416 | + | |
| 417 | + return ExecutionLogListResponse( | |
| 418 | + total=len(items), | |
| 419 | + items=items, | |
| 420 | + ) | |
| 421 | + | |
| 422 | + finally: | |
| 423 | + cursor.close() | |
| 424 | + conn.close() | |
| 425 | + | |
| 426 | + | |
| 427 | +class PartSummaryResponse(BaseModel): | |
| 428 | + """配件汇总响应""" | |
| 429 | + id: int | |
| 430 | + task_no: str | |
| 431 | + part_code: str | |
| 432 | + part_name: Optional[str] = None | |
| 433 | + unit: Optional[str] = None | |
| 434 | + cost_price: float = 0 | |
| 435 | + total_storage_cnt: float = 0 | |
| 436 | + total_avg_sales_cnt: float = 0 | |
| 437 | + group_current_ratio: Optional[float] = None | |
| 438 | + total_suggest_cnt: int = 0 | |
| 439 | + total_suggest_amount: float = 0 | |
| 440 | + shop_count: int = 0 | |
| 441 | + need_replenishment_shop_count: int = 0 | |
| 442 | + part_decision_reason: Optional[str] = None | |
| 443 | + priority: int = 2 | |
| 444 | + llm_confidence: Optional[float] = None | |
| 445 | + statistics_date: Optional[str] = None | |
| 446 | + group_post_plan_ratio: Optional[float] = None | |
| 447 | + | |
| 448 | + | |
| 449 | +class PartSummaryListResponse(BaseModel): | |
| 450 | + """配件汇总列表响应""" | |
| 451 | + total: int | |
| 452 | + page: int | |
| 453 | + page_size: int | |
| 454 | + items: List[PartSummaryResponse] | |
| 455 | + | |
| 456 | + | |
| 457 | +@router.get("/tasks/{task_no}/part-summaries", response_model=PartSummaryListResponse) | |
| 458 | +async def get_task_part_summaries( | |
| 459 | + task_no: str, | |
| 460 | + page: int = Query(1, ge=1), | |
| 461 | + page_size: int = Query(50, ge=1, le=200), | |
| 462 | + sort_by: str = Query("total_suggest_amount", description="排序字段"), | |
| 463 | + sort_order: str = Query("desc", description="排序方向: asc/desc"), | |
| 464 | + part_code: Optional[str] = Query(None, description="配件编码筛选"), | |
| 465 | + priority: Optional[int] = Query(None, description="优先级筛选"), | |
| 466 | +): | |
| 467 | + """获取任务的配件汇总列表""" | |
| 468 | + conn = get_connection() | |
| 469 | + cursor = conn.cursor(dictionary=True) | |
| 470 | + | |
| 471 | + try: | |
| 472 | + # 验证排序字段 | |
| 473 | + allowed_sort_fields = [ | |
| 474 | + "total_suggest_amount", "total_suggest_cnt", "cost_price", | |
| 475 | + "total_avg_sales_cnt", "group_current_ratio", "part_code", | |
| 476 | + "priority", "need_replenishment_shop_count", | |
| 477 | + "total_storage_cnt", "shop_count", "group_post_plan_ratio" | |
| 478 | + ] | |
| 479 | + if sort_by not in allowed_sort_fields: | |
| 480 | + sort_by = "total_suggest_amount" | |
| 481 | + | |
| 482 | + sort_direction = "DESC" if sort_order.lower() == "desc" else "ASC" | |
| 483 | + | |
| 484 | + # 构建查询条件 | |
| 485 | + where_clauses = ["task_no = %s"] | |
| 486 | + params = [task_no] | |
| 487 | + | |
| 488 | + if part_code: | |
| 489 | + where_clauses.append("part_code LIKE %s") | |
| 490 | + params.append(f"%{part_code}%") | |
| 491 | + | |
| 492 | + if priority is not None: | |
| 493 | + where_clauses.append("priority = %s") | |
| 494 | + params.append(priority) | |
| 495 | + | |
| 496 | + where_sql = " AND ".join(where_clauses) | |
| 497 | + | |
| 498 | + # 查询总数 | |
| 499 | + cursor.execute( | |
| 500 | + f"SELECT COUNT(*) as total FROM ai_replenishment_part_summary WHERE {where_sql}", | |
| 501 | + params | |
| 502 | + ) | |
| 503 | + total = cursor.fetchone()["total"] | |
| 504 | + | |
| 505 | + # 查询分页数据 | |
| 506 | + offset = (page - 1) * page_size | |
| 507 | + | |
| 508 | + # 动态计算计划后库销比: (库存 + 建议) / 月均销 | |
| 509 | + # 注意: total_avg_sales_cnt 可能为 0, 需要处理除以零的情况 | |
| 510 | + query_sql = f""" | |
| 511 | + SELECT *, | |
| 512 | + ( | |
| 513 | + (COALESCE(total_storage_cnt, 0) + COALESCE(total_suggest_cnt, 0)) / | |
| 514 | + NULLIF(total_avg_sales_cnt, 0) | |
| 515 | + ) as group_post_plan_ratio | |
| 516 | + FROM ai_replenishment_part_summary | |
| 517 | + WHERE {where_sql} | |
| 518 | + ORDER BY {sort_by} {sort_direction} | |
| 519 | + LIMIT %s OFFSET %s | |
| 520 | + """ | |
| 521 | + | |
| 522 | + cursor.execute(query_sql, params + [page_size, offset]) | |
| 523 | + rows = cursor.fetchall() | |
| 524 | + | |
| 525 | + items = [] | |
| 526 | + for row in rows: | |
| 527 | + items.append(PartSummaryResponse( | |
| 528 | + id=row["id"], | |
| 529 | + task_no=row["task_no"], | |
| 530 | + part_code=row["part_code"], | |
| 531 | + part_name=row.get("part_name"), | |
| 532 | + unit=row.get("unit"), | |
| 533 | + cost_price=float(row.get("cost_price") or 0), | |
| 534 | + total_storage_cnt=float(row.get("total_storage_cnt") or 0), | |
| 535 | + total_avg_sales_cnt=float(row.get("total_avg_sales_cnt") or 0), | |
| 536 | + group_current_ratio=float(row["group_current_ratio"]) if row.get("group_current_ratio") else None, | |
| 537 | + group_post_plan_ratio=float(row["group_post_plan_ratio"]) if row.get("group_post_plan_ratio") is not None else None, | |
| 538 | + total_suggest_cnt=row.get("total_suggest_cnt") or 0, | |
| 539 | + total_suggest_amount=float(row.get("total_suggest_amount") or 0), | |
| 540 | + shop_count=row.get("shop_count") or 0, | |
| 541 | + need_replenishment_shop_count=row.get("need_replenishment_shop_count") or 0, | |
| 542 | + part_decision_reason=row.get("part_decision_reason"), | |
| 543 | + priority=row.get("priority") or 2, | |
| 544 | + llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None, | |
| 545 | + statistics_date=row.get("statistics_date"), | |
| 546 | + )) | |
| 547 | + | |
| 548 | + return PartSummaryListResponse( | |
| 549 | + total=total, | |
| 550 | + page=page, | |
| 551 | + page_size=page_size, | |
| 552 | + items=items, | |
| 553 | + ) | |
| 554 | + | |
| 555 | + finally: | |
| 556 | + cursor.close() | |
| 557 | + conn.close() | |
| 558 | + | |
| 559 | + | |
| 560 | +@router.get("/tasks/{task_no}/parts/{part_code}/shops") | |
| 561 | +async def get_part_shop_details( | |
| 562 | + task_no: str, | |
| 563 | + part_code: str, | |
| 564 | +): | |
| 565 | + """获取指定配件的门店明细""" | |
| 566 | + conn = get_connection() | |
| 567 | + cursor = conn.cursor(dictionary=True) | |
| 568 | + | |
| 569 | + try: | |
| 570 | + cursor.execute( | |
| 571 | + """ | |
| 572 | + SELECT * FROM ai_replenishment_detail | |
| 573 | + WHERE task_no = %s AND part_code = %s | |
| 574 | + ORDER BY suggest_amount DESC | |
| 575 | + """, | |
| 576 | + (task_no, part_code) | |
| 577 | + ) | |
| 578 | + rows = cursor.fetchall() | |
| 579 | + | |
| 580 | + items = [] | |
| 581 | + for row in rows: | |
| 582 | + items.append(DetailResponse( | |
| 583 | + id=row["id"], | |
| 584 | + task_no=row["task_no"], | |
| 585 | + shop_id=row["shop_id"], | |
| 586 | + shop_name=row.get("shop_name"), | |
| 587 | + part_code=row["part_code"], | |
| 588 | + part_name=row.get("part_name"), | |
| 589 | + unit=row.get("unit"), | |
| 590 | + cost_price=float(row.get("cost_price") or 0), | |
| 591 | + current_ratio=float(row["current_ratio"]) if row.get("current_ratio") else None, | |
| 592 | + base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None, | |
| 593 | + post_plan_ratio=float(row["post_plan_ratio"]) if row.get("post_plan_ratio") else None, | |
| 594 | + valid_storage_cnt=float(row.get("valid_storage_cnt") or 0), | |
| 595 | + avg_sales_cnt=float(row.get("avg_sales_cnt") or 0), | |
| 596 | + suggest_cnt=row.get("suggest_cnt") or 0, | |
| 597 | + suggest_amount=float(row.get("suggest_amount") or 0), | |
| 598 | + suggestion_reason=row.get("suggestion_reason"), | |
| 599 | + priority=row.get("priority") or 2, | |
| 600 | + llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None, | |
| 601 | + statistics_date=row.get("statistics_date"), | |
| 602 | + )) | |
| 603 | + | |
| 604 | + return { | |
| 605 | + "total": len(items), | |
| 606 | + "items": items, | |
| 607 | + } | |
| 608 | + | |
| 609 | + finally: | |
| 610 | + cursor.close() | |
| 611 | + conn.close() | ... | ... |
src/fw_pms_ai/config/__init__.py
0 → 100644
src/fw_pms_ai/config/settings.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/config/settings.py | |
| 1 | +""" | |
| 2 | +配置管理模块 | |
| 3 | +使用 pydantic-settings 从环境变量加载配置 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +from functools import lru_cache | |
| 7 | +from pydantic_settings import BaseSettings, SettingsConfigDict | |
| 8 | + | |
| 9 | + | |
| 10 | +class Settings(BaseSettings): | |
| 11 | + """应用配置""" | |
| 12 | + | |
| 13 | + model_config = SettingsConfigDict( | |
| 14 | + env_file=".env", | |
| 15 | + env_file_encoding="utf-8", | |
| 16 | + case_sensitive=False, | |
| 17 | + ) | |
| 18 | + | |
| 19 | + # GLM-4.7 配置 | |
| 20 | + glm_api_key: str = "" | |
| 21 | + glm_model: str = "glm-4" | |
| 22 | + | |
| 23 | + # OpenAI 兼容模式配置(火山引擎等) | |
| 24 | + openai_compat_api_key: str = "" | |
| 25 | + openai_compat_base_url: str = "https://ark.cn-beijing.volces.com/api/v3" | |
| 26 | + openai_compat_model: str = "doubao-seed-1-8-251228" | |
| 27 | + | |
| 28 | + # Anthropic 兼容模式配置(智谱AI) | |
| 29 | + anthropic_api_key: str = "" | |
| 30 | + anthropic_base_url: str = "https://open.bigmodel.cn/api/anthropic" | |
| 31 | + anthropic_model: str = "glm-4.7" | |
| 32 | + | |
| 33 | + # 豆包配置 | |
| 34 | + doubao_api_key: str = "" | |
| 35 | + doubao_model: str = "doubao-pro" | |
| 36 | + | |
| 37 | + # 数据库配置 | |
| 38 | + mysql_host: str = "localhost" | |
| 39 | + mysql_port: int = 3306 | |
| 40 | + mysql_user: str = "root" | |
| 41 | + mysql_password: str = "" | |
| 42 | + mysql_database: str = "fw_pms" | |
| 43 | + | |
| 44 | + # 定时任务配置 | |
| 45 | + scheduler_cron_hour: int = 2 | |
| 46 | + scheduler_cron_minute: int = 0 | |
| 47 | + | |
| 48 | + # 日志配置 | |
| 49 | + log_level: str = "INFO" | |
| 50 | + | |
| 51 | + @property | |
| 52 | + def mysql_connection_string(self) -> str: | |
| 53 | + """MySQL 连接字符串""" | |
| 54 | + return ( | |
| 55 | + f"mysql+mysqlconnector://{self.mysql_user}:{self.mysql_password}" | |
| 56 | + f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}" | |
| 57 | + ) | |
| 58 | + | |
| 59 | + @property | |
| 60 | + def primary_llm_provider(self) -> str: | |
| 61 | + """主要 LLM 供应商""" | |
| 62 | + if self.openai_compat_api_key: | |
| 63 | + return "openai_compat" | |
| 64 | + elif self.anthropic_api_key: | |
| 65 | + return "anthropic_compat" | |
| 66 | + elif self.glm_api_key: | |
| 67 | + return "glm" | |
| 68 | + elif self.doubao_api_key: | |
| 69 | + return "doubao" | |
| 70 | + else: | |
| 71 | + raise ValueError("未配置任何 LLM API Key") | |
| 72 | + | |
| 73 | + | |
| 74 | +@lru_cache | |
| 75 | +def get_settings() -> Settings: | |
| 76 | + """获取配置单例""" | |
| 77 | + return Settings() | ... | ... |
src/fw_pms_ai/llm/__init__.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/llm/__init__.py | |
| 1 | +"""LLM 模块""" | |
| 2 | + | |
| 3 | +from .base import BaseLLMClient, LLMResponse, LLMUsage | |
| 4 | +from .glm import GLMClient | |
| 5 | +from .doubao import DoubaoClient | |
| 6 | +from .anthropic_compat import AnthropicCompatClient | |
| 7 | +from .openai_compat import OpenAICompatClient | |
| 8 | +from ..config import get_settings | |
| 9 | + | |
| 10 | + | |
| 11 | +def get_llm_client() -> BaseLLMClient: | |
| 12 | + """获取 LLM 客户端""" | |
| 13 | + settings = get_settings() | |
| 14 | + provider = settings.primary_llm_provider | |
| 15 | + | |
| 16 | + if provider == "openai_compat": | |
| 17 | + return OpenAICompatClient() | |
| 18 | + elif provider == "anthropic_compat": | |
| 19 | + return AnthropicCompatClient() | |
| 20 | + elif provider == "glm": | |
| 21 | + return GLMClient() | |
| 22 | + elif provider == "doubao": | |
| 23 | + return DoubaoClient() | |
| 24 | + else: | |
| 25 | + raise ValueError(f"不支持的 LLM 供应商: {provider}") | |
| 26 | + | |
| 27 | + | |
| 28 | +__all__ = [ | |
| 29 | + "BaseLLMClient", | |
| 30 | + "LLMResponse", | |
| 31 | + "LLMUsage", | |
| 32 | + "GLMClient", | |
| 33 | + "DoubaoClient", | |
| 34 | + "AnthropicCompatClient", | |
| 35 | + "OpenAICompatClient", | |
| 36 | + "get_llm_client", | |
| 37 | +] | |
| 38 | + | ... | ... |
src/fw_pms_ai/llm/anthropic_compat.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/llm/anthropic_compat.py | |
| 1 | +""" | |
| 2 | +Anthropic 兼容模式客户端 | |
| 3 | +支持智谱AI的Anthropic兼容接口 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +from langchain_core.language_models import BaseChatModel | |
| 8 | +from langchain_core.messages import BaseMessage, AIMessage | |
| 9 | +import anthropic | |
| 10 | + | |
| 11 | +from .base import BaseLLMClient, LLMResponse, LLMUsage | |
| 12 | +from ..config import get_settings | |
| 13 | + | |
| 14 | +logger = logging.getLogger(__name__) | |
| 15 | + | |
| 16 | + | |
| 17 | +class ChatAnthropicCompat(BaseChatModel): | |
| 18 | + """Anthropic兼容模式 LangChain 包装器""" | |
| 19 | + | |
| 20 | + client: anthropic.Anthropic = None | |
| 21 | + model: str = "glm-4.7" | |
| 22 | + temperature: float = 0.7 | |
| 23 | + max_tokens: int = 4096 | |
| 24 | + | |
| 25 | + def __init__(self, api_key: str, base_url: str, model: str = "glm-4.7", **kwargs): | |
| 26 | + super().__init__(**kwargs) | |
| 27 | + self.client = anthropic.Anthropic( | |
| 28 | + api_key=api_key, | |
| 29 | + base_url=base_url, | |
| 30 | + ) | |
| 31 | + self.model = model | |
| 32 | + | |
| 33 | + @property | |
| 34 | + def _llm_type(self) -> str: | |
| 35 | + return "anthropic_compat" | |
| 36 | + | |
| 37 | + def _generate(self, messages, stop=None, run_manager=None, **kwargs): | |
| 38 | + from langchain_core.outputs import ChatGeneration, ChatResult | |
| 39 | + | |
| 40 | + formatted_messages = [] | |
| 41 | + system_content = None | |
| 42 | + | |
| 43 | + for msg in messages: | |
| 44 | + if hasattr(msg, 'type'): | |
| 45 | + if msg.type == "system": | |
| 46 | + system_content = msg.content | |
| 47 | + continue | |
| 48 | + role = "user" if msg.type == "human" else "assistant" | |
| 49 | + else: | |
| 50 | + role = "user" | |
| 51 | + formatted_messages.append({"role": role, "content": msg.content}) | |
| 52 | + | |
| 53 | + create_kwargs = { | |
| 54 | + "model": self.model, | |
| 55 | + "messages": formatted_messages, | |
| 56 | + "max_tokens": self.max_tokens, | |
| 57 | + } | |
| 58 | + if system_content: | |
| 59 | + create_kwargs["system"] = system_content | |
| 60 | + | |
| 61 | + response = self.client.messages.create(**create_kwargs) | |
| 62 | + | |
| 63 | + content = response.content[0].text | |
| 64 | + generation = ChatGeneration(message=AIMessage(content=content)) | |
| 65 | + | |
| 66 | + return ChatResult( | |
| 67 | + generations=[generation], | |
| 68 | + llm_output={ | |
| 69 | + "token_usage": { | |
| 70 | + "prompt_tokens": response.usage.input_tokens, | |
| 71 | + "completion_tokens": response.usage.output_tokens, | |
| 72 | + "total_tokens": response.usage.input_tokens + response.usage.output_tokens, | |
| 73 | + } | |
| 74 | + } | |
| 75 | + ) | |
| 76 | + | |
| 77 | + | |
| 78 | +class AnthropicCompatClient(BaseLLMClient): | |
| 79 | + """Anthropic兼容模式客户端""" | |
| 80 | + | |
| 81 | + def __init__(self, api_key: str = None, base_url: str = None, model: str = None): | |
| 82 | + settings = get_settings() | |
| 83 | + self._api_key = api_key or settings.anthropic_api_key | |
| 84 | + self._base_url = base_url or settings.anthropic_base_url | |
| 85 | + self._model = model or settings.anthropic_model | |
| 86 | + self._client = anthropic.Anthropic( | |
| 87 | + api_key=self._api_key, | |
| 88 | + base_url=self._base_url, | |
| 89 | + ) | |
| 90 | + self._chat_model = None | |
| 91 | + | |
| 92 | + @property | |
| 93 | + def provider(self) -> str: | |
| 94 | + return "anthropic_compat" | |
| 95 | + | |
| 96 | + @property | |
| 97 | + def model_name(self) -> str: | |
| 98 | + return self._model | |
| 99 | + | |
| 100 | + def get_chat_model(self) -> BaseChatModel: | |
| 101 | + """获取 LangChain 聊天模型""" | |
| 102 | + if self._chat_model is None: | |
| 103 | + self._chat_model = ChatAnthropicCompat( | |
| 104 | + api_key=self._api_key, | |
| 105 | + base_url=self._base_url, | |
| 106 | + model=self._model, | |
| 107 | + ) | |
| 108 | + return self._chat_model | |
| 109 | + | |
| 110 | + def invoke(self, messages: list[BaseMessage]) -> LLMResponse: | |
| 111 | + """调用 Anthropic 兼容接口(带自定义重试和速率限制处理)""" | |
| 112 | + import time | |
| 113 | + import random | |
| 114 | + | |
| 115 | + max_retries = 5 | |
| 116 | + base_delay = 2.0 | |
| 117 | + max_delay = 30.0 | |
| 118 | + post_request_delay = 1.0 | |
| 119 | + | |
| 120 | + formatted_messages = [] | |
| 121 | + system_content = None | |
| 122 | + | |
| 123 | + for msg in messages: | |
| 124 | + if hasattr(msg, 'type'): | |
| 125 | + if msg.type == "system": | |
| 126 | + system_content = msg.content | |
| 127 | + continue | |
| 128 | + role = "user" if msg.type == "human" else "assistant" | |
| 129 | + else: | |
| 130 | + role = "user" | |
| 131 | + formatted_messages.append({"role": role, "content": msg.content}) | |
| 132 | + | |
| 133 | + create_kwargs = { | |
| 134 | + "model": self._model, | |
| 135 | + "messages": formatted_messages, | |
| 136 | + "max_tokens": 4096, | |
| 137 | + } | |
| 138 | + if system_content: | |
| 139 | + create_kwargs["system"] = system_content | |
| 140 | + | |
| 141 | + last_exception = None | |
| 142 | + | |
| 143 | + for attempt in range(max_retries): | |
| 144 | + try: | |
| 145 | + response = self._client.messages.create(**create_kwargs) | |
| 146 | + | |
| 147 | + content = response.content[0].text | |
| 148 | + usage = self.create_usage( | |
| 149 | + prompt_tokens=response.usage.input_tokens, | |
| 150 | + completion_tokens=response.usage.output_tokens, | |
| 151 | + ) | |
| 152 | + | |
| 153 | + logger.info( | |
| 154 | + f"Anthropic兼容接口调用完成: model={self._model}, " | |
| 155 | + f"tokens={usage.total_tokens}" | |
| 156 | + ) | |
| 157 | + | |
| 158 | + time.sleep(post_request_delay) | |
| 159 | + | |
| 160 | + return LLMResponse(content=content, usage=usage, raw_response=response) | |
| 161 | + | |
| 162 | + except Exception as e: | |
| 163 | + last_exception = e | |
| 164 | + error_str = str(e) | |
| 165 | + is_rate_limit = "429" in error_str or "rate" in error_str.lower() or "1302" in error_str | |
| 166 | + | |
| 167 | + if attempt < max_retries - 1: | |
| 168 | + if is_rate_limit: | |
| 169 | + delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay) | |
| 170 | + logger.warning( | |
| 171 | + f"API速率限制 (尝试 {attempt + 1}/{max_retries}), " | |
| 172 | + f"等待 {delay:.1f}秒 后重试..." | |
| 173 | + ) | |
| 174 | + else: | |
| 175 | + delay = base_delay * (attempt + 1) | |
| 176 | + logger.warning( | |
| 177 | + f"API调用失败 (尝试 {attempt + 1}/{max_retries}): {e}, " | |
| 178 | + f"等待 {delay:.1f}秒 后重试..." | |
| 179 | + ) | |
| 180 | + time.sleep(delay) | |
| 181 | + else: | |
| 182 | + logger.error(f"Anthropic兼容接口调用失败(已重试{max_retries}次): {e}") | |
| 183 | + raise | ... | ... |
src/fw_pms_ai/llm/base.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/llm/base.py | |
| 1 | +""" | |
| 2 | +LLM 基础抽象类 | |
| 3 | +定义统一的 LLM 接口 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +from abc import ABC, abstractmethod | |
| 7 | +from typing import Any | |
| 8 | +from dataclasses import dataclass, field | |
| 9 | +from langchain_core.language_models import BaseChatModel | |
| 10 | +from langchain_core.messages import BaseMessage | |
| 11 | + | |
| 12 | + | |
| 13 | +@dataclass | |
| 14 | +class LLMUsage: | |
| 15 | + """LLM 使用统计""" | |
| 16 | + provider: str = "" | |
| 17 | + model: str = "" | |
| 18 | + prompt_tokens: int = 0 | |
| 19 | + completion_tokens: int = 0 | |
| 20 | + total_tokens: int = 0 | |
| 21 | + | |
| 22 | + def add(self, other: "LLMUsage") -> "LLMUsage": | |
| 23 | + """累加使用量""" | |
| 24 | + return LLMUsage( | |
| 25 | + provider=self.provider or other.provider, | |
| 26 | + model=self.model or other.model, | |
| 27 | + prompt_tokens=self.prompt_tokens + other.prompt_tokens, | |
| 28 | + completion_tokens=self.completion_tokens + other.completion_tokens, | |
| 29 | + total_tokens=self.total_tokens + other.total_tokens, | |
| 30 | + ) | |
| 31 | + | |
| 32 | + | |
| 33 | +@dataclass | |
| 34 | +class LLMResponse: | |
| 35 | + """LLM 响应""" | |
| 36 | + content: str | |
| 37 | + usage: LLMUsage = field(default_factory=LLMUsage) | |
| 38 | + raw_response: Any = None | |
| 39 | + | |
| 40 | + | |
| 41 | +class BaseLLMClient(ABC): | |
| 42 | + """LLM 客户端基类""" | |
| 43 | + | |
| 44 | + @property | |
| 45 | + @abstractmethod | |
| 46 | + def provider(self) -> str: | |
| 47 | + """供应商名称""" | |
| 48 | + pass | |
| 49 | + | |
| 50 | + @property | |
| 51 | + @abstractmethod | |
| 52 | + def model_name(self) -> str: | |
| 53 | + """模型名称""" | |
| 54 | + pass | |
| 55 | + | |
| 56 | + @abstractmethod | |
| 57 | + def get_chat_model(self) -> BaseChatModel: | |
| 58 | + """获取 LangChain 聊天模型""" | |
| 59 | + pass | |
| 60 | + | |
| 61 | + @abstractmethod | |
| 62 | + def invoke(self, messages: list[BaseMessage]) -> LLMResponse: | |
| 63 | + """调用 LLM""" | |
| 64 | + pass | |
| 65 | + | |
| 66 | + def create_usage(self, prompt_tokens: int = 0, completion_tokens: int = 0) -> LLMUsage: | |
| 67 | + """创建使用统计""" | |
| 68 | + return LLMUsage( | |
| 69 | + provider=self.provider, | |
| 70 | + model=self.model_name, | |
| 71 | + prompt_tokens=prompt_tokens, | |
| 72 | + completion_tokens=completion_tokens, | |
| 73 | + total_tokens=prompt_tokens + completion_tokens, | |
| 74 | + ) | ... | ... |
src/fw_pms_ai/llm/doubao.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/llm/doubao.py | |
| 1 | +""" | |
| 2 | +豆包 (字节跳动) 集成 - 备选 LLM | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import logging | |
| 6 | +import httpx | |
| 7 | +from langchain_core.language_models import BaseChatModel | |
| 8 | +from langchain_core.messages import BaseMessage, AIMessage | |
| 9 | + | |
| 10 | +from .base import BaseLLMClient, LLMResponse | |
| 11 | +from ..config import get_settings | |
| 12 | + | |
| 13 | +logger = logging.getLogger(__name__) | |
| 14 | + | |
| 15 | + | |
| 16 | +class DoubaoClient(BaseLLMClient): | |
| 17 | + """豆包客户端 (备选)""" | |
| 18 | + | |
| 19 | + def __init__(self, api_key: str = None, model: str = None): | |
| 20 | + settings = get_settings() | |
| 21 | + self._api_key = api_key or settings.doubao_api_key | |
| 22 | + self._model = model or settings.doubao_model | |
| 23 | + self._base_url = "https://ark.cn-beijing.volces.com/api/v3" | |
| 24 | + | |
| 25 | + @property | |
| 26 | + def provider(self) -> str: | |
| 27 | + return "doubao" | |
| 28 | + | |
| 29 | + @property | |
| 30 | + def model_name(self) -> str: | |
| 31 | + return self._model | |
| 32 | + | |
| 33 | + def get_chat_model(self) -> BaseChatModel: | |
| 34 | + """获取 LangChain 聊天模型""" | |
| 35 | + raise NotImplementedError("豆包 LangChain 集成待实现") | |
| 36 | + | |
| 37 | + def invoke(self, messages: list[BaseMessage]) -> LLMResponse: | |
| 38 | + """调用豆包""" | |
| 39 | + try: | |
| 40 | + formatted_messages = [] | |
| 41 | + for msg in messages: | |
| 42 | + if hasattr(msg, 'type'): | |
| 43 | + role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system" | |
| 44 | + else: | |
| 45 | + role = "user" | |
| 46 | + formatted_messages.append({"role": role, "content": msg.content}) | |
| 47 | + | |
| 48 | + with httpx.Client() as client: | |
| 49 | + response = client.post( | |
| 50 | + f"{self._base_url}/chat/completions", | |
| 51 | + headers={ | |
| 52 | + "Authorization": f"Bearer {self._api_key}", | |
| 53 | + "Content-Type": "application/json", | |
| 54 | + }, | |
| 55 | + json={ | |
| 56 | + "model": self._model, | |
| 57 | + "messages": formatted_messages, | |
| 58 | + "temperature": 0.7, | |
| 59 | + "max_tokens": 4096, | |
| 60 | + }, | |
| 61 | + timeout=60.0, | |
| 62 | + ) | |
| 63 | + response.raise_for_status() | |
| 64 | + data = response.json() | |
| 65 | + | |
| 66 | + content = data["choices"][0]["message"]["content"] | |
| 67 | + usage_data = data.get("usage", {}) | |
| 68 | + usage = self.create_usage( | |
| 69 | + prompt_tokens=usage_data.get("prompt_tokens", 0), | |
| 70 | + completion_tokens=usage_data.get("completion_tokens", 0), | |
| 71 | + ) | |
| 72 | + | |
| 73 | + logger.info( | |
| 74 | + f"豆包调用完成: model={self._model}, " | |
| 75 | + f"tokens={usage.total_tokens}" | |
| 76 | + ) | |
| 77 | + | |
| 78 | + return LLMResponse(content=content, usage=usage, raw_response=data) | |
| 79 | + | |
| 80 | + except Exception as e: | |
| 81 | + logger.error(f"豆包调用失败: {e}") | |
| 82 | + raise | ... | ... |
src/fw_pms_ai/llm/glm.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/llm/glm.py | |
| 1 | +""" | |
| 2 | +GLM-4.7 (智谱AI) 集成 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import logging | |
| 6 | +from langchain_core.language_models import BaseChatModel | |
| 7 | +from langchain_core.messages import BaseMessage, AIMessage | |
| 8 | +from zhipuai import ZhipuAI | |
| 9 | + | |
| 10 | +from .base import BaseLLMClient, LLMResponse, LLMUsage | |
| 11 | +from ..config import get_settings | |
| 12 | + | |
| 13 | +logger = logging.getLogger(__name__) | |
| 14 | + | |
| 15 | + | |
| 16 | +class ChatZhipuAI(BaseChatModel): | |
| 17 | + """智谱AI 聊天模型 LangChain 包装器""" | |
| 18 | + | |
| 19 | + client: ZhipuAI = None | |
| 20 | + model: str = "glm-4" | |
| 21 | + temperature: float = 0.7 | |
| 22 | + max_tokens: int = 4096 | |
| 23 | + | |
| 24 | + def __init__(self, api_key: str, model: str = "glm-4", **kwargs): | |
| 25 | + super().__init__(**kwargs) | |
| 26 | + self.client = ZhipuAI(api_key=api_key) | |
| 27 | + self.model = model | |
| 28 | + | |
| 29 | + @property | |
| 30 | + def _llm_type(self) -> str: | |
| 31 | + return "zhipuai" | |
| 32 | + | |
| 33 | + def _generate(self, messages, stop=None, run_manager=None, **kwargs): | |
| 34 | + from langchain_core.outputs import ChatGeneration, ChatResult | |
| 35 | + | |
| 36 | + formatted_messages = [] | |
| 37 | + for msg in messages: | |
| 38 | + if hasattr(msg, 'type'): | |
| 39 | + role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system" | |
| 40 | + else: | |
| 41 | + role = "user" | |
| 42 | + formatted_messages.append({"role": role, "content": msg.content}) | |
| 43 | + | |
| 44 | + response = self.client.chat.completions.create( | |
| 45 | + model=self.model, | |
| 46 | + messages=formatted_messages, | |
| 47 | + temperature=self.temperature, | |
| 48 | + max_tokens=self.max_tokens, | |
| 49 | + ) | |
| 50 | + | |
| 51 | + content = response.choices[0].message.content | |
| 52 | + generation = ChatGeneration(message=AIMessage(content=content)) | |
| 53 | + | |
| 54 | + return ChatResult( | |
| 55 | + generations=[generation], | |
| 56 | + llm_output={ | |
| 57 | + "token_usage": { | |
| 58 | + "prompt_tokens": response.usage.prompt_tokens, | |
| 59 | + "completion_tokens": response.usage.completion_tokens, | |
| 60 | + "total_tokens": response.usage.total_tokens, | |
| 61 | + } | |
| 62 | + } | |
| 63 | + ) | |
| 64 | + | |
| 65 | + | |
| 66 | +class GLMClient(BaseLLMClient): | |
| 67 | + """GLM-4.7 客户端""" | |
| 68 | + | |
| 69 | + def __init__(self, api_key: str = None, model: str = None): | |
| 70 | + settings = get_settings() | |
| 71 | + self._api_key = api_key or settings.glm_api_key | |
| 72 | + self._model = model or settings.glm_model | |
| 73 | + self._client = ZhipuAI(api_key=self._api_key) | |
| 74 | + self._chat_model = None | |
| 75 | + | |
| 76 | + @property | |
| 77 | + def provider(self) -> str: | |
| 78 | + return "glm" | |
| 79 | + | |
| 80 | + @property | |
| 81 | + def model_name(self) -> str: | |
| 82 | + return self._model | |
| 83 | + | |
| 84 | + def get_chat_model(self) -> BaseChatModel: | |
| 85 | + """获取 LangChain 聊天模型""" | |
| 86 | + if self._chat_model is None: | |
| 87 | + self._chat_model = ChatZhipuAI(api_key=self._api_key, model=self._model) | |
| 88 | + return self._chat_model | |
| 89 | + | |
| 90 | + def invoke(self, messages: list[BaseMessage]) -> LLMResponse: | |
| 91 | + """调用 GLM""" | |
| 92 | + try: | |
| 93 | + formatted_messages = [] | |
| 94 | + for msg in messages: | |
| 95 | + if hasattr(msg, 'type'): | |
| 96 | + role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system" | |
| 97 | + else: | |
| 98 | + role = "user" | |
| 99 | + formatted_messages.append({"role": role, "content": msg.content}) | |
| 100 | + | |
| 101 | + response = self._client.chat.completions.create( | |
| 102 | + model=self._model, | |
| 103 | + messages=formatted_messages, | |
| 104 | + temperature=0.7, | |
| 105 | + max_tokens=4096, | |
| 106 | + ) | |
| 107 | + | |
| 108 | + content = response.choices[0].message.content | |
| 109 | + usage = self.create_usage( | |
| 110 | + prompt_tokens=response.usage.prompt_tokens, | |
| 111 | + completion_tokens=response.usage.completion_tokens, | |
| 112 | + ) | |
| 113 | + | |
| 114 | + logger.info( | |
| 115 | + f"GLM 调用完成: model={self._model}, " | |
| 116 | + f"tokens={usage.total_tokens}" | |
| 117 | + ) | |
| 118 | + | |
| 119 | + return LLMResponse(content=content, usage=usage, raw_response=response) | |
| 120 | + | |
| 121 | + except Exception as e: | |
| 122 | + logger.error(f"GLM 调用失败: {e}") | |
| 123 | + raise | ... | ... |
src/fw_pms_ai/llm/openai_compat.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/llm/openai_compat.py | |
| 1 | +""" | |
| 2 | +OpenAI 兼容模式客户端 | |
| 3 | +支持火山引擎等 OpenAI 兼容接口 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +from langchain_core.language_models import BaseChatModel | |
| 8 | +from langchain_core.messages import BaseMessage, AIMessage | |
| 9 | +from langchain_openai import ChatOpenAI | |
| 10 | +from openai import OpenAI | |
| 11 | + | |
| 12 | +from .base import BaseLLMClient, LLMResponse, LLMUsage | |
| 13 | +from ..config import get_settings | |
| 14 | + | |
| 15 | +logger = logging.getLogger(__name__) | |
| 16 | + | |
| 17 | + | |
| 18 | +class OpenAICompatClient(BaseLLMClient): | |
| 19 | + """OpenAI 兼容模式客户端(火山引擎等)""" | |
| 20 | + | |
| 21 | + def __init__(self, api_key: str = None, base_url: str = None, model: str = None): | |
| 22 | + settings = get_settings() | |
| 23 | + self._api_key = api_key or settings.openai_compat_api_key | |
| 24 | + self._base_url = base_url or settings.openai_compat_base_url | |
| 25 | + self._model = model or settings.openai_compat_model | |
| 26 | + self._client = OpenAI( | |
| 27 | + api_key=self._api_key, | |
| 28 | + base_url=self._base_url, | |
| 29 | + ) | |
| 30 | + self._chat_model = None | |
| 31 | + | |
| 32 | + @property | |
| 33 | + def provider(self) -> str: | |
| 34 | + return "openai_compat" | |
| 35 | + | |
| 36 | + @property | |
| 37 | + def model_name(self) -> str: | |
| 38 | + return self._model | |
| 39 | + | |
| 40 | + def get_chat_model(self) -> BaseChatModel: | |
| 41 | + """获取 LangChain 聊天模型""" | |
| 42 | + if self._chat_model is None: | |
| 43 | + self._chat_model = ChatOpenAI( | |
| 44 | + api_key=self._api_key, | |
| 45 | + base_url=self._base_url, | |
| 46 | + model=self._model, | |
| 47 | + temperature=0.7, | |
| 48 | + max_tokens=4096, | |
| 49 | + ) | |
| 50 | + return self._chat_model | |
| 51 | + | |
| 52 | + def invoke(self, messages: list[BaseMessage]) -> LLMResponse: | |
| 53 | + """调用 OpenAI 兼容接口(带自定义重试和速率限制处理)""" | |
| 54 | + import time | |
| 55 | + import random | |
| 56 | + | |
| 57 | + max_retries = 5 | |
| 58 | + base_delay = 2.0 | |
| 59 | + max_delay = 30.0 | |
| 60 | + post_request_delay = 1.0 | |
| 61 | + | |
| 62 | + formatted_messages = [] | |
| 63 | + | |
| 64 | + for msg in messages: | |
| 65 | + if hasattr(msg, 'type'): | |
| 66 | + if msg.type == "system": | |
| 67 | + role = "system" | |
| 68 | + elif msg.type == "human": | |
| 69 | + role = "user" | |
| 70 | + else: | |
| 71 | + role = "assistant" | |
| 72 | + else: | |
| 73 | + role = "user" | |
| 74 | + formatted_messages.append({"role": role, "content": msg.content}) | |
| 75 | + | |
| 76 | + last_exception = None | |
| 77 | + | |
| 78 | + for attempt in range(max_retries): | |
| 79 | + try: | |
| 80 | + response = self._client.chat.completions.create( | |
| 81 | + model=self._model, | |
| 82 | + messages=formatted_messages, | |
| 83 | + max_tokens=4096, | |
| 84 | + ) | |
| 85 | + | |
| 86 | + content = response.choices[0].message.content | |
| 87 | + usage = self.create_usage( | |
| 88 | + prompt_tokens=response.usage.prompt_tokens, | |
| 89 | + completion_tokens=response.usage.completion_tokens, | |
| 90 | + ) | |
| 91 | + | |
| 92 | + logger.info( | |
| 93 | + f"OpenAI兼容接口调用完成: model={self._model}, " | |
| 94 | + f"tokens={usage.total_tokens}" | |
| 95 | + ) | |
| 96 | + | |
| 97 | + time.sleep(post_request_delay) | |
| 98 | + | |
| 99 | + return LLMResponse(content=content, usage=usage, raw_response=response) | |
| 100 | + | |
| 101 | + except Exception as e: | |
| 102 | + last_exception = e | |
| 103 | + error_str = str(e) | |
| 104 | + is_rate_limit = "429" in error_str or "rate" in error_str.lower() | |
| 105 | + | |
| 106 | + if attempt < max_retries - 1: | |
| 107 | + if is_rate_limit: | |
| 108 | + delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay) | |
| 109 | + logger.warning( | |
| 110 | + f"API速率限制 (尝试 {attempt + 1}/{max_retries}), " | |
| 111 | + f"等待 {delay:.1f}秒 后重试..." | |
| 112 | + ) | |
| 113 | + else: | |
| 114 | + delay = base_delay * (attempt + 1) | |
| 115 | + logger.warning( | |
| 116 | + f"API调用失败 (尝试 {attempt + 1}/{max_retries}): {e}, " | |
| 117 | + f"等待 {delay:.1f}秒 后重试..." | |
| 118 | + ) | |
| 119 | + time.sleep(delay) | |
| 120 | + else: | |
| 121 | + logger.error(f"OpenAI兼容接口调用失败(已重试{max_retries}次): {e}") | |
| 122 | + raise | ... | ... |
src/fw_pms_ai/main.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/main.py | |
| 1 | +""" | |
| 2 | +fw-pms-ai 主入口 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import logging | |
| 6 | +import sys | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +try: | |
| 10 | + from .scheduler.tasks import main as scheduler_main | |
| 11 | +except ImportError: | |
| 12 | + # 直接运行时,添加项目根目录到 sys.path | |
| 13 | + project_root = Path(__file__).parent.parent.parent | |
| 14 | + if str(project_root) not in sys.path: | |
| 15 | + sys.path.insert(0, str(project_root)) | |
| 16 | + from fw_pms_ai.scheduler.tasks import main as scheduler_main | |
| 17 | + | |
| 18 | + | |
| 19 | +def main(): | |
| 20 | + """应用入口""" | |
| 21 | + logging.basicConfig( | |
| 22 | + level=logging.INFO, | |
| 23 | + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| 24 | + ) | |
| 25 | + | |
| 26 | + scheduler_main() | |
| 27 | + | |
| 28 | + | |
| 29 | +if __name__ == "__main__": | |
| 30 | + main() | ... | ... |
src/fw_pms_ai/models/__init__.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/models/__init__.py | |
| 1 | +"""数据模型模块""" | |
| 2 | + | |
| 3 | +from .part_ratio import PartRatio | |
| 4 | +from .task import ReplenishmentTask, ReplenishmentDetail, TaskStatus | |
| 5 | +from .execution_log import TaskExecutionLog, LogStatus | |
| 6 | +from .part_summary import ReplenishmentPartSummary | |
| 7 | +from .sql_result import SQLExecutionResult | |
| 8 | +from .suggestion import ReplenishmentSuggestion, PartAnalysisResult | |
| 9 | + | |
| 10 | +__all__ = [ | |
| 11 | + "PartRatio", | |
| 12 | + "ReplenishmentTask", | |
| 13 | + "ReplenishmentDetail", | |
| 14 | + "TaskStatus", | |
| 15 | + "TaskExecutionLog", | |
| 16 | + "LogStatus", | |
| 17 | + "ReplenishmentPartSummary", | |
| 18 | + "SQLExecutionResult", | |
| 19 | + "ReplenishmentSuggestion", | |
| 20 | + "PartAnalysisResult", | |
| 21 | +] | |
| 22 | + | |
| 23 | + | |
| 24 | + | ... | ... |
src/fw_pms_ai/models/execution_log.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/models/execution_log.py | |
| 1 | +""" | |
| 2 | +任务执行日志模型和LLM建议明细模型 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +from dataclasses import dataclass, field | |
| 6 | +from datetime import datetime | |
| 7 | +from decimal import Decimal | |
| 8 | +from typing import Optional | |
| 9 | +from enum import IntEnum | |
| 10 | + | |
| 11 | + | |
| 12 | +class LogStatus(IntEnum): | |
| 13 | + """日志状态""" | |
| 14 | + RUNNING = 0 | |
| 15 | + SUCCESS = 1 | |
| 16 | + FAILED = 2 | |
| 17 | + SKIPPED = 3 | |
| 18 | + | |
| 19 | + | |
| 20 | +@dataclass | |
| 21 | +class TaskExecutionLog: | |
| 22 | + """任务执行日志""" | |
| 23 | + task_no: str | |
| 24 | + group_id: int | |
| 25 | + dealer_grouping_id: int | |
| 26 | + step_name: str | |
| 27 | + | |
| 28 | + id: Optional[int] = None | |
| 29 | + brand_grouping_id: Optional[int] = None | |
| 30 | + brand_grouping_name: str = "" | |
| 31 | + dealer_grouping_name: str = "" | |
| 32 | + step_order: int = 0 | |
| 33 | + status: LogStatus = LogStatus.RUNNING | |
| 34 | + input_data: str = "" | |
| 35 | + output_data: str = "" | |
| 36 | + error_message: str = "" | |
| 37 | + retry_count: int = 0 | |
| 38 | + sql_query: str = "" | |
| 39 | + llm_prompt: str = "" | |
| 40 | + llm_response: str = "" | |
| 41 | + llm_tokens: int = 0 | |
| 42 | + execution_time_ms: int = 0 | |
| 43 | + start_time: Optional[datetime] = None | |
| 44 | + end_time: Optional[datetime] = None | |
| 45 | + create_time: Optional[datetime] = None | |
| 46 | + | |
| 47 | + | |
| 48 | + | ... | ... |
src/fw_pms_ai/models/part_ratio.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/models/part_ratio.py | |
| 1 | +""" | |
| 2 | +数据模型 - 库销比 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +from dataclasses import dataclass | |
| 6 | +from decimal import Decimal | |
| 7 | +from datetime import datetime | |
| 8 | +from typing import Optional | |
| 9 | + | |
| 10 | + | |
| 11 | +@dataclass | |
| 12 | +class PartRatio: | |
| 13 | + """配件库销比数据""" | |
| 14 | + | |
| 15 | + id: int | |
| 16 | + group_id: int | |
| 17 | + brand_id: Optional[int] = None | |
| 18 | + brand_grouping_id: Optional[int] = None | |
| 19 | + supplier_id: Optional[int] = None | |
| 20 | + supplier_name: Optional[str] = None | |
| 21 | + area_id: Optional[int] = None | |
| 22 | + area_name: Optional[str] = None | |
| 23 | + shop_id: int = 0 | |
| 24 | + shop_name: Optional[str] = None | |
| 25 | + part_id: Optional[int] = None | |
| 26 | + part_code: str = "" | |
| 27 | + part_name: Optional[str] = None | |
| 28 | + part_biz_type: Optional[str] = None | |
| 29 | + unit_price: Decimal = Decimal("0") | |
| 30 | + cost_price: Decimal = Decimal("0") | |
| 31 | + storage_total_cnt: Decimal = Decimal("0") | |
| 32 | + in_stock_unlocked_cnt: Decimal = Decimal("0") | |
| 33 | + has_plan_cnt: Decimal = Decimal("0") | |
| 34 | + on_the_way_cnt: Decimal = Decimal("0") | |
| 35 | + out_stock_cnt: Decimal = Decimal("0") | |
| 36 | + storage_locked_cnt: Decimal = Decimal("0") | |
| 37 | + out_stock_ongoing_cnt: Decimal = Decimal("0") | |
| 38 | + buy_cnt: int = 0 | |
| 39 | + transfer_cnt: int = 0 | |
| 40 | + gen_transfer_cnt: int = 0 | |
| 41 | + part_tag: Optional[int] = None | |
| 42 | + stock_age: Optional[int] = None | |
| 43 | + unit: Optional[str] = None | |
| 44 | + out_times: Optional[int] = None | |
| 45 | + statistics_date: str = "" | |
| 46 | + | |
| 47 | + @property | |
| 48 | + def valid_storage_cnt(self) -> Decimal: | |
| 49 | + """有效库存数量 = 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途""" | |
| 50 | + return (self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt + | |
| 51 | + Decimal(str(self.transfer_cnt)) + Decimal(str(self.gen_transfer_cnt))) | |
| 52 | + | |
| 53 | + @property | |
| 54 | + def valid_storage_amount(self) -> Decimal: | |
| 55 | + """有效库存金额""" | |
| 56 | + return self.valid_storage_cnt * self.cost_price | |
| 57 | + | |
| 58 | + @property | |
| 59 | + def avg_sales_cnt(self) -> Decimal: | |
| 60 | + """平均销量 (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3""" | |
| 61 | + total = (self.out_stock_cnt or Decimal("0")) + self.storage_locked_cnt + self.out_stock_ongoing_cnt + Decimal(str(self.buy_cnt)) | |
| 62 | + return total / Decimal("3") | |
| 63 | + | |
| 64 | + @property | |
| 65 | + def avg_sales_amount(self) -> Decimal: | |
| 66 | + """平均销量金额""" | |
| 67 | + return self.avg_sales_cnt * self.cost_price | |
| 68 | + | |
| 69 | + @property | |
| 70 | + def current_ratio(self) -> Decimal: | |
| 71 | + """当前库销比""" | |
| 72 | + if self.avg_sales_cnt == 0: | |
| 73 | + return Decimal("999") | |
| 74 | + return self.valid_storage_cnt / self.avg_sales_cnt | |
| 75 | + | |
| 76 | + @classmethod | |
| 77 | + def from_dict(cls, data: dict) -> "PartRatio": | |
| 78 | + """从字典创建""" | |
| 79 | + return cls( | |
| 80 | + id=data.get("id", 0), | |
| 81 | + group_id=data.get("group_id", 0), | |
| 82 | + brand_id=data.get("brand_id"), | |
| 83 | + brand_grouping_id=data.get("brand_grouping_id"), | |
| 84 | + supplier_id=data.get("supplier_id"), | |
| 85 | + supplier_name=data.get("supplier_name"), | |
| 86 | + area_id=data.get("area_id"), | |
| 87 | + area_name=data.get("area_name"), | |
| 88 | + shop_id=data.get("shop_id", 0), | |
| 89 | + shop_name=data.get("shop_name"), | |
| 90 | + part_id=data.get("part_id"), | |
| 91 | + part_code=data.get("part_code", ""), | |
| 92 | + part_name=data.get("part_name"), | |
| 93 | + part_biz_type=data.get("part_biz_type"), | |
| 94 | + unit_price=Decimal(str(data.get("unit_price", 0))), | |
| 95 | + cost_price=Decimal(str(data.get("cost_price", 0))), | |
| 96 | + storage_total_cnt=Decimal(str(data.get("storage_total_cnt", 0))), | |
| 97 | + in_stock_unlocked_cnt=Decimal(str(data.get("in_stock_unlocked_cnt", 0))), | |
| 98 | + has_plan_cnt=Decimal(str(data.get("has_plan_cnt", 0))), | |
| 99 | + on_the_way_cnt=Decimal(str(data.get("on_the_way_cnt", 0))), | |
| 100 | + out_stock_cnt=Decimal(str(data.get("out_stock_cnt", 0))), | |
| 101 | + storage_locked_cnt=Decimal(str(data.get("storage_locked_cnt", 0))), | |
| 102 | + out_stock_ongoing_cnt=Decimal(str(data.get("out_stock_ongoing_cnt", 0))), | |
| 103 | + buy_cnt=int(data.get("buy_cnt", 0)), | |
| 104 | + transfer_cnt=int(data.get("transfer_cnt", 0)), | |
| 105 | + gen_transfer_cnt=int(data.get("gen_transfer_cnt", 0)), | |
| 106 | + part_tag=data.get("part_tag"), | |
| 107 | + stock_age=data.get("stock_age"), | |
| 108 | + unit=data.get("unit"), | |
| 109 | + out_times=data.get("out_times"), | |
| 110 | + statistics_date=data.get("statistics_date", ""), | |
| 111 | + ) | ... | ... |
src/fw_pms_ai/models/part_summary.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/models/part_summary.py | |
| 1 | +""" | |
| 2 | +数据模型 - 配件汇总 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +from dataclasses import dataclass | |
| 6 | +from decimal import Decimal | |
| 7 | +from datetime import datetime | |
| 8 | +from typing import Optional | |
| 9 | + | |
| 10 | + | |
| 11 | +@dataclass | |
| 12 | +class ReplenishmentPartSummary: | |
| 13 | + """AI补货建议-配件汇总""" | |
| 14 | + | |
| 15 | + task_no: str | |
| 16 | + group_id: int | |
| 17 | + dealer_grouping_id: int | |
| 18 | + part_code: str | |
| 19 | + | |
| 20 | + id: Optional[int] = None | |
| 21 | + part_name: Optional[str] = None | |
| 22 | + unit: Optional[str] = None | |
| 23 | + cost_price: Decimal = Decimal("0") | |
| 24 | + | |
| 25 | + # 商家组合级别汇总数据 | |
| 26 | + total_storage_cnt: Decimal = Decimal("0") | |
| 27 | + total_avg_sales_cnt: Decimal = Decimal("0") | |
| 28 | + group_current_ratio: Optional[Decimal] = None | |
| 29 | + | |
| 30 | + # 补货建议汇总 | |
| 31 | + total_suggest_cnt: int = 0 | |
| 32 | + total_suggest_amount: Decimal = Decimal("0") | |
| 33 | + shop_count: int = 0 | |
| 34 | + need_replenishment_shop_count: int = 0 | |
| 35 | + | |
| 36 | + # LLM分析结果 | |
| 37 | + part_decision_reason: str = "" | |
| 38 | + priority: int = 2 | |
| 39 | + llm_confidence: float = 0.8 | |
| 40 | + | |
| 41 | + # 元数据 | |
| 42 | + statistics_date: str = "" | |
| 43 | + create_time: Optional[datetime] = None | |
| 44 | + | |
| 45 | + def to_dict(self) -> dict: | |
| 46 | + """转换为字典""" | |
| 47 | + return { | |
| 48 | + "task_no": self.task_no, | |
| 49 | + "group_id": self.group_id, | |
| 50 | + "dealer_grouping_id": self.dealer_grouping_id, | |
| 51 | + "part_code": self.part_code, | |
| 52 | + "part_name": self.part_name, | |
| 53 | + "unit": self.unit, | |
| 54 | + "cost_price": float(self.cost_price), | |
| 55 | + "total_storage_cnt": float(self.total_storage_cnt), | |
| 56 | + "total_avg_sales_cnt": float(self.total_avg_sales_cnt), | |
| 57 | + "group_current_ratio": float(self.group_current_ratio) if self.group_current_ratio else None, | |
| 58 | + "total_suggest_cnt": self.total_suggest_cnt, | |
| 59 | + "total_suggest_amount": float(self.total_suggest_amount), | |
| 60 | + "shop_count": self.shop_count, | |
| 61 | + "need_replenishment_shop_count": self.need_replenishment_shop_count, | |
| 62 | + "part_decision_reason": self.part_decision_reason, | |
| 63 | + "priority": self.priority, | |
| 64 | + "llm_confidence": self.llm_confidence, | |
| 65 | + "statistics_date": self.statistics_date, | |
| 66 | + } | ... | ... |
src/fw_pms_ai/models/sql_result.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/models/sql_result.py | |
| 1 | +""" | |
| 2 | +SQL 执行结果模型 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +from dataclasses import dataclass | |
| 6 | +from typing import Dict, List, Optional | |
| 7 | + | |
| 8 | + | |
| 9 | +@dataclass | |
| 10 | +class SQLExecutionResult: | |
| 11 | + """SQL执行结果""" | |
| 12 | + success: bool | |
| 13 | + sql: str | |
| 14 | + data: Optional[List[Dict]] = None | |
| 15 | + error: Optional[str] = None | |
| 16 | + retry_count: int = 0 | |
| 17 | + execution_time_ms: int = 0 | ... | ... |
src/fw_pms_ai/models/suggestion.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/models/suggestion.py | |
| 1 | +""" | |
| 2 | +补货建议和配件分析结果模型 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +from dataclasses import dataclass, field | |
| 6 | +from decimal import Decimal | |
| 7 | +from typing import List | |
| 8 | + | |
| 9 | + | |
| 10 | +@dataclass | |
| 11 | +class ReplenishmentSuggestion: | |
| 12 | + """补货建议""" | |
| 13 | + shop_id: int | |
| 14 | + shop_name: str | |
| 15 | + part_code: str | |
| 16 | + part_name: str | |
| 17 | + unit: str | |
| 18 | + cost_price: Decimal | |
| 19 | + current_storage_cnt: Decimal | |
| 20 | + avg_sales_cnt: Decimal | |
| 21 | + current_ratio: Decimal | |
| 22 | + suggest_cnt: int | |
| 23 | + suggest_amount: Decimal | |
| 24 | + suggestion_reason: str | |
| 25 | + priority: int = 2 | |
| 26 | + confidence: float = 0.8 | |
| 27 | + | |
| 28 | + | |
| 29 | +@dataclass | |
| 30 | +class PartAnalysisResult: | |
| 31 | + """配件分析结果 - 包含配件级汇总信息""" | |
| 32 | + part_code: str | |
| 33 | + part_name: str | |
| 34 | + unit: str | |
| 35 | + cost_price: Decimal | |
| 36 | + total_storage_cnt: Decimal | |
| 37 | + total_avg_sales_cnt: Decimal | |
| 38 | + group_current_ratio: Decimal | |
| 39 | + need_replenishment: bool | |
| 40 | + total_suggest_cnt: int | |
| 41 | + total_suggest_amount: Decimal | |
| 42 | + shop_count: int | |
| 43 | + need_replenishment_shop_count: int | |
| 44 | + part_decision_reason: str | |
| 45 | + priority: int = 2 | |
| 46 | + confidence: float = 0.8 | |
| 47 | + suggestions: List["ReplenishmentSuggestion"] = field(default_factory=list) | ... | ... |
src/fw_pms_ai/models/task.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/models/task.py | |
| 1 | +""" | |
| 2 | +数据模型 - 补货任务 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +from dataclasses import dataclass, field | |
| 6 | +from decimal import Decimal | |
| 7 | +from datetime import datetime | |
| 8 | +from typing import Optional | |
| 9 | +from enum import IntEnum | |
| 10 | + | |
| 11 | + | |
| 12 | +class TaskStatus(IntEnum): | |
| 13 | + """任务状态""" | |
| 14 | + RUNNING = 0 | |
| 15 | + SUCCESS = 1 | |
| 16 | + FAILED = 2 | |
| 17 | + | |
| 18 | + | |
| 19 | +@dataclass | |
| 20 | +class ReplenishmentTask: | |
| 21 | + """AI补货任务""" | |
| 22 | + | |
| 23 | + task_no: str | |
| 24 | + group_id: int | |
| 25 | + dealer_grouping_id: int | |
| 26 | + | |
| 27 | + id: Optional[int] = None | |
| 28 | + dealer_grouping_name: Optional[str] = None | |
| 29 | + brand_grouping_id: Optional[int] = None | |
| 30 | + plan_amount: Decimal = Decimal("0") | |
| 31 | + actual_amount: Decimal = Decimal("0") | |
| 32 | + part_count: int = 0 | |
| 33 | + base_ratio: Optional[Decimal] = None | |
| 34 | + status: TaskStatus = TaskStatus.RUNNING | |
| 35 | + error_message: str = "" | |
| 36 | + llm_provider: str = "" | |
| 37 | + llm_model: str = "" | |
| 38 | + llm_total_tokens: int = 0 | |
| 39 | + statistics_date: str = "" | |
| 40 | + start_time: Optional[datetime] = None | |
| 41 | + end_time: Optional[datetime] = None | |
| 42 | + create_time: Optional[datetime] = None | |
| 43 | + | |
| 44 | + def to_dict(self) -> dict: | |
| 45 | + """转换为字典""" | |
| 46 | + return { | |
| 47 | + "task_no": self.task_no, | |
| 48 | + "group_id": self.group_id, | |
| 49 | + "dealer_grouping_id": self.dealer_grouping_id, | |
| 50 | + "dealer_grouping_name": self.dealer_grouping_name, | |
| 51 | + "brand_grouping_id": self.brand_grouping_id, | |
| 52 | + "plan_amount": float(self.plan_amount), | |
| 53 | + "actual_amount": float(self.actual_amount), | |
| 54 | + "part_count": self.part_count, | |
| 55 | + "base_ratio": float(self.base_ratio) if self.base_ratio else None, | |
| 56 | + "status": int(self.status), | |
| 57 | + "error_message": self.error_message, | |
| 58 | + "llm_provider": self.llm_provider, | |
| 59 | + "llm_model": self.llm_model, | |
| 60 | + "llm_total_tokens": self.llm_total_tokens, | |
| 61 | + "statistics_date": self.statistics_date, | |
| 62 | + } | |
| 63 | + | |
| 64 | + | |
| 65 | +@dataclass | |
| 66 | +class ReplenishmentDetail: | |
| 67 | + """AI补货建议明细""" | |
| 68 | + | |
| 69 | + task_no: str | |
| 70 | + group_id: int | |
| 71 | + dealer_grouping_id: int | |
| 72 | + shop_id: int | |
| 73 | + part_code: str | |
| 74 | + | |
| 75 | + id: Optional[int] = None | |
| 76 | + brand_grouping_id: Optional[int] = None | |
| 77 | + shop_name: Optional[str] = None | |
| 78 | + part_name: Optional[str] = None | |
| 79 | + unit: Optional[str] = None | |
| 80 | + cost_price: Decimal = Decimal("0") | |
| 81 | + current_ratio: Optional[Decimal] = None | |
| 82 | + base_ratio: Optional[Decimal] = None | |
| 83 | + post_plan_ratio: Optional[Decimal] = None | |
| 84 | + valid_storage_cnt: Decimal = Decimal("0") | |
| 85 | + avg_sales_cnt: Decimal = Decimal("0") | |
| 86 | + suggest_cnt: int = 0 | |
| 87 | + suggest_amount: Decimal = Decimal("0") | |
| 88 | + suggestion_reason: str = "" | |
| 89 | + priority: int = 2 | |
| 90 | + llm_confidence: float = 0.8 | |
| 91 | + statistics_date: str = "" | |
| 92 | + create_time: Optional[datetime] = None | |
| 93 | + | |
| 94 | + def to_dict(self) -> dict: | |
| 95 | + """转换为字典""" | |
| 96 | + return { | |
| 97 | + "task_no": self.task_no, | |
| 98 | + "group_id": self.group_id, | |
| 99 | + "dealer_grouping_id": self.dealer_grouping_id, | |
| 100 | + "brand_grouping_id": self.brand_grouping_id, | |
| 101 | + "shop_id": self.shop_id, | |
| 102 | + "shop_name": self.shop_name, | |
| 103 | + "part_code": self.part_code, | |
| 104 | + "part_name": self.part_name, | |
| 105 | + "unit": self.unit, | |
| 106 | + "cost_price": float(self.cost_price), | |
| 107 | + "current_ratio": float(self.current_ratio) if self.current_ratio else None, | |
| 108 | + "base_ratio": float(self.base_ratio) if self.base_ratio else None, | |
| 109 | + "post_plan_ratio": float(self.post_plan_ratio) if self.post_plan_ratio else None, | |
| 110 | + "valid_storage_cnt": float(self.valid_storage_cnt), | |
| 111 | + "avg_sales_cnt": float(self.avg_sales_cnt), | |
| 112 | + "suggest_cnt": self.suggest_cnt, | |
| 113 | + "suggest_amount": float(self.suggest_amount), | |
| 114 | + "suggestion_reason": self.suggestion_reason, | |
| 115 | + "priority": self.priority, | |
| 116 | + "llm_confidence": self.llm_confidence, | |
| 117 | + "statistics_date": self.statistics_date, | |
| 118 | + } | ... | ... |
src/fw_pms_ai/scheduler/__init__.py
0 → 100644
src/fw_pms_ai/scheduler/tasks.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/scheduler/tasks.py | |
| 1 | +""" | |
| 2 | +定时任务 | |
| 3 | +使用 APScheduler 实现每日凌晨执行 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +import argparse | |
| 8 | +from datetime import date | |
| 9 | + | |
| 10 | +from apscheduler.schedulers.blocking import BlockingScheduler | |
| 11 | +from apscheduler.triggers.cron import CronTrigger | |
| 12 | + | |
| 13 | +from ..config import get_settings | |
| 14 | +from ..agent import ReplenishmentAgent | |
| 15 | + | |
| 16 | +logger = logging.getLogger(__name__) | |
| 17 | + | |
| 18 | + | |
| 19 | +def run_replenishment_task(): | |
| 20 | + """执行补货建议任务""" | |
| 21 | + logger.info("="*50) | |
| 22 | + logger.info("开始执行 AI 补货建议定时任务") | |
| 23 | + logger.info("="*50) | |
| 24 | + | |
| 25 | + try: | |
| 26 | + agent = ReplenishmentAgent() | |
| 27 | + | |
| 28 | + # 默认配置 - 可从数据库读取 | |
| 29 | + group_id = 2 | |
| 30 | + | |
| 31 | + agent.run_for_all_groupings( | |
| 32 | + group_id=group_id, | |
| 33 | + ) | |
| 34 | + | |
| 35 | + logger.info("="*50) | |
| 36 | + logger.info("AI 补货建议定时任务执行完成") | |
| 37 | + logger.info("="*50) | |
| 38 | + | |
| 39 | + except Exception as e: | |
| 40 | + logger.error(f"定时任务执行失败: {e}", exc_info=True) | |
| 41 | + raise | |
| 42 | + | |
| 43 | + | |
| 44 | +def start_scheduler(): | |
| 45 | + """启动定时调度""" | |
| 46 | + settings = get_settings() | |
| 47 | + | |
| 48 | + scheduler = BlockingScheduler() | |
| 49 | + | |
| 50 | + # 添加定时任务 | |
| 51 | + trigger = CronTrigger( | |
| 52 | + hour=settings.scheduler_cron_hour, | |
| 53 | + minute=settings.scheduler_cron_minute, | |
| 54 | + ) | |
| 55 | + | |
| 56 | + scheduler.add_job( | |
| 57 | + run_replenishment_task, | |
| 58 | + trigger=trigger, | |
| 59 | + id="replenishment_task", | |
| 60 | + name="AI 补货建议任务", | |
| 61 | + replace_existing=True, | |
| 62 | + ) | |
| 63 | + | |
| 64 | + logger.info( | |
| 65 | + f"定时任务已配置: 每日 {settings.scheduler_cron_hour:02d}:{settings.scheduler_cron_minute:02d} 执行" | |
| 66 | + ) | |
| 67 | + | |
| 68 | + try: | |
| 69 | + logger.info("调度器启动...") | |
| 70 | + scheduler.start() | |
| 71 | + except (KeyboardInterrupt, SystemExit): | |
| 72 | + logger.info("调度器停止") | |
| 73 | + scheduler.shutdown() | |
| 74 | + | |
| 75 | + | |
| 76 | +def main(): | |
| 77 | + """CLI 入口""" | |
| 78 | + parser = argparse.ArgumentParser(description="AI 补货建议定时任务") | |
| 79 | + parser.add_argument( | |
| 80 | + "--run-once", | |
| 81 | + action="store_true", | |
| 82 | + help="立即执行一次(不启动调度器)", | |
| 83 | + ) | |
| 84 | + parser.add_argument( | |
| 85 | + "--group-id", | |
| 86 | + type=int, | |
| 87 | + default=2, | |
| 88 | + help="集团ID (默认: 2)", | |
| 89 | + ) | |
| 90 | + parser.add_argument( | |
| 91 | + "--dealer-grouping-id", | |
| 92 | + type=int, | |
| 93 | + help="指定商家组合ID (可选)", | |
| 94 | + ) | |
| 95 | + | |
| 96 | + args = parser.parse_args() | |
| 97 | + | |
| 98 | + # 配置日志 | |
| 99 | + logging.basicConfig( | |
| 100 | + level=logging.INFO, | |
| 101 | + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| 102 | + ) | |
| 103 | + | |
| 104 | + if args.run_once: | |
| 105 | + logger.info("单次执行模式") | |
| 106 | + | |
| 107 | + if args.dealer_grouping_id: | |
| 108 | + # 指定商家组合 | |
| 109 | + from ..services import DataService | |
| 110 | + | |
| 111 | + agent = ReplenishmentAgent() | |
| 112 | + data_service = DataService() | |
| 113 | + | |
| 114 | + try: | |
| 115 | + groupings = data_service.get_dealer_groupings(args.group_id) | |
| 116 | + grouping = next((g for g in groupings if g["id"] == args.dealer_grouping_id), None) | |
| 117 | + | |
| 118 | + if not grouping: | |
| 119 | + logger.error(f"未找到商家组合: {args.dealer_grouping_id}") | |
| 120 | + return | |
| 121 | + | |
| 122 | + # 直接使用预计划数据,不需要 shop_ids | |
| 123 | + agent.run( | |
| 124 | + group_id=args.group_id, | |
| 125 | + dealer_grouping_id=grouping["id"], | |
| 126 | + dealer_grouping_name=grouping["name"], | |
| 127 | + ) | |
| 128 | + | |
| 129 | + finally: | |
| 130 | + data_service.close() | |
| 131 | + else: | |
| 132 | + # 所有商家组合 | |
| 133 | + run_replenishment_task() | |
| 134 | + else: | |
| 135 | + start_scheduler() | |
| 136 | + | |
| 137 | + | |
| 138 | +if __name__ == "__main__": | |
| 139 | + main() | ... | ... |
src/fw_pms_ai/services/__init__.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/services/__init__.py | |
| 1 | +"""服务模块""" | |
| 2 | + | |
| 3 | +from .data_service import DataService | |
| 4 | +from .result_writer import ResultWriter | |
| 5 | +from .repository import TaskRepository, DetailRepository, LogRepository, SummaryRepository | |
| 6 | + | |
| 7 | +__all__ = [ | |
| 8 | + "DataService", | |
| 9 | + "ResultWriter", | |
| 10 | + "TaskRepository", | |
| 11 | + "DetailRepository", | |
| 12 | + "LogRepository", | |
| 13 | + "SummaryRepository", | |
| 14 | +] | |
| 15 | + | |
| 16 | + | ... | ... |
src/fw_pms_ai/services/data_service.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/services/data_service.py | |
| 1 | +""" | |
| 2 | +数据获取服务 | |
| 3 | +从 MySQL 数据库查询库销比等数据 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +from typing import List, Optional | |
| 8 | +from datetime import date | |
| 9 | +from decimal import Decimal | |
| 10 | + | |
| 11 | +from .db import get_connection | |
| 12 | +from ..models import PartRatio | |
| 13 | + | |
| 14 | +logger = logging.getLogger(__name__) | |
| 15 | + | |
| 16 | + | |
| 17 | +class DataService: | |
| 18 | + """数据服务""" | |
| 19 | + | |
| 20 | + def __init__(self): | |
| 21 | + self._conn = None | |
| 22 | + | |
| 23 | + def _get_connection(self): | |
| 24 | + """获取数据库连接""" | |
| 25 | + if self._conn is None or not self._conn.is_connected(): | |
| 26 | + self._conn = get_connection() | |
| 27 | + return self._conn | |
| 28 | + | |
| 29 | + def close(self): | |
| 30 | + """关闭连接""" | |
| 31 | + if self._conn and self._conn.is_connected(): | |
| 32 | + self._conn.close() | |
| 33 | + self._conn = None | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + def get_dealer_groupings(self, group_id: int) -> List[dict]: | |
| 38 | + """ | |
| 39 | + 获取商家组合列表 | |
| 40 | + | |
| 41 | + Returns: | |
| 42 | + [{"id": 1, "name": "商家组合A", "dealer_ids": [1, 2, 3]}] | |
| 43 | + """ | |
| 44 | + conn = self._get_connection() | |
| 45 | + cursor = conn.cursor(dictionary=True) | |
| 46 | + | |
| 47 | + try: | |
| 48 | + sql = """ | |
| 49 | + SELECT id, region_name as name, auth_dealers | |
| 50 | + FROM artificial_region_dealer | |
| 51 | + WHERE group_id = %s | |
| 52 | + """ | |
| 53 | + cursor.execute(sql, [group_id]) | |
| 54 | + rows = cursor.fetchall() | |
| 55 | + | |
| 56 | + result = [] | |
| 57 | + for row in rows: | |
| 58 | + dealer_ids = [] | |
| 59 | + if row.get("auth_dealers"): | |
| 60 | + try: | |
| 61 | + import json | |
| 62 | + dealers = json.loads(row["auth_dealers"]) | |
| 63 | + if isinstance(dealers, list): | |
| 64 | + if dealers and isinstance(dealers[0], dict): | |
| 65 | + dealer_ids = [d.get("dealerId") for d in dealers if d.get("dealerId")] | |
| 66 | + else: | |
| 67 | + dealer_ids = [int(d) for d in dealers if d] | |
| 68 | + except: | |
| 69 | + pass | |
| 70 | + result.append({ | |
| 71 | + "id": row["id"], | |
| 72 | + "name": row["name"], | |
| 73 | + "dealer_ids": dealer_ids, | |
| 74 | + }) | |
| 75 | + | |
| 76 | + logger.info(f"查询商家组合: group_id={group_id}, count={len(result)}") | |
| 77 | + return result | |
| 78 | + | |
| 79 | + finally: | |
| 80 | + cursor.close() | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | ... | ... |
src/fw_pms_ai/services/db.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/services/db.py | |
| 1 | +""" | |
| 2 | +数据库连接管理模块 | |
| 3 | +统一管理 MySQL 数据库连接 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import mysql.connector | |
| 7 | +from mysql.connector import MySQLConnection | |
| 8 | + | |
| 9 | +from ..config import get_settings | |
| 10 | + | |
| 11 | + | |
| 12 | +def get_connection() -> MySQLConnection: | |
| 13 | + """ | |
| 14 | + 获取数据库连接 | |
| 15 | + | |
| 16 | + Returns: | |
| 17 | + MySQLConnection: MySQL 数据库连接 | |
| 18 | + """ | |
| 19 | + settings = get_settings() | |
| 20 | + return mysql.connector.connect( | |
| 21 | + host=settings.mysql_host, | |
| 22 | + port=settings.mysql_port, | |
| 23 | + user=settings.mysql_user, | |
| 24 | + password=settings.mysql_password, | |
| 25 | + database=settings.mysql_database, | |
| 26 | + ) | |
| 27 | + | |
| 28 | + | |
| 29 | +class DatabaseConnection: | |
| 30 | + """ | |
| 31 | + 数据库连接上下文管理器 | |
| 32 | + | |
| 33 | + 使用示例: | |
| 34 | + with DatabaseConnection() as conn: | |
| 35 | + cursor = conn.cursor(dictionary=True) | |
| 36 | + cursor.execute("SELECT * FROM table") | |
| 37 | + rows = cursor.fetchall() | |
| 38 | + """ | |
| 39 | + | |
| 40 | + def __init__(self): | |
| 41 | + self._conn: MySQLConnection = None | |
| 42 | + | |
| 43 | + def __enter__(self) -> MySQLConnection: | |
| 44 | + self._conn = get_connection() | |
| 45 | + return self._conn | |
| 46 | + | |
| 47 | + def __exit__(self, exc_type, exc_val, exc_tb): | |
| 48 | + if self._conn and self._conn.is_connected(): | |
| 49 | + self._conn.close() | ... | ... |
src/fw_pms_ai/services/repository/__init__.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/services/repository/__init__.py | |
| 1 | +""" | |
| 2 | +Repository 数据访问层子包 | |
| 3 | + | |
| 4 | +提供各表的 CRUD 操作 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from .task_repo import TaskRepository | |
| 8 | +from .detail_repo import DetailRepository | |
| 9 | +from .log_repo import LogRepository, SummaryRepository | |
| 10 | + | |
| 11 | +__all__ = [ | |
| 12 | + "TaskRepository", | |
| 13 | + "DetailRepository", | |
| 14 | + "LogRepository", | |
| 15 | + "SummaryRepository", | |
| 16 | +] | ... | ... |
src/fw_pms_ai/services/repository/detail_repo.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/services/repository/detail_repo.py | |
| 1 | +""" | |
| 2 | +补货明细数据访问层 | |
| 3 | + | |
| 4 | +提供 ai_replenishment_detail 表的 CRUD 操作 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +from typing import List | |
| 9 | + | |
| 10 | +from ..db import get_connection | |
| 11 | +from ...models import ReplenishmentDetail | |
| 12 | + | |
| 13 | +logger = logging.getLogger(__name__) | |
| 14 | + | |
| 15 | + | |
| 16 | +class DetailRepository: | |
| 17 | + """补货明细数据访问""" | |
| 18 | + | |
| 19 | + def __init__(self, connection=None): | |
| 20 | + self._conn = connection | |
| 21 | + | |
| 22 | + def _get_connection(self): | |
| 23 | + """获取数据库连接""" | |
| 24 | + if self._conn is None or not self._conn.is_connected(): | |
| 25 | + self._conn = get_connection() | |
| 26 | + return self._conn | |
| 27 | + | |
| 28 | + def close(self): | |
| 29 | + """关闭连接""" | |
| 30 | + if self._conn and self._conn.is_connected(): | |
| 31 | + self._conn.close() | |
| 32 | + self._conn = None | |
| 33 | + | |
| 34 | + def save_batch(self, details: List[ReplenishmentDetail]) -> int: | |
| 35 | + """ | |
| 36 | + 批量保存补货明细 | |
| 37 | + | |
| 38 | + Returns: | |
| 39 | + 插入的行数 | |
| 40 | + """ | |
| 41 | + if not details: | |
| 42 | + return 0 | |
| 43 | + | |
| 44 | + conn = self._get_connection() | |
| 45 | + cursor = conn.cursor() | |
| 46 | + | |
| 47 | + try: | |
| 48 | + sql = """ | |
| 49 | + INSERT INTO ai_replenishment_detail ( | |
| 50 | + task_no, group_id, dealer_grouping_id, brand_grouping_id, | |
| 51 | + shop_id, shop_name, part_code, part_name, unit, cost_price, | |
| 52 | + current_ratio, base_ratio, post_plan_ratio, | |
| 53 | + valid_storage_cnt, avg_sales_cnt, suggest_cnt, suggest_amount, | |
| 54 | + suggestion_reason, priority, llm_confidence, statistics_date, create_time | |
| 55 | + ) VALUES ( | |
| 56 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, | |
| 57 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() | |
| 58 | + ) | |
| 59 | + """ | |
| 60 | + | |
| 61 | + values = [ | |
| 62 | + ( | |
| 63 | + d.task_no, d.group_id, d.dealer_grouping_id, d.brand_grouping_id, | |
| 64 | + d.shop_id, d.shop_name, d.part_code, d.part_name, d.unit, | |
| 65 | + float(d.cost_price), | |
| 66 | + float(d.current_ratio) if d.current_ratio else None, | |
| 67 | + float(d.base_ratio) if d.base_ratio else None, | |
| 68 | + float(d.post_plan_ratio) if d.post_plan_ratio else None, | |
| 69 | + float(d.valid_storage_cnt), float(d.avg_sales_cnt), | |
| 70 | + d.suggest_cnt, float(d.suggest_amount), | |
| 71 | + d.suggestion_reason, d.priority, d.llm_confidence, d.statistics_date, | |
| 72 | + ) | |
| 73 | + for d in details | |
| 74 | + ] | |
| 75 | + | |
| 76 | + cursor.executemany(sql, values) | |
| 77 | + conn.commit() | |
| 78 | + | |
| 79 | + logger.info(f"保存补货明细: {cursor.rowcount}条") | |
| 80 | + return cursor.rowcount | |
| 81 | + | |
| 82 | + finally: | |
| 83 | + cursor.close() | |
| 84 | + | |
| 85 | + def delete_by_task_no(self, task_no: str) -> int: | |
| 86 | + """ | |
| 87 | + 删除指定任务的补货明细 | |
| 88 | + | |
| 89 | + Returns: | |
| 90 | + 删除的行数 | |
| 91 | + """ | |
| 92 | + conn = self._get_connection() | |
| 93 | + cursor = conn.cursor() | |
| 94 | + | |
| 95 | + try: | |
| 96 | + sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s" | |
| 97 | + cursor.execute(sql, (task_no,)) | |
| 98 | + conn.commit() | |
| 99 | + | |
| 100 | + logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}") | |
| 101 | + return cursor.rowcount | |
| 102 | + | |
| 103 | + finally: | |
| 104 | + cursor.close() | |
| 105 | + | |
| 106 | + def find_by_task_no(self, task_no: str) -> List[ReplenishmentDetail]: | |
| 107 | + """根据 task_no 查询补货明细""" | |
| 108 | + conn = self._get_connection() | |
| 109 | + cursor = conn.cursor(dictionary=True) | |
| 110 | + | |
| 111 | + try: | |
| 112 | + sql = "SELECT * FROM ai_replenishment_detail WHERE task_no = %s" | |
| 113 | + cursor.execute(sql, (task_no,)) | |
| 114 | + rows = cursor.fetchall() | |
| 115 | + | |
| 116 | + return [ReplenishmentDetail(**row) for row in rows] | |
| 117 | + | |
| 118 | + finally: | |
| 119 | + cursor.close() | ... | ... |
src/fw_pms_ai/services/repository/log_repo.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/services/repository/log_repo.py | |
| 1 | +""" | |
| 2 | +日志和汇总数据访问层 | |
| 3 | + | |
| 4 | +提供 ai_task_execution_log 和 ai_replenishment_part_summary 表的 CRUD 操作 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +from typing import List | |
| 9 | + | |
| 10 | +from ..db import get_connection | |
| 11 | +from ...models import TaskExecutionLog, ReplenishmentPartSummary | |
| 12 | + | |
| 13 | +logger = logging.getLogger(__name__) | |
| 14 | + | |
| 15 | + | |
| 16 | +class LogRepository: | |
| 17 | + """执行日志数据访问""" | |
| 18 | + | |
| 19 | + def __init__(self, connection=None): | |
| 20 | + self._conn = connection | |
| 21 | + | |
| 22 | + def _get_connection(self): | |
| 23 | + """获取数据库连接""" | |
| 24 | + if self._conn is None or not self._conn.is_connected(): | |
| 25 | + self._conn = get_connection() | |
| 26 | + return self._conn | |
| 27 | + | |
| 28 | + def close(self): | |
| 29 | + """关闭连接""" | |
| 30 | + if self._conn and self._conn.is_connected(): | |
| 31 | + self._conn.close() | |
| 32 | + self._conn = None | |
| 33 | + | |
| 34 | + def create(self, log: TaskExecutionLog) -> int: | |
| 35 | + """ | |
| 36 | + 保存执行日志 | |
| 37 | + | |
| 38 | + Returns: | |
| 39 | + 插入的日志ID | |
| 40 | + """ | |
| 41 | + conn = self._get_connection() | |
| 42 | + cursor = conn.cursor() | |
| 43 | + | |
| 44 | + try: | |
| 45 | + sql = """ | |
| 46 | + INSERT INTO ai_task_execution_log ( | |
| 47 | + task_no, group_id, dealer_grouping_id, brand_grouping_id, | |
| 48 | + brand_grouping_name, dealer_grouping_name, | |
| 49 | + step_name, step_order, status, input_data, output_data, | |
| 50 | + error_message, retry_count, sql_query, llm_prompt, llm_response, | |
| 51 | + llm_tokens, execution_time_ms, start_time, end_time, create_time | |
| 52 | + ) VALUES ( | |
| 53 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, | |
| 54 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() | |
| 55 | + ) | |
| 56 | + """ | |
| 57 | + | |
| 58 | + values = ( | |
| 59 | + log.task_no, log.group_id, log.dealer_grouping_id, | |
| 60 | + log.brand_grouping_id, log.brand_grouping_name, | |
| 61 | + log.dealer_grouping_name, | |
| 62 | + log.step_name, log.step_order, int(log.status), | |
| 63 | + log.input_data, log.output_data, log.error_message, | |
| 64 | + log.retry_count, log.sql_query, log.llm_prompt, log.llm_response, | |
| 65 | + log.llm_tokens, log.execution_time_ms, | |
| 66 | + log.start_time, log.end_time, | |
| 67 | + ) | |
| 68 | + | |
| 69 | + cursor.execute(sql, values) | |
| 70 | + conn.commit() | |
| 71 | + | |
| 72 | + return cursor.lastrowid | |
| 73 | + | |
| 74 | + finally: | |
| 75 | + cursor.close() | |
| 76 | + | |
| 77 | + | |
| 78 | +class SummaryRepository: | |
| 79 | + """配件汇总数据访问""" | |
| 80 | + | |
| 81 | + def __init__(self, connection=None): | |
| 82 | + self._conn = connection | |
| 83 | + | |
| 84 | + def _get_connection(self): | |
| 85 | + """获取数据库连接""" | |
| 86 | + if self._conn is None or not self._conn.is_connected(): | |
| 87 | + self._conn = get_connection() | |
| 88 | + return self._conn | |
| 89 | + | |
| 90 | + def close(self): | |
| 91 | + """关闭连接""" | |
| 92 | + if self._conn and self._conn.is_connected(): | |
| 93 | + self._conn.close() | |
| 94 | + self._conn = None | |
| 95 | + | |
| 96 | + def save_batch(self, summaries: List[ReplenishmentPartSummary]) -> int: | |
| 97 | + """ | |
| 98 | + 批量保存配件汇总 | |
| 99 | + | |
| 100 | + Returns: | |
| 101 | + 插入的行数 | |
| 102 | + """ | |
| 103 | + if not summaries: | |
| 104 | + return 0 | |
| 105 | + | |
| 106 | + conn = self._get_connection() | |
| 107 | + cursor = conn.cursor() | |
| 108 | + | |
| 109 | + try: | |
| 110 | + sql = """ | |
| 111 | + INSERT INTO ai_replenishment_part_summary ( | |
| 112 | + task_no, group_id, dealer_grouping_id, part_code, part_name, | |
| 113 | + unit, cost_price, total_storage_cnt, total_avg_sales_cnt, | |
| 114 | + group_current_ratio, total_suggest_cnt, total_suggest_amount, | |
| 115 | + shop_count, need_replenishment_shop_count, part_decision_reason, | |
| 116 | + priority, llm_confidence, statistics_date, create_time | |
| 117 | + ) VALUES ( | |
| 118 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, | |
| 119 | + %s, %s, %s, %s, %s, %s, %s, %s, NOW() | |
| 120 | + ) | |
| 121 | + """ | |
| 122 | + | |
| 123 | + values = [ | |
| 124 | + ( | |
| 125 | + s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name, | |
| 126 | + s.unit, float(s.cost_price), float(s.total_storage_cnt), | |
| 127 | + float(s.total_avg_sales_cnt), | |
| 128 | + float(s.group_current_ratio) if s.group_current_ratio else None, | |
| 129 | + s.total_suggest_cnt, float(s.total_suggest_amount), | |
| 130 | + s.shop_count, s.need_replenishment_shop_count, s.part_decision_reason, | |
| 131 | + s.priority, s.llm_confidence, s.statistics_date, | |
| 132 | + ) | |
| 133 | + for s in summaries | |
| 134 | + ] | |
| 135 | + | |
| 136 | + cursor.executemany(sql, values) | |
| 137 | + conn.commit() | |
| 138 | + | |
| 139 | + logger.info(f"保存配件汇总: {cursor.rowcount}条") | |
| 140 | + return cursor.rowcount | |
| 141 | + | |
| 142 | + finally: | |
| 143 | + cursor.close() | |
| 144 | + | |
| 145 | + def delete_by_task_no(self, task_no: str) -> int: | |
| 146 | + """ | |
| 147 | + 删除指定任务的配件汇总 | |
| 148 | + | |
| 149 | + Returns: | |
| 150 | + 删除的行数 | |
| 151 | + """ | |
| 152 | + conn = self._get_connection() | |
| 153 | + cursor = conn.cursor() | |
| 154 | + | |
| 155 | + try: | |
| 156 | + sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s" | |
| 157 | + cursor.execute(sql, (task_no,)) | |
| 158 | + conn.commit() | |
| 159 | + | |
| 160 | + logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}") | |
| 161 | + return cursor.rowcount | |
| 162 | + | |
| 163 | + finally: | |
| 164 | + cursor.close() | ... | ... |
src/fw_pms_ai/services/repository/task_repo.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/services/repository/task_repo.py | |
| 1 | +""" | |
| 2 | +任务数据访问层 | |
| 3 | + | |
| 4 | +提供 ai_replenishment_task 表的 CRUD 操作 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +from datetime import datetime | |
| 9 | +from typing import Optional | |
| 10 | + | |
| 11 | +from ..db import get_connection | |
| 12 | +from ...models import ReplenishmentTask | |
| 13 | + | |
| 14 | +logger = logging.getLogger(__name__) | |
| 15 | + | |
| 16 | + | |
| 17 | +class TaskRepository: | |
| 18 | + """任务数据访问""" | |
| 19 | + | |
| 20 | + def __init__(self, connection=None): | |
| 21 | + self._conn = connection | |
| 22 | + | |
| 23 | + def _get_connection(self): | |
| 24 | + """获取数据库连接""" | |
| 25 | + if self._conn is None or not self._conn.is_connected(): | |
| 26 | + self._conn = get_connection() | |
| 27 | + return self._conn | |
| 28 | + | |
| 29 | + def close(self): | |
| 30 | + """关闭连接""" | |
| 31 | + if self._conn and self._conn.is_connected(): | |
| 32 | + self._conn.close() | |
| 33 | + self._conn = None | |
| 34 | + | |
| 35 | + def create(self, task: ReplenishmentTask) -> int: | |
| 36 | + """ | |
| 37 | + 创建任务记录 | |
| 38 | + | |
| 39 | + Returns: | |
| 40 | + 插入的任务ID | |
| 41 | + """ | |
| 42 | + conn = self._get_connection() | |
| 43 | + cursor = conn.cursor() | |
| 44 | + | |
| 45 | + try: | |
| 46 | + sql = """ | |
| 47 | + INSERT INTO ai_replenishment_task ( | |
| 48 | + task_no, group_id, dealer_grouping_id, dealer_grouping_name, | |
| 49 | + brand_grouping_id, plan_amount, actual_amount, part_count, | |
| 50 | + base_ratio, status, error_message, llm_provider, llm_model, | |
| 51 | + llm_total_tokens, statistics_date, start_time, end_time, create_time | |
| 52 | + ) VALUES ( | |
| 53 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() | |
| 54 | + ) | |
| 55 | + """ | |
| 56 | + | |
| 57 | + values = ( | |
| 58 | + task.task_no, | |
| 59 | + task.group_id, | |
| 60 | + task.dealer_grouping_id, | |
| 61 | + task.dealer_grouping_name, | |
| 62 | + task.brand_grouping_id, | |
| 63 | + float(task.plan_amount), | |
| 64 | + float(task.actual_amount), | |
| 65 | + task.part_count, | |
| 66 | + float(task.base_ratio) if task.base_ratio else None, | |
| 67 | + int(task.status), | |
| 68 | + task.error_message, | |
| 69 | + task.llm_provider, | |
| 70 | + task.llm_model, | |
| 71 | + task.llm_total_tokens, | |
| 72 | + task.statistics_date, | |
| 73 | + datetime.now() if task.start_time is None else task.start_time, | |
| 74 | + task.end_time, | |
| 75 | + ) | |
| 76 | + | |
| 77 | + cursor.execute(sql, values) | |
| 78 | + conn.commit() | |
| 79 | + | |
| 80 | + task_id = cursor.lastrowid | |
| 81 | + logger.info(f"创建任务记录: task_no={task.task_no}, id={task_id}") | |
| 82 | + return task_id | |
| 83 | + | |
| 84 | + finally: | |
| 85 | + cursor.close() | |
| 86 | + | |
| 87 | + def update(self, task: ReplenishmentTask) -> int: | |
| 88 | + """ | |
| 89 | + 更新任务记录 | |
| 90 | + | |
| 91 | + Returns: | |
| 92 | + 更新的行数 | |
| 93 | + """ | |
| 94 | + conn = self._get_connection() | |
| 95 | + cursor = conn.cursor() | |
| 96 | + | |
| 97 | + try: | |
| 98 | + sql = """ | |
| 99 | + UPDATE ai_replenishment_task | |
| 100 | + SET actual_amount = %s, | |
| 101 | + part_count = %s, | |
| 102 | + base_ratio = %s, | |
| 103 | + status = %s, | |
| 104 | + error_message = %s, | |
| 105 | + llm_provider = %s, | |
| 106 | + llm_model = %s, | |
| 107 | + llm_total_tokens = %s, | |
| 108 | + end_time = %s | |
| 109 | + WHERE task_no = %s | |
| 110 | + """ | |
| 111 | + | |
| 112 | + values = ( | |
| 113 | + float(task.actual_amount), | |
| 114 | + task.part_count, | |
| 115 | + float(task.base_ratio) if task.base_ratio else None, | |
| 116 | + int(task.status), | |
| 117 | + task.error_message, | |
| 118 | + task.llm_provider, | |
| 119 | + task.llm_model, | |
| 120 | + task.llm_total_tokens, | |
| 121 | + datetime.now() if task.end_time is None else task.end_time, | |
| 122 | + task.task_no, | |
| 123 | + ) | |
| 124 | + | |
| 125 | + cursor.execute(sql, values) | |
| 126 | + conn.commit() | |
| 127 | + | |
| 128 | + logger.info(f"更新任务记录: task_no={task.task_no}, rows={cursor.rowcount}") | |
| 129 | + return cursor.rowcount | |
| 130 | + | |
| 131 | + finally: | |
| 132 | + cursor.close() | |
| 133 | + | |
| 134 | + def find_by_task_no(self, task_no: str) -> Optional[ReplenishmentTask]: | |
| 135 | + """根据 task_no 查询任务""" | |
| 136 | + conn = self._get_connection() | |
| 137 | + cursor = conn.cursor(dictionary=True) | |
| 138 | + | |
| 139 | + try: | |
| 140 | + sql = "SELECT * FROM ai_replenishment_task WHERE task_no = %s" | |
| 141 | + cursor.execute(sql, (task_no,)) | |
| 142 | + row = cursor.fetchone() | |
| 143 | + | |
| 144 | + if row: | |
| 145 | + return ReplenishmentTask(**row) | |
| 146 | + return None | |
| 147 | + | |
| 148 | + finally: | |
| 149 | + cursor.close() | ... | ... |
src/fw_pms_ai/services/result_writer.py
0 → 100644
| 1 | +++ a/src/fw_pms_ai/services/result_writer.py | |
| 1 | +""" | |
| 2 | +结果写入服务 | |
| 3 | +负责将补货建议结果写入数据库 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +import json | |
| 8 | +from typing import List, Optional | |
| 9 | +from datetime import datetime | |
| 10 | + | |
| 11 | +from .db import get_connection | |
| 12 | +from ..models import ( | |
| 13 | + ReplenishmentTask, | |
| 14 | + ReplenishmentDetail, | |
| 15 | + TaskExecutionLog, | |
| 16 | + ReplenishmentPartSummary, | |
| 17 | +) | |
| 18 | + | |
| 19 | +logger = logging.getLogger(__name__) | |
| 20 | + | |
| 21 | + | |
| 22 | +class ResultWriter: | |
| 23 | + """结果写入服务""" | |
| 24 | + | |
| 25 | + def __init__(self): | |
| 26 | + self._conn = None | |
| 27 | + | |
| 28 | + def _get_connection(self): | |
| 29 | + """获取数据库连接""" | |
| 30 | + if self._conn is None or not self._conn.is_connected(): | |
| 31 | + self._conn = get_connection() | |
| 32 | + return self._conn | |
| 33 | + | |
| 34 | + def close(self): | |
| 35 | + """关闭连接""" | |
| 36 | + if self._conn and self._conn.is_connected(): | |
| 37 | + self._conn.close() | |
| 38 | + self._conn = None | |
| 39 | + | |
| 40 | + def save_task(self, task: ReplenishmentTask) -> int: | |
| 41 | + """ | |
| 42 | + 保存任务记录 | |
| 43 | + | |
| 44 | + Returns: | |
| 45 | + 插入的任务ID | |
| 46 | + """ | |
| 47 | + conn = self._get_connection() | |
| 48 | + cursor = conn.cursor() | |
| 49 | + | |
| 50 | + try: | |
| 51 | + sql = """ | |
| 52 | + INSERT INTO ai_replenishment_task ( | |
| 53 | + task_no, group_id, dealer_grouping_id, dealer_grouping_name, | |
| 54 | + brand_grouping_id, plan_amount, actual_amount, part_count, | |
| 55 | + base_ratio, status, error_message, llm_provider, llm_model, | |
| 56 | + llm_total_tokens, statistics_date, start_time, end_time, create_time | |
| 57 | + ) VALUES ( | |
| 58 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() | |
| 59 | + ) | |
| 60 | + """ | |
| 61 | + | |
| 62 | + values = ( | |
| 63 | + task.task_no, | |
| 64 | + task.group_id, | |
| 65 | + task.dealer_grouping_id, | |
| 66 | + task.dealer_grouping_name, | |
| 67 | + task.brand_grouping_id, | |
| 68 | + float(task.plan_amount), | |
| 69 | + float(task.actual_amount), | |
| 70 | + task.part_count, | |
| 71 | + float(task.base_ratio) if task.base_ratio else None, | |
| 72 | + int(task.status), | |
| 73 | + task.error_message, | |
| 74 | + task.llm_provider, | |
| 75 | + task.llm_model, | |
| 76 | + task.llm_total_tokens, | |
| 77 | + task.statistics_date, | |
| 78 | + datetime.now() if task.start_time is None else task.start_time, | |
| 79 | + task.end_time, | |
| 80 | + ) | |
| 81 | + | |
| 82 | + cursor.execute(sql, values) | |
| 83 | + conn.commit() | |
| 84 | + | |
| 85 | + task_id = cursor.lastrowid | |
| 86 | + logger.info(f"保存任务记录: task_no={task.task_no}, id={task_id}") | |
| 87 | + return task_id | |
| 88 | + | |
| 89 | + finally: | |
| 90 | + cursor.close() | |
| 91 | + | |
| 92 | + def update_task(self, task: ReplenishmentTask) -> int: | |
| 93 | + """ | |
| 94 | + 更新任务记录 | |
| 95 | + | |
| 96 | + Returns: | |
| 97 | + 更新的行数 | |
| 98 | + """ | |
| 99 | + conn = self._get_connection() | |
| 100 | + cursor = conn.cursor() | |
| 101 | + | |
| 102 | + try: | |
| 103 | + sql = """ | |
| 104 | + UPDATE ai_replenishment_task | |
| 105 | + SET actual_amount = %s, | |
| 106 | + part_count = %s, | |
| 107 | + base_ratio = %s, | |
| 108 | + status = %s, | |
| 109 | + error_message = %s, | |
| 110 | + llm_provider = %s, | |
| 111 | + llm_model = %s, | |
| 112 | + llm_total_tokens = %s, | |
| 113 | + end_time = %s | |
| 114 | + WHERE task_no = %s | |
| 115 | + """ | |
| 116 | + | |
| 117 | + values = ( | |
| 118 | + float(task.actual_amount), | |
| 119 | + task.part_count, | |
| 120 | + float(task.base_ratio) if task.base_ratio else None, | |
| 121 | + int(task.status), | |
| 122 | + task.error_message, | |
| 123 | + task.llm_provider, | |
| 124 | + task.llm_model, | |
| 125 | + task.llm_total_tokens, | |
| 126 | + datetime.now() if task.end_time is None else task.end_time, | |
| 127 | + task.task_no, | |
| 128 | + ) | |
| 129 | + | |
| 130 | + cursor.execute(sql, values) | |
| 131 | + conn.commit() | |
| 132 | + | |
| 133 | + logger.info(f"更新任务记录: task_no={task.task_no}, rows={cursor.rowcount}") | |
| 134 | + return cursor.rowcount | |
| 135 | + | |
| 136 | + finally: | |
| 137 | + cursor.close() | |
| 138 | + | |
| 139 | + def save_details(self, details: List[ReplenishmentDetail]) -> int: | |
| 140 | + """ | |
| 141 | + 保存补货明细 | |
| 142 | + | |
| 143 | + Returns: | |
| 144 | + 插入的行数 | |
| 145 | + """ | |
| 146 | + if not details: | |
| 147 | + return 0 | |
| 148 | + | |
| 149 | + conn = self._get_connection() | |
| 150 | + cursor = conn.cursor() | |
| 151 | + | |
| 152 | + try: | |
| 153 | + sql = """ | |
| 154 | + INSERT INTO ai_replenishment_detail ( | |
| 155 | + task_no, group_id, dealer_grouping_id, brand_grouping_id, | |
| 156 | + shop_id, shop_name, part_code, part_name, unit, cost_price, | |
| 157 | + current_ratio, base_ratio, post_plan_ratio, | |
| 158 | + valid_storage_cnt, avg_sales_cnt, suggest_cnt, suggest_amount, | |
| 159 | + suggestion_reason, priority, llm_confidence, statistics_date, create_time | |
| 160 | + ) VALUES ( | |
| 161 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, | |
| 162 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() | |
| 163 | + ) | |
| 164 | + """ | |
| 165 | + | |
| 166 | + values = [ | |
| 167 | + ( | |
| 168 | + d.task_no, d.group_id, d.dealer_grouping_id, d.brand_grouping_id, | |
| 169 | + d.shop_id, d.shop_name, d.part_code, d.part_name, d.unit, | |
| 170 | + float(d.cost_price), | |
| 171 | + float(d.current_ratio) if d.current_ratio else None, | |
| 172 | + float(d.base_ratio) if d.base_ratio else None, | |
| 173 | + float(d.post_plan_ratio) if d.post_plan_ratio else None, | |
| 174 | + float(d.valid_storage_cnt), float(d.avg_sales_cnt), | |
| 175 | + d.suggest_cnt, float(d.suggest_amount), | |
| 176 | + d.suggestion_reason, d.priority, d.llm_confidence, d.statistics_date, | |
| 177 | + ) | |
| 178 | + for d in details | |
| 179 | + ] | |
| 180 | + | |
| 181 | + cursor.executemany(sql, values) | |
| 182 | + conn.commit() | |
| 183 | + | |
| 184 | + logger.info(f"保存补货明细: {cursor.rowcount}条") | |
| 185 | + return cursor.rowcount | |
| 186 | + | |
| 187 | + finally: | |
| 188 | + cursor.close() | |
| 189 | + | |
| 190 | + def save_execution_log(self, log: TaskExecutionLog) -> int: | |
| 191 | + """ | |
| 192 | + 保存执行日志 | |
| 193 | + | |
| 194 | + Returns: | |
| 195 | + 插入的日志ID | |
| 196 | + """ | |
| 197 | + conn = self._get_connection() | |
| 198 | + cursor = conn.cursor() | |
| 199 | + | |
| 200 | + try: | |
| 201 | + sql = """ | |
| 202 | + INSERT INTO ai_task_execution_log ( | |
| 203 | + task_no, group_id, dealer_grouping_id, brand_grouping_id, | |
| 204 | + brand_grouping_name, dealer_grouping_name, | |
| 205 | + step_name, step_order, status, input_data, output_data, | |
| 206 | + error_message, retry_count, sql_query, llm_prompt, llm_response, | |
| 207 | + llm_tokens, execution_time_ms, start_time, end_time, create_time | |
| 208 | + ) VALUES ( | |
| 209 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, | |
| 210 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() | |
| 211 | + ) | |
| 212 | + """ | |
| 213 | + | |
| 214 | + values = ( | |
| 215 | + log.task_no, log.group_id, log.dealer_grouping_id, | |
| 216 | + log.brand_grouping_id, log.brand_grouping_name, | |
| 217 | + log.dealer_grouping_name, | |
| 218 | + log.step_name, log.step_order, int(log.status), | |
| 219 | + log.input_data, log.output_data, log.error_message, | |
| 220 | + log.retry_count, log.sql_query, log.llm_prompt, log.llm_response, | |
| 221 | + log.llm_tokens, log.execution_time_ms, | |
| 222 | + log.start_time, log.end_time, | |
| 223 | + ) | |
| 224 | + | |
| 225 | + cursor.execute(sql, values) | |
| 226 | + conn.commit() | |
| 227 | + | |
| 228 | + log_id = cursor.lastrowid | |
| 229 | + return log_id | |
| 230 | + | |
| 231 | + finally: | |
| 232 | + cursor.close() | |
| 233 | + | |
| 234 | + def save_part_summaries(self, summaries: List[ReplenishmentPartSummary]) -> int: | |
| 235 | + """ | |
| 236 | + 保存配件汇总信息 | |
| 237 | + | |
| 238 | + Returns: | |
| 239 | + 插入的行数 | |
| 240 | + """ | |
| 241 | + if not summaries: | |
| 242 | + return 0 | |
| 243 | + | |
| 244 | + conn = self._get_connection() | |
| 245 | + cursor = conn.cursor() | |
| 246 | + | |
| 247 | + try: | |
| 248 | + sql = """ | |
| 249 | + INSERT INTO ai_replenishment_part_summary ( | |
| 250 | + task_no, group_id, dealer_grouping_id, part_code, part_name, | |
| 251 | + unit, cost_price, total_storage_cnt, total_avg_sales_cnt, | |
| 252 | + group_current_ratio, total_suggest_cnt, total_suggest_amount, | |
| 253 | + shop_count, need_replenishment_shop_count, part_decision_reason, | |
| 254 | + priority, llm_confidence, statistics_date, create_time | |
| 255 | + ) VALUES ( | |
| 256 | + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, | |
| 257 | + %s, %s, %s, %s, %s, %s, %s, %s, NOW() | |
| 258 | + ) | |
| 259 | + """ | |
| 260 | + | |
| 261 | + values = [ | |
| 262 | + ( | |
| 263 | + s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name, | |
| 264 | + s.unit, float(s.cost_price), float(s.total_storage_cnt), | |
| 265 | + float(s.total_avg_sales_cnt), | |
| 266 | + float(s.group_current_ratio) if s.group_current_ratio else None, | |
| 267 | + s.total_suggest_cnt, float(s.total_suggest_amount), | |
| 268 | + s.shop_count, s.need_replenishment_shop_count, s.part_decision_reason, | |
| 269 | + s.priority, s.llm_confidence, s.statistics_date, | |
| 270 | + ) | |
| 271 | + for s in summaries | |
| 272 | + ] | |
| 273 | + | |
| 274 | + cursor.executemany(sql, values) | |
| 275 | + conn.commit() | |
| 276 | + | |
| 277 | + logger.info(f"保存配件汇总: {cursor.rowcount}条") | |
| 278 | + return cursor.rowcount | |
| 279 | + | |
| 280 | + finally: | |
| 281 | + cursor.close() | |
| 282 | + | |
| 283 | + def delete_part_summaries_by_task(self, task_no: str) -> int: | |
| 284 | + """ | |
| 285 | + 删除指定任务的配件汇总 | |
| 286 | + | |
| 287 | + Returns: | |
| 288 | + 删除的行数 | |
| 289 | + """ | |
| 290 | + conn = self._get_connection() | |
| 291 | + cursor = conn.cursor() | |
| 292 | + | |
| 293 | + try: | |
| 294 | + sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s" | |
| 295 | + cursor.execute(sql, (task_no,)) | |
| 296 | + conn.commit() | |
| 297 | + | |
| 298 | + logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}") | |
| 299 | + return cursor.rowcount | |
| 300 | + | |
| 301 | + finally: | |
| 302 | + cursor.close() | |
| 303 | + | |
| 304 | + def delete_details_by_task(self, task_no: str) -> int: | |
| 305 | + """ | |
| 306 | + 删除指定任务的补货明细 | |
| 307 | + | |
| 308 | + Returns: | |
| 309 | + 删除的行数 | |
| 310 | + """ | |
| 311 | + conn = self._get_connection() | |
| 312 | + cursor = conn.cursor() | |
| 313 | + | |
| 314 | + try: | |
| 315 | + sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s" | |
| 316 | + cursor.execute(sql, (task_no,)) | |
| 317 | + conn.commit() | |
| 318 | + | |
| 319 | + logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}") | |
| 320 | + return cursor.rowcount | |
| 321 | + | |
| 322 | + finally: | |
| 323 | + cursor.close() | |
| 324 | + | ... | ... |
ui/css/style.css
0 → 100644
| 1 | +++ a/ui/css/style.css | |
| 1 | +/** | |
| 2 | + * AI 补货建议系统 - 核心样式 | |
| 3 | + * 设计理念: 深色主题 + 玻璃拟态 + 现代专业风格 | |
| 4 | + */ | |
| 5 | + | |
| 6 | +/* ===================== | |
| 7 | + CSS Variables | |
| 8 | + ===================== */ | |
| 9 | +:root { | |
| 10 | + /* 主色调 */ | |
| 11 | + --color-primary: #6366f1; | |
| 12 | + --color-primary-light: #818cf8; | |
| 13 | + --color-primary-dark: #4f46e5; | |
| 14 | + | |
| 15 | + /* 功能色 */ | |
| 16 | + --color-success: #10b981; | |
| 17 | + --color-success-light: #34d399; | |
| 18 | + --color-warning: #f59e0b; | |
| 19 | + --color-warning-light: #fbbf24; | |
| 20 | + --color-danger: #ef4444; | |
| 21 | + --color-danger-light: #f87171; | |
| 22 | + --color-info: #3b82f6; | |
| 23 | + --color-info-light: #60a5fa; | |
| 24 | + | |
| 25 | + /* 深色主题背景 */ | |
| 26 | + --bg-base: #0f172a; | |
| 27 | + --bg-surface: #1e293b; | |
| 28 | + --bg-elevated: #334155; | |
| 29 | + --bg-hover: #475569; | |
| 30 | + | |
| 31 | + /* 文字颜色 */ | |
| 32 | + --text-primary: #f8fafc; | |
| 33 | + --text-secondary: #94a3b8; | |
| 34 | + --text-muted: #64748b; | |
| 35 | + --text-disabled: #475569; | |
| 36 | + | |
| 37 | + /* 边框 */ | |
| 38 | + --border-color: rgba(148, 163, 184, 0.1); | |
| 39 | + --border-color-light: rgba(148, 163, 184, 0.2); | |
| 40 | + | |
| 41 | + /* 阴影 */ | |
| 42 | + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); | |
| 43 | + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); | |
| 44 | + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3); | |
| 45 | + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4); | |
| 46 | + | |
| 47 | + /* 玻璃效果 */ | |
| 48 | + --glass-bg: rgba(30, 41, 59, 0.8); | |
| 49 | + --glass-border: rgba(148, 163, 184, 0.15); | |
| 50 | + --glass-blur: 12px; | |
| 51 | + | |
| 52 | + /* 间距 */ | |
| 53 | + --spacing-xs: 4px; | |
| 54 | + --spacing-sm: 8px; | |
| 55 | + --spacing-md: 16px; | |
| 56 | + --spacing-lg: 24px; | |
| 57 | + --spacing-xl: 32px; | |
| 58 | + --spacing-2xl: 48px; | |
| 59 | + | |
| 60 | + /* 圆角 */ | |
| 61 | + --radius-sm: 6px; | |
| 62 | + --radius-md: 10px; | |
| 63 | + --radius-lg: 16px; | |
| 64 | + --radius-xl: 24px; | |
| 65 | + --radius-full: 9999px; | |
| 66 | + | |
| 67 | + /* 字体 */ | |
| 68 | + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| 69 | + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; | |
| 70 | + | |
| 71 | + /* 过渡 */ | |
| 72 | + --transition-fast: 150ms ease; | |
| 73 | + --transition-base: 250ms ease; | |
| 74 | + --transition-slow: 400ms ease; | |
| 75 | + | |
| 76 | + /* 布局 */ | |
| 77 | + --sidebar-width: 260px; | |
| 78 | + --sidebar-width-collapsed: 64px; | |
| 79 | + --topbar-height: 64px; | |
| 80 | +} | |
| 81 | + | |
| 82 | +/* ===================== | |
| 83 | + Reset & Base | |
| 84 | + ===================== */ | |
| 85 | +*, *::before, *::after { | |
| 86 | + box-sizing: border-box; | |
| 87 | + margin: 0; | |
| 88 | + padding: 0; | |
| 89 | +} | |
| 90 | + | |
| 91 | +html { | |
| 92 | + font-size: 14px; | |
| 93 | + scroll-behavior: smooth; | |
| 94 | +} | |
| 95 | + | |
| 96 | +body { | |
| 97 | + font-family: var(--font-sans); | |
| 98 | + background-color: var(--bg-base); | |
| 99 | + color: var(--text-primary); | |
| 100 | + line-height: 1.6; | |
| 101 | + min-height: 100vh; | |
| 102 | + display: flex; | |
| 103 | + overflow-x: hidden; | |
| 104 | +} | |
| 105 | + | |
| 106 | +a { | |
| 107 | + color: inherit; | |
| 108 | + text-decoration: none; | |
| 109 | +} | |
| 110 | + | |
| 111 | +button { | |
| 112 | + font-family: inherit; | |
| 113 | + cursor: pointer; | |
| 114 | + border: none; | |
| 115 | + background: none; | |
| 116 | +} | |
| 117 | + | |
| 118 | +table { | |
| 119 | + border-collapse: collapse; | |
| 120 | + width: 100%; | |
| 121 | +} | |
| 122 | + | |
| 123 | +/* ===================== | |
| 124 | + Scrollbar | |
| 125 | + ===================== */ | |
| 126 | +::-webkit-scrollbar { | |
| 127 | + width: 8px; | |
| 128 | + height: 8px; | |
| 129 | +} | |
| 130 | + | |
| 131 | +::-webkit-scrollbar-track { | |
| 132 | + background: var(--bg-surface); | |
| 133 | +} | |
| 134 | + | |
| 135 | +::-webkit-scrollbar-thumb { | |
| 136 | + background: var(--bg-elevated); | |
| 137 | + border-radius: var(--radius-full); | |
| 138 | +} | |
| 139 | + | |
| 140 | +::-webkit-scrollbar-thumb:hover { | |
| 141 | + background: var(--bg-hover); | |
| 142 | +} | |
| 143 | + | |
| 144 | +/* ===================== | |
| 145 | + Sidebar | |
| 146 | + ===================== */ | |
| 147 | +.sidebar { | |
| 148 | + width: var(--sidebar-width); | |
| 149 | + height: 100vh; | |
| 150 | + background: var(--glass-bg); | |
| 151 | + backdrop-filter: blur(var(--glass-blur)); | |
| 152 | + border-right: 1px solid var(--glass-border); | |
| 153 | + display: flex; | |
| 154 | + flex-direction: column; | |
| 155 | + position: fixed; | |
| 156 | + left: 0; | |
| 157 | + top: 0; | |
| 158 | + z-index: 100; | |
| 159 | + transition: transform var(--transition-base); | |
| 160 | +} | |
| 161 | + | |
| 162 | +.sidebar.visible { | |
| 163 | + transform: translateX(0); | |
| 164 | +} | |
| 165 | + | |
| 166 | +.sidebar-overlay { | |
| 167 | + position: fixed; | |
| 168 | + inset: 0; | |
| 169 | + background: rgba(15, 23, 42, 0.5); | |
| 170 | + backdrop-filter: blur(2px); | |
| 171 | + z-index: 90; | |
| 172 | + opacity: 0; | |
| 173 | + visibility: hidden; | |
| 174 | + transition: all var(--transition-base); | |
| 175 | +} | |
| 176 | + | |
| 177 | +.sidebar-overlay.active { | |
| 178 | + opacity: 1; | |
| 179 | + visibility: visible; | |
| 180 | +} | |
| 181 | + | |
| 182 | +.sidebar-header { | |
| 183 | + padding: var(--spacing-lg); | |
| 184 | + border-bottom: 1px solid var(--border-color); | |
| 185 | + display: flex; | |
| 186 | + align-items: center; | |
| 187 | + justify-content: space-between; | |
| 188 | +} | |
| 189 | + | |
| 190 | +.sidebar-collapse-btn { | |
| 191 | + color: var(--text-secondary); | |
| 192 | + transition: transform var(--transition-base); | |
| 193 | +} | |
| 194 | + | |
| 195 | +.sidebar-collapse-btn:hover { | |
| 196 | + color: var(--text-primary); | |
| 197 | + background: var(--bg-elevated); | |
| 198 | +} | |
| 199 | + | |
| 200 | +/* Collapsed State */ | |
| 201 | +.sidebar.collapsed { | |
| 202 | + width: var(--sidebar-width-collapsed); | |
| 203 | +} | |
| 204 | + | |
| 205 | +.sidebar.collapsed .logo-text, | |
| 206 | +.sidebar.collapsed .version-info, | |
| 207 | +.sidebar.collapsed .nav-item span { | |
| 208 | + display: none; | |
| 209 | +} | |
| 210 | + | |
| 211 | +.sidebar.collapsed .sidebar-header { | |
| 212 | + padding: var(--spacing-lg) var(--spacing-sm); | |
| 213 | + justify-content: center; | |
| 214 | +} | |
| 215 | + | |
| 216 | +.sidebar.collapsed .logo { | |
| 217 | + display: none; | |
| 218 | +} | |
| 219 | + | |
| 220 | +.sidebar.collapsed .sidebar-collapse-btn { | |
| 221 | + transform: rotate(180deg); | |
| 222 | +} | |
| 223 | + | |
| 224 | +.sidebar.collapsed .sidebar-nav { | |
| 225 | + padding: var(--spacing-md) var(--spacing-xs); | |
| 226 | + align-items: center; | |
| 227 | +} | |
| 228 | + | |
| 229 | +.sidebar.collapsed .nav-item { | |
| 230 | + justify-content: center; | |
| 231 | + padding: var(--spacing-md); | |
| 232 | + width: 40px; | |
| 233 | + height: 40px; | |
| 234 | +} | |
| 235 | + | |
| 236 | +.sidebar.collapsed .nav-item svg { | |
| 237 | + margin: 0; | |
| 238 | +} | |
| 239 | + | |
| 240 | +.sidebar.collapsed + .sidebar-overlay + .main-content { | |
| 241 | + margin-left: var(--sidebar-width-collapsed); | |
| 242 | +} | |
| 243 | + | |
| 244 | +.logo { | |
| 245 | + display: flex; | |
| 246 | + align-items: center; | |
| 247 | + gap: var(--spacing-md); | |
| 248 | +} | |
| 249 | + | |
| 250 | +.logo-icon { | |
| 251 | + width: 32px; | |
| 252 | + height: 32px; | |
| 253 | + color: var(--color-primary); | |
| 254 | +} | |
| 255 | + | |
| 256 | +.logo-text { | |
| 257 | + font-size: 1.25rem; | |
| 258 | + font-weight: 600; | |
| 259 | + background: linear-gradient(135deg, var(--color-primary-light), var(--color-primary)); | |
| 260 | + -webkit-background-clip: text; | |
| 261 | + -webkit-text-fill-color: transparent; | |
| 262 | + background-clip: text; | |
| 263 | +} | |
| 264 | + | |
| 265 | +.sidebar-nav { | |
| 266 | + flex: 1; | |
| 267 | + padding: var(--spacing-md); | |
| 268 | + display: flex; | |
| 269 | + flex-direction: column; | |
| 270 | + gap: var(--spacing-xs); | |
| 271 | +} | |
| 272 | + | |
| 273 | +.nav-item { | |
| 274 | + display: flex; | |
| 275 | + align-items: center; | |
| 276 | + gap: var(--spacing-md); | |
| 277 | + padding: var(--spacing-md) var(--spacing-lg); | |
| 278 | + border-radius: var(--radius-md); | |
| 279 | + color: var(--text-secondary); | |
| 280 | + transition: all var(--transition-fast); | |
| 281 | +} | |
| 282 | + | |
| 283 | +.nav-item:hover { | |
| 284 | + background: var(--bg-elevated); | |
| 285 | + color: var(--text-primary); | |
| 286 | +} | |
| 287 | + | |
| 288 | +.nav-item.active { | |
| 289 | + background: linear-gradient(135deg, var(--color-primary-dark), var(--color-primary)); | |
| 290 | + color: white; | |
| 291 | + box-shadow: var(--shadow-md); | |
| 292 | +} | |
| 293 | + | |
| 294 | +.nav-item svg { | |
| 295 | + width: 20px; | |
| 296 | + height: 20px; | |
| 297 | +} | |
| 298 | + | |
| 299 | +.sidebar-footer { | |
| 300 | + padding: var(--spacing-md) var(--spacing-lg); | |
| 301 | + border-top: 1px solid var(--border-color); | |
| 302 | +} | |
| 303 | + | |
| 304 | +.version-info { | |
| 305 | + font-size: 0.75rem; | |
| 306 | + color: var(--text-muted); | |
| 307 | + text-align: center; | |
| 308 | +} | |
| 309 | + | |
| 310 | +/* ===================== | |
| 311 | + Main Content | |
| 312 | + ===================== */ | |
| 313 | +.main-content { | |
| 314 | + flex: 1; | |
| 315 | + margin-left: var(--sidebar-width); | |
| 316 | + min-height: 100vh; | |
| 317 | + display: flex; | |
| 318 | + flex-direction: column; | |
| 319 | + transition: margin-left var(--transition-base); | |
| 320 | +} | |
| 321 | + | |
| 322 | +.top-bar { | |
| 323 | + height: var(--topbar-height); | |
| 324 | + background: var(--glass-bg); | |
| 325 | + backdrop-filter: blur(var(--glass-blur)); | |
| 326 | + border-bottom: 1px solid var(--glass-border); | |
| 327 | + display: flex; | |
| 328 | + align-items: center; | |
| 329 | + justify-content: space-between; | |
| 330 | + padding: 0 var(--spacing-xl); | |
| 331 | + top: 0; | |
| 332 | + z-index: 50; | |
| 333 | + gap: var(--spacing-sm); | |
| 334 | +} | |
| 335 | + | |
| 336 | +.menu-toggle { | |
| 337 | + display: none; | |
| 338 | + margin-right: var(--spacing-sm); | |
| 339 | +} | |
| 340 | + | |
| 341 | +@media (max-width: 1024px) { | |
| 342 | + .menu-toggle { | |
| 343 | + display: inline-flex; | |
| 344 | + } | |
| 345 | +} | |
| 346 | + | |
| 347 | +.breadcrumb { | |
| 348 | + display: flex; | |
| 349 | + align-items: center; | |
| 350 | + gap: var(--spacing-sm); | |
| 351 | + color: var(--text-secondary); | |
| 352 | +} | |
| 353 | + | |
| 354 | +.breadcrumb-item { | |
| 355 | + display: flex; | |
| 356 | + align-items: center; | |
| 357 | + gap: var(--spacing-sm); | |
| 358 | +} | |
| 359 | + | |
| 360 | +.breadcrumb-item:not(:last-child)::after { | |
| 361 | + content: '/'; | |
| 362 | + color: var(--text-muted); | |
| 363 | + margin-left: var(--spacing-sm); | |
| 364 | +} | |
| 365 | + | |
| 366 | +.breadcrumb-item a { | |
| 367 | + color: var(--color-primary-light); | |
| 368 | + transition: color var(--transition-fast); | |
| 369 | +} | |
| 370 | + | |
| 371 | +.breadcrumb-item a:hover { | |
| 372 | + color: var(--color-primary); | |
| 373 | +} | |
| 374 | + | |
| 375 | +.top-bar-actions { | |
| 376 | + display: flex; | |
| 377 | + align-items: center; | |
| 378 | + gap: var(--spacing-sm); | |
| 379 | +} | |
| 380 | + | |
| 381 | +.page-container { | |
| 382 | + flex: 1; | |
| 383 | + padding: var(--spacing-xl); | |
| 384 | + overflow-y: auto; | |
| 385 | +} | |
| 386 | + | |
| 387 | +/* ===================== | |
| 388 | + Buttons | |
| 389 | + ===================== */ | |
| 390 | +.btn { | |
| 391 | + display: inline-flex; | |
| 392 | + align-items: center; | |
| 393 | + justify-content: center; | |
| 394 | + gap: var(--spacing-sm); | |
| 395 | + padding: var(--spacing-sm) var(--spacing-lg); | |
| 396 | + font-size: 0.875rem; | |
| 397 | + font-weight: 500; | |
| 398 | + border-radius: var(--radius-md); | |
| 399 | + transition: all var(--transition-fast); | |
| 400 | + white-space: nowrap; | |
| 401 | +} | |
| 402 | + | |
| 403 | +.btn-primary { | |
| 404 | + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); | |
| 405 | + color: white; | |
| 406 | + box-shadow: var(--shadow-sm); | |
| 407 | +} | |
| 408 | + | |
| 409 | +.btn-primary:hover { | |
| 410 | + box-shadow: var(--shadow-md); | |
| 411 | + transform: translateY(-1px); | |
| 412 | +} | |
| 413 | + | |
| 414 | +.btn-secondary { | |
| 415 | + background: var(--bg-elevated); | |
| 416 | + color: var(--text-primary); | |
| 417 | + border: 1px solid var(--border-color-light); | |
| 418 | +} | |
| 419 | + | |
| 420 | +.btn-secondary:hover { | |
| 421 | + background: var(--bg-hover); | |
| 422 | +} | |
| 423 | + | |
| 424 | +.btn-ghost { | |
| 425 | + color: var(--text-secondary); | |
| 426 | +} | |
| 427 | + | |
| 428 | +.btn-ghost:hover { | |
| 429 | + background: var(--bg-elevated); | |
| 430 | + color: var(--text-primary); | |
| 431 | +} | |
| 432 | + | |
| 433 | +.btn-icon { | |
| 434 | + width: 40px; | |
| 435 | + height: 40px; | |
| 436 | + padding: 0; | |
| 437 | + border-radius: var(--radius-md); | |
| 438 | + color: var(--text-secondary); | |
| 439 | +} | |
| 440 | + | |
| 441 | +.btn-icon:hover { | |
| 442 | + background: var(--bg-elevated); | |
| 443 | + color: var(--text-primary); | |
| 444 | +} | |
| 445 | + | |
| 446 | +.btn-icon svg { | |
| 447 | + width: 20px; | |
| 448 | + height: 20px; | |
| 449 | +} | |
| 450 | + | |
| 451 | +.btn-sm { | |
| 452 | + padding: var(--spacing-xs) var(--spacing-md); | |
| 453 | + font-size: 0.75rem; | |
| 454 | +} | |
| 455 | + | |
| 456 | +/* ===================== | |
| 457 | + Cards | |
| 458 | + ===================== */ | |
| 459 | +.card { | |
| 460 | + background: var(--glass-bg); | |
| 461 | + backdrop-filter: blur(var(--glass-blur)); | |
| 462 | + border: 1px solid var(--glass-border); | |
| 463 | + border-radius: var(--radius-lg); | |
| 464 | + padding: var(--spacing-lg); | |
| 465 | + transition: all var(--transition-base); | |
| 466 | +} | |
| 467 | + | |
| 468 | +.card:hover { | |
| 469 | + border-color: var(--border-color-light); | |
| 470 | + box-shadow: var(--shadow-lg); | |
| 471 | +} | |
| 472 | + | |
| 473 | +.card-header { | |
| 474 | + display: flex; | |
| 475 | + align-items: center; | |
| 476 | + justify-content: space-between; | |
| 477 | + margin-bottom: var(--spacing-lg); | |
| 478 | +} | |
| 479 | + | |
| 480 | +.card-title { | |
| 481 | + font-size: 1.125rem; | |
| 482 | + font-weight: 600; | |
| 483 | + display: flex; | |
| 484 | + align-items: center; | |
| 485 | + gap: var(--spacing-sm); | |
| 486 | +} | |
| 487 | + | |
| 488 | +.card-title svg { | |
| 489 | + width: 20px; | |
| 490 | + height: 20px; | |
| 491 | + color: var(--color-primary); | |
| 492 | +} | |
| 493 | + | |
| 494 | +/* ===================== | |
| 495 | + Stat Cards | |
| 496 | + ===================== */ | |
| 497 | +.stats-grid { | |
| 498 | + display: grid; | |
| 499 | + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| 500 | + gap: var(--spacing-lg); | |
| 501 | + margin-bottom: var(--spacing-xl); | |
| 502 | +} | |
| 503 | + | |
| 504 | +.stat-card { | |
| 505 | + background: var(--glass-bg); | |
| 506 | + backdrop-filter: blur(var(--glass-blur)); | |
| 507 | + border: 1px solid var(--glass-border); | |
| 508 | + border-radius: var(--radius-lg); | |
| 509 | + padding: var(--spacing-lg); | |
| 510 | + display: flex; | |
| 511 | + align-items: flex-start; | |
| 512 | + gap: var(--spacing-lg); | |
| 513 | + transition: all var(--transition-base); | |
| 514 | +} | |
| 515 | + | |
| 516 | +.stat-card:hover { | |
| 517 | + transform: translateY(-2px); | |
| 518 | + box-shadow: var(--shadow-lg); | |
| 519 | +} | |
| 520 | + | |
| 521 | +.stat-icon { | |
| 522 | + width: 48px; | |
| 523 | + height: 48px; | |
| 524 | + border-radius: var(--radius-md); | |
| 525 | + display: flex; | |
| 526 | + align-items: center; | |
| 527 | + justify-content: center; | |
| 528 | + flex-shrink: 0; | |
| 529 | +} | |
| 530 | + | |
| 531 | +.stat-icon svg { | |
| 532 | + width: 24px; | |
| 533 | + height: 24px; | |
| 534 | +} | |
| 535 | + | |
| 536 | +.stat-icon.primary { | |
| 537 | + background: rgba(99, 102, 241, 0.15); | |
| 538 | + color: var(--color-primary); | |
| 539 | +} | |
| 540 | + | |
| 541 | +.stat-icon.success { | |
| 542 | + background: rgba(16, 185, 129, 0.15); | |
| 543 | + color: var(--color-success); | |
| 544 | +} | |
| 545 | + | |
| 546 | +.stat-icon.warning { | |
| 547 | + background: rgba(245, 158, 11, 0.15); | |
| 548 | + color: var(--color-warning); | |
| 549 | +} | |
| 550 | + | |
| 551 | +.stat-icon.danger { | |
| 552 | + background: rgba(239, 68, 68, 0.15); | |
| 553 | + color: var(--color-danger); | |
| 554 | +} | |
| 555 | + | |
| 556 | +.stat-icon.info { | |
| 557 | + background: rgba(59, 130, 246, 0.15); | |
| 558 | + color: var(--color-info); | |
| 559 | +} | |
| 560 | + | |
| 561 | +.stat-content { | |
| 562 | + flex: 1; | |
| 563 | +} | |
| 564 | + | |
| 565 | +.stat-label { | |
| 566 | + font-size: 0.875rem; | |
| 567 | + color: var(--text-secondary); | |
| 568 | + margin-bottom: var(--spacing-xs); | |
| 569 | +} | |
| 570 | + | |
| 571 | +.stat-value { | |
| 572 | + font-size: 1.75rem; | |
| 573 | + font-weight: 700; | |
| 574 | + line-height: 1.2; | |
| 575 | +} | |
| 576 | + | |
| 577 | +.stat-change { | |
| 578 | + font-size: 0.75rem; | |
| 579 | + margin-top: var(--spacing-xs); | |
| 580 | + display: flex; | |
| 581 | + align-items: center; | |
| 582 | + gap: var(--spacing-xs); | |
| 583 | +} | |
| 584 | + | |
| 585 | +.stat-change.positive { | |
| 586 | + color: var(--color-success); | |
| 587 | +} | |
| 588 | + | |
| 589 | +.stat-change.negative { | |
| 590 | + color: var(--color-danger); | |
| 591 | +} | |
| 592 | + | |
| 593 | +/* ===================== | |
| 594 | + Tables | |
| 595 | + ===================== */ | |
| 596 | +.table-container { | |
| 597 | + background: var(--glass-bg); | |
| 598 | + backdrop-filter: blur(var(--glass-blur)); | |
| 599 | + border: 1px solid var(--glass-border); | |
| 600 | + border-radius: var(--radius-lg); | |
| 601 | + overflow: hidden; | |
| 602 | +} | |
| 603 | + | |
| 604 | +.table-container > .table-wrapper { | |
| 605 | + border-radius: var(--radius-lg); | |
| 606 | + overflow-x: auto; | |
| 607 | +} | |
| 608 | + | |
| 609 | +.table-header { | |
| 610 | + display: flex; | |
| 611 | + align-items: center; | |
| 612 | + justify-content: space-between; | |
| 613 | + padding: var(--spacing-lg); | |
| 614 | + border-bottom: 1px solid var(--border-color); | |
| 615 | +} | |
| 616 | + | |
| 617 | +.table-title { | |
| 618 | + font-size: 1.125rem; | |
| 619 | + font-weight: 600; | |
| 620 | +} | |
| 621 | + | |
| 622 | +.table-actions { | |
| 623 | + display: flex; | |
| 624 | + align-items: center; | |
| 625 | + gap: var(--spacing-sm); | |
| 626 | +} | |
| 627 | + | |
| 628 | +.table-wrapper { | |
| 629 | + overflow-x: auto; | |
| 630 | + -webkit-overflow-scrolling: touch; | |
| 631 | + width: 100%; | |
| 632 | +} | |
| 633 | + | |
| 634 | +table { | |
| 635 | + width: 100%; | |
| 636 | + border-collapse: collapse; | |
| 637 | +} | |
| 638 | + | |
| 639 | +thead { | |
| 640 | + background: var(--bg-surface); | |
| 641 | +} | |
| 642 | + | |
| 643 | +th { | |
| 644 | + padding: var(--spacing-md) var(--spacing-lg); | |
| 645 | + text-align: left; | |
| 646 | + font-weight: 600; | |
| 647 | + font-size: 0.75rem; | |
| 648 | + text-transform: uppercase; | |
| 649 | + letter-spacing: 0.05em; | |
| 650 | + color: var(--text-secondary); | |
| 651 | + border-bottom: 1px solid var(--border-color); | |
| 652 | + white-space: nowrap; | |
| 653 | +} | |
| 654 | + | |
| 655 | +td { | |
| 656 | + padding: var(--spacing-md) var(--spacing-lg); | |
| 657 | + border-bottom: 1px solid var(--border-color); | |
| 658 | + vertical-align: middle; | |
| 659 | +} | |
| 660 | + | |
| 661 | +.table-cell-secondary { | |
| 662 | + min-width: 120px; | |
| 663 | + max-width: 200px; | |
| 664 | + word-break: break-word; | |
| 665 | +} | |
| 666 | + | |
| 667 | +tbody tr { | |
| 668 | + transition: background var(--transition-fast); | |
| 669 | +} | |
| 670 | + | |
| 671 | +tbody tr:hover { | |
| 672 | + background: var(--bg-elevated); | |
| 673 | +} | |
| 674 | + | |
| 675 | +tbody tr:last-child td { | |
| 676 | + border-bottom: none; | |
| 677 | +} | |
| 678 | + | |
| 679 | +.table-cell-link { | |
| 680 | + color: var(--color-primary-light); | |
| 681 | + font-weight: 500; | |
| 682 | + cursor: pointer; | |
| 683 | + transition: color var(--transition-fast); | |
| 684 | +} | |
| 685 | + | |
| 686 | +.table-cell-link:hover { | |
| 687 | + color: var(--color-primary); | |
| 688 | + text-decoration: underline; | |
| 689 | +} | |
| 690 | + | |
| 691 | +.table-cell-mono { | |
| 692 | + font-family: var(--font-mono); | |
| 693 | + font-size: 0.8rem; | |
| 694 | +} | |
| 695 | + | |
| 696 | +.table-cell-amount { | |
| 697 | + font-weight: 600; | |
| 698 | + color: var(--color-success); | |
| 699 | +} | |
| 700 | + | |
| 701 | +.table-cell-secondary { | |
| 702 | + color: var(--text-secondary); | |
| 703 | + font-size: 0.875rem; | |
| 704 | +} | |
| 705 | + | |
| 706 | +/* ===================== | |
| 707 | + Badges / Status | |
| 708 | + ===================== */ | |
| 709 | +.badge { | |
| 710 | + display: inline-flex; | |
| 711 | + align-items: center; | |
| 712 | + gap: var(--spacing-xs); | |
| 713 | + padding: var(--spacing-xs) var(--spacing-sm); | |
| 714 | + font-size: 0.75rem; | |
| 715 | + font-weight: 500; | |
| 716 | + border-radius: var(--radius-full); | |
| 717 | + white-space: nowrap; | |
| 718 | +} | |
| 719 | + | |
| 720 | +.badge-success { | |
| 721 | + background: rgba(16, 185, 129, 0.15); | |
| 722 | + color: var(--color-success-light); | |
| 723 | +} | |
| 724 | + | |
| 725 | +.badge-warning { | |
| 726 | + background: rgba(245, 158, 11, 0.15); | |
| 727 | + color: var(--color-warning-light); | |
| 728 | +} | |
| 729 | + | |
| 730 | +.badge-danger { | |
| 731 | + background: rgba(239, 68, 68, 0.15); | |
| 732 | + color: var(--color-danger-light); | |
| 733 | +} | |
| 734 | + | |
| 735 | +.badge-info { | |
| 736 | + background: rgba(59, 130, 246, 0.15); | |
| 737 | + color: var(--color-info-light); | |
| 738 | +} | |
| 739 | + | |
| 740 | +.badge-neutral { | |
| 741 | + background: var(--bg-elevated); | |
| 742 | + color: var(--text-secondary); | |
| 743 | +} | |
| 744 | + | |
| 745 | +.badge-dot { | |
| 746 | + width: 6px; | |
| 747 | + height: 6px; | |
| 748 | + border-radius: 50%; | |
| 749 | + background: currentColor; | |
| 750 | +} | |
| 751 | + | |
| 752 | +/* ===================== | |
| 753 | + Pagination | |
| 754 | + ===================== */ | |
| 755 | +.pagination { | |
| 756 | + display: flex; | |
| 757 | + align-items: center; | |
| 758 | + justify-content: space-between; | |
| 759 | + padding: var(--spacing-lg); | |
| 760 | + border-top: 1px solid var(--border-color); | |
| 761 | +} | |
| 762 | + | |
| 763 | +.pagination-info { | |
| 764 | + font-size: 0.875rem; | |
| 765 | + color: var(--text-secondary); | |
| 766 | +} | |
| 767 | + | |
| 768 | +.pagination-controls { | |
| 769 | + display: flex; | |
| 770 | + align-items: center; | |
| 771 | + gap: var(--spacing-xs); | |
| 772 | +} | |
| 773 | + | |
| 774 | +.pagination-btn { | |
| 775 | + width: 36px; | |
| 776 | + height: 36px; | |
| 777 | + display: flex; | |
| 778 | + align-items: center; | |
| 779 | + justify-content: center; | |
| 780 | + border-radius: var(--radius-md); | |
| 781 | + color: var(--text-secondary); | |
| 782 | + transition: all var(--transition-fast); | |
| 783 | +} | |
| 784 | + | |
| 785 | +.pagination-btn:hover:not(:disabled) { | |
| 786 | + background: var(--bg-elevated); | |
| 787 | + color: var(--text-primary); | |
| 788 | +} | |
| 789 | + | |
| 790 | +.pagination-btn:disabled { | |
| 791 | + opacity: 0.5; | |
| 792 | + cursor: not-allowed; | |
| 793 | +} | |
| 794 | + | |
| 795 | +.pagination-btn.active { | |
| 796 | + background: var(--color-primary); | |
| 797 | + color: white; | |
| 798 | +} | |
| 799 | + | |
| 800 | +.pagination-btn svg { | |
| 801 | + width: 18px; | |
| 802 | + height: 18px; | |
| 803 | +} | |
| 804 | + | |
| 805 | +/* ===================== | |
| 806 | + Loading & Overlay | |
| 807 | + ===================== */ | |
| 808 | +.loading-overlay { | |
| 809 | + position: fixed; | |
| 810 | + inset: 0; | |
| 811 | + background: rgba(15, 23, 42, 0.8); | |
| 812 | + backdrop-filter: blur(4px); | |
| 813 | + display: flex; | |
| 814 | + align-items: center; | |
| 815 | + justify-content: center; | |
| 816 | + z-index: 1000; | |
| 817 | + opacity: 0; | |
| 818 | + visibility: hidden; | |
| 819 | + transition: all var(--transition-base); | |
| 820 | +} | |
| 821 | + | |
| 822 | +.loading-overlay.active { | |
| 823 | + opacity: 1; | |
| 824 | + visibility: visible; | |
| 825 | +} | |
| 826 | + | |
| 827 | +.loading-spinner { | |
| 828 | + display: flex; | |
| 829 | + flex-direction: column; | |
| 830 | + align-items: center; | |
| 831 | + gap: var(--spacing-md); | |
| 832 | + color: var(--text-primary); | |
| 833 | +} | |
| 834 | + | |
| 835 | +.loading-spinner svg { | |
| 836 | + width: 48px; | |
| 837 | + height: 48px; | |
| 838 | + color: var(--color-primary); | |
| 839 | +} | |
| 840 | + | |
| 841 | +.spin { | |
| 842 | + animation: spin 1s linear infinite; | |
| 843 | +} | |
| 844 | + | |
| 845 | +@keyframes spin { | |
| 846 | + from { transform: rotate(0deg); } | |
| 847 | + to { transform: rotate(360deg); } | |
| 848 | +} | |
| 849 | + | |
| 850 | +/* ===================== | |
| 851 | + Modal | |
| 852 | + ===================== */ | |
| 853 | +.modal-overlay { | |
| 854 | + position: fixed; | |
| 855 | + inset: 0; | |
| 856 | + background: rgba(15, 23, 42, 0.8); | |
| 857 | + backdrop-filter: blur(4px); | |
| 858 | + display: flex; | |
| 859 | + align-items: center; | |
| 860 | + justify-content: center; | |
| 861 | + z-index: 1000; | |
| 862 | + padding: var(--spacing-xl); | |
| 863 | + opacity: 0; | |
| 864 | + visibility: hidden; | |
| 865 | + transition: all var(--transition-base); | |
| 866 | +} | |
| 867 | + | |
| 868 | +.modal-overlay.active { | |
| 869 | + opacity: 1; | |
| 870 | + visibility: visible; | |
| 871 | +} | |
| 872 | + | |
| 873 | +.modal { | |
| 874 | + background: var(--bg-surface); | |
| 875 | + border: 1px solid var(--glass-border); | |
| 876 | + border-radius: var(--radius-xl); | |
| 877 | + width: 100%; | |
| 878 | + max-width: 800px; | |
| 879 | + max-height: 90vh; | |
| 880 | + display: flex; | |
| 881 | + flex-direction: column; | |
| 882 | + box-shadow: var(--shadow-xl); | |
| 883 | + transform: scale(0.95); | |
| 884 | + transition: transform var(--transition-base); | |
| 885 | +} | |
| 886 | + | |
| 887 | +.modal-overlay.active .modal { | |
| 888 | + transform: scale(1); | |
| 889 | +} | |
| 890 | + | |
| 891 | +.modal-header { | |
| 892 | + display: flex; | |
| 893 | + align-items: center; | |
| 894 | + justify-content: space-between; | |
| 895 | + padding: var(--spacing-lg) var(--spacing-xl); | |
| 896 | + border-bottom: 1px solid var(--border-color); | |
| 897 | +} | |
| 898 | + | |
| 899 | +.modal-title { | |
| 900 | + font-size: 1.25rem; | |
| 901 | + font-weight: 600; | |
| 902 | +} | |
| 903 | + | |
| 904 | +.modal-body { | |
| 905 | + flex: 1; | |
| 906 | + padding: var(--spacing-xl); | |
| 907 | + overflow-y: auto; | |
| 908 | +} | |
| 909 | + | |
| 910 | +/* ===================== | |
| 911 | + Toast Notifications | |
| 912 | + ===================== */ | |
| 913 | +.toast-container { | |
| 914 | + position: fixed; | |
| 915 | + top: var(--spacing-lg); | |
| 916 | + right: var(--spacing-lg); | |
| 917 | + z-index: 1100; | |
| 918 | + display: flex; | |
| 919 | + flex-direction: column; | |
| 920 | + gap: var(--spacing-sm); | |
| 921 | +} | |
| 922 | + | |
| 923 | +.toast { | |
| 924 | + background: var(--bg-surface); | |
| 925 | + border: 1px solid var(--glass-border); | |
| 926 | + border-radius: var(--radius-md); | |
| 927 | + padding: var(--spacing-md) var(--spacing-lg); | |
| 928 | + display: flex; | |
| 929 | + align-items: center; | |
| 930 | + gap: var(--spacing-md); | |
| 931 | + box-shadow: var(--shadow-lg); | |
| 932 | + animation: slideIn var(--transition-base) ease; | |
| 933 | + min-width: 300px; | |
| 934 | +} | |
| 935 | + | |
| 936 | +.toast.success { | |
| 937 | + border-left: 4px solid var(--color-success); | |
| 938 | +} | |
| 939 | + | |
| 940 | +.toast.error { | |
| 941 | + border-left: 4px solid var(--color-danger); | |
| 942 | +} | |
| 943 | + | |
| 944 | +.toast.warning { | |
| 945 | + border-left: 4px solid var(--color-warning); | |
| 946 | +} | |
| 947 | + | |
| 948 | +.toast.info { | |
| 949 | + border-left: 4px solid var(--color-info); | |
| 950 | +} | |
| 951 | + | |
| 952 | +.toast-icon { | |
| 953 | + width: 20px; | |
| 954 | + height: 20px; | |
| 955 | + flex-shrink: 0; | |
| 956 | +} | |
| 957 | + | |
| 958 | +.toast.success .toast-icon { color: var(--color-success); } | |
| 959 | +.toast.error .toast-icon { color: var(--color-danger); } | |
| 960 | +.toast.warning .toast-icon { color: var(--color-warning); } | |
| 961 | +.toast.info .toast-icon { color: var(--color-info); } | |
| 962 | + | |
| 963 | +.toast-message { | |
| 964 | + flex: 1; | |
| 965 | + font-size: 0.875rem; | |
| 966 | +} | |
| 967 | + | |
| 968 | +@keyframes slideIn { | |
| 969 | + from { | |
| 970 | + transform: translateX(100%); | |
| 971 | + opacity: 0; | |
| 972 | + } | |
| 973 | + to { | |
| 974 | + transform: translateX(0); | |
| 975 | + opacity: 1; | |
| 976 | + } | |
| 977 | +} | |
| 978 | + | |
| 979 | +/* ===================== | |
| 980 | + Detail View | |
| 981 | + ===================== */ | |
| 982 | +.detail-header { | |
| 983 | + display: flex; | |
| 984 | + align-items: flex-start; | |
| 985 | + justify-content: space-between; | |
| 986 | + margin-bottom: var(--spacing-xl); | |
| 987 | +} | |
| 988 | + | |
| 989 | +.detail-title { | |
| 990 | + font-size: 1.5rem; | |
| 991 | + font-weight: 700; | |
| 992 | + display: flex; | |
| 993 | + align-items: center; | |
| 994 | + gap: var(--spacing-md); | |
| 995 | +} | |
| 996 | + | |
| 997 | +.detail-title .badge { | |
| 998 | + font-size: 0.875rem; | |
| 999 | +} | |
| 1000 | + | |
| 1001 | +.detail-meta { | |
| 1002 | + display: flex; | |
| 1003 | + align-items: center; | |
| 1004 | + gap: var(--spacing-lg); | |
| 1005 | + margin-top: var(--spacing-sm); | |
| 1006 | + color: var(--text-secondary); | |
| 1007 | + font-size: 0.875rem; | |
| 1008 | +} | |
| 1009 | + | |
| 1010 | +.detail-meta-item { | |
| 1011 | + display: flex; | |
| 1012 | + align-items: center; | |
| 1013 | + gap: var(--spacing-xs); | |
| 1014 | +} | |
| 1015 | + | |
| 1016 | +.detail-meta-item svg { | |
| 1017 | + width: 16px; | |
| 1018 | + height: 16px; | |
| 1019 | +} | |
| 1020 | + | |
| 1021 | +.detail-actions { | |
| 1022 | + display: flex; | |
| 1023 | + gap: var(--spacing-sm); | |
| 1024 | +} | |
| 1025 | + | |
| 1026 | +.detail-grid { | |
| 1027 | + display: grid; | |
| 1028 | + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| 1029 | + gap: var(--spacing-lg); | |
| 1030 | + margin-bottom: var(--spacing-xl); | |
| 1031 | +} | |
| 1032 | + | |
| 1033 | +.detail-section { | |
| 1034 | + margin-bottom: var(--spacing-xl); | |
| 1035 | +} | |
| 1036 | + | |
| 1037 | +.detail-section-title { | |
| 1038 | + font-size: 1.125rem; | |
| 1039 | + font-weight: 600; | |
| 1040 | + margin-bottom: var(--spacing-lg); | |
| 1041 | + display: flex; | |
| 1042 | + align-items: center; | |
| 1043 | + gap: var(--spacing-sm); | |
| 1044 | + color: var(--text-primary); | |
| 1045 | +} | |
| 1046 | + | |
| 1047 | +.detail-section-title svg { | |
| 1048 | + width: 20px; | |
| 1049 | + height: 20px; | |
| 1050 | + color: var(--color-primary); | |
| 1051 | +} | |
| 1052 | + | |
| 1053 | +/* ===================== | |
| 1054 | + Info List | |
| 1055 | + ===================== */ | |
| 1056 | +.info-list { | |
| 1057 | + display: grid; | |
| 1058 | + gap: var(--spacing-md); | |
| 1059 | +} | |
| 1060 | + | |
| 1061 | +.info-item { | |
| 1062 | + display: flex; | |
| 1063 | + justify-content: space-between; | |
| 1064 | + align-items: center; | |
| 1065 | + padding: var(--spacing-sm) 0; | |
| 1066 | + border-bottom: 1px solid var(--border-color); | |
| 1067 | +} | |
| 1068 | + | |
| 1069 | +.info-item:last-child { | |
| 1070 | + border-bottom: none; | |
| 1071 | +} | |
| 1072 | + | |
| 1073 | +.info-label { | |
| 1074 | + color: var(--text-secondary); | |
| 1075 | + font-size: 0.875rem; | |
| 1076 | +} | |
| 1077 | + | |
| 1078 | +.info-value { | |
| 1079 | + font-weight: 500; | |
| 1080 | + text-align: right; | |
| 1081 | +} | |
| 1082 | + | |
| 1083 | +/* ===================== | |
| 1084 | + Markdown Content | |
| 1085 | + ===================== */ | |
| 1086 | +.markdown-content { | |
| 1087 | + line-height: 1.8; | |
| 1088 | + color: var(--text-primary); | |
| 1089 | +} | |
| 1090 | + | |
| 1091 | +.markdown-content h1, | |
| 1092 | +.markdown-content h2, | |
| 1093 | +.markdown-content h3, | |
| 1094 | +.markdown-content h4 { | |
| 1095 | + margin-top: var(--spacing-xl); | |
| 1096 | + margin-bottom: var(--spacing-md); | |
| 1097 | + font-weight: 600; | |
| 1098 | +} | |
| 1099 | + | |
| 1100 | +.markdown-content h1 { font-size: 1.75rem; } | |
| 1101 | +.markdown-content h2 { font-size: 1.5rem; } | |
| 1102 | +.markdown-content h3 { font-size: 1.25rem; } | |
| 1103 | +.markdown-content h4 { font-size: 1.125rem; } | |
| 1104 | + | |
| 1105 | +.markdown-content p { | |
| 1106 | + margin-bottom: var(--spacing-md); | |
| 1107 | +} | |
| 1108 | + | |
| 1109 | +.markdown-content ul, | |
| 1110 | +.markdown-content ol { | |
| 1111 | + margin-bottom: var(--spacing-md); | |
| 1112 | + padding-left: var(--spacing-xl); | |
| 1113 | +} | |
| 1114 | + | |
| 1115 | +.markdown-content li { | |
| 1116 | + margin-bottom: var(--spacing-sm); | |
| 1117 | +} | |
| 1118 | + | |
| 1119 | +.markdown-content code { | |
| 1120 | + background: var(--bg-elevated); | |
| 1121 | + padding: var(--spacing-xs) var(--spacing-sm); | |
| 1122 | + border-radius: var(--radius-sm); | |
| 1123 | + font-family: var(--font-mono); | |
| 1124 | + font-size: 0.875rem; | |
| 1125 | +} | |
| 1126 | + | |
| 1127 | +.markdown-content pre { | |
| 1128 | + background: var(--bg-elevated); | |
| 1129 | + padding: var(--spacing-lg); | |
| 1130 | + border-radius: var(--radius-md); | |
| 1131 | + overflow-x: auto; | |
| 1132 | + margin-bottom: var(--spacing-md); | |
| 1133 | +} | |
| 1134 | + | |
| 1135 | +.markdown-content pre code { | |
| 1136 | + background: none; | |
| 1137 | + padding: 0; | |
| 1138 | +} | |
| 1139 | + | |
| 1140 | +.markdown-content blockquote { | |
| 1141 | + border-left: 4px solid var(--color-primary); | |
| 1142 | + padding-left: var(--spacing-lg); | |
| 1143 | + margin: var(--spacing-lg) 0; | |
| 1144 | + color: var(--text-secondary); | |
| 1145 | +} | |
| 1146 | + | |
| 1147 | +.markdown-content table { | |
| 1148 | + margin: var(--spacing-lg) 0; | |
| 1149 | + border: 1px solid var(--border-color); | |
| 1150 | + border-radius: var(--radius-md); | |
| 1151 | + overflow: hidden; | |
| 1152 | +} | |
| 1153 | + | |
| 1154 | +.markdown-content th, | |
| 1155 | +.markdown-content td { | |
| 1156 | + padding: var(--spacing-sm) var(--spacing-md); | |
| 1157 | + border: 1px solid var(--border-color); | |
| 1158 | +} | |
| 1159 | + | |
| 1160 | +.markdown-content th { | |
| 1161 | + background: var(--bg-elevated); | |
| 1162 | +} | |
| 1163 | + | |
| 1164 | +.markdown-content hr { | |
| 1165 | + border: none; | |
| 1166 | + border-top: 1px solid var(--border-color); | |
| 1167 | + margin: var(--spacing-xl) 0; | |
| 1168 | +} | |
| 1169 | + | |
| 1170 | +/* ===================== | |
| 1171 | + Empty State | |
| 1172 | + ===================== */ | |
| 1173 | +.empty-state { | |
| 1174 | + display: flex; | |
| 1175 | + flex-direction: column; | |
| 1176 | + align-items: center; | |
| 1177 | + justify-content: center; | |
| 1178 | + padding: var(--spacing-2xl); | |
| 1179 | + text-align: center; | |
| 1180 | + color: var(--text-secondary); | |
| 1181 | +} | |
| 1182 | + | |
| 1183 | +.empty-state svg { | |
| 1184 | + width: 64px; | |
| 1185 | + height: 64px; | |
| 1186 | + color: var(--text-muted); | |
| 1187 | + margin-bottom: var(--spacing-lg); | |
| 1188 | +} | |
| 1189 | + | |
| 1190 | +.empty-state-title { | |
| 1191 | + font-size: 1.125rem; | |
| 1192 | + font-weight: 600; | |
| 1193 | + color: var(--text-primary); | |
| 1194 | + margin-bottom: var(--spacing-sm); | |
| 1195 | +} | |
| 1196 | + | |
| 1197 | +.empty-state-description { | |
| 1198 | + font-size: 0.875rem; | |
| 1199 | + max-width: 400px; | |
| 1200 | +} | |
| 1201 | + | |
| 1202 | +/* ===================== | |
| 1203 | + Tabs | |
| 1204 | + ===================== */ | |
| 1205 | +.tabs { | |
| 1206 | + display: flex; | |
| 1207 | + gap: var(--spacing-xs); | |
| 1208 | + border-bottom: 1px solid var(--border-color); | |
| 1209 | + margin-bottom: var(--spacing-xl); | |
| 1210 | +} | |
| 1211 | + | |
| 1212 | +.tab { | |
| 1213 | + padding: var(--spacing-md) var(--spacing-lg); | |
| 1214 | + font-weight: 500; | |
| 1215 | + color: var(--text-secondary); | |
| 1216 | + border-bottom: 2px solid transparent; | |
| 1217 | + margin-bottom: -1px; | |
| 1218 | + transition: all var(--transition-fast); | |
| 1219 | +} | |
| 1220 | + | |
| 1221 | +.tab:hover { | |
| 1222 | + color: var(--text-primary); | |
| 1223 | +} | |
| 1224 | + | |
| 1225 | +.tab.active { | |
| 1226 | + color: var(--color-primary); | |
| 1227 | + border-bottom-color: var(--color-primary); | |
| 1228 | +} | |
| 1229 | + | |
| 1230 | +/* ===================== | |
| 1231 | + Ratio Indicator | |
| 1232 | + ===================== */ | |
| 1233 | +.ratio-indicator { | |
| 1234 | + display: inline-flex; | |
| 1235 | + align-items: center; | |
| 1236 | + gap: var(--spacing-xs); | |
| 1237 | +} | |
| 1238 | + | |
| 1239 | +.ratio-bar { | |
| 1240 | + width: 60px; | |
| 1241 | + height: 6px; | |
| 1242 | + background: var(--bg-elevated); | |
| 1243 | + border-radius: var(--radius-full); | |
| 1244 | + overflow: hidden; | |
| 1245 | +} | |
| 1246 | + | |
| 1247 | +.ratio-bar-fill { | |
| 1248 | + height: 100%; | |
| 1249 | + border-radius: var(--radius-full); | |
| 1250 | + transition: width var(--transition-base); | |
| 1251 | +} | |
| 1252 | + | |
| 1253 | +.ratio-low .ratio-bar-fill { background: var(--color-danger); } | |
| 1254 | +.ratio-normal .ratio-bar-fill { background: var(--color-success); } | |
| 1255 | +.ratio-high .ratio-bar-fill { background: var(--color-warning); } | |
| 1256 | + | |
| 1257 | +/* ===================== | |
| 1258 | + Priority Badge | |
| 1259 | + ===================== */ | |
| 1260 | +.priority-badge { | |
| 1261 | + display: inline-flex; | |
| 1262 | + align-items: center; | |
| 1263 | + gap: var(--spacing-xs); | |
| 1264 | + padding: var(--spacing-xs) var(--spacing-sm); | |
| 1265 | + border-radius: var(--radius-sm); | |
| 1266 | + font-size: 0.75rem; | |
| 1267 | + font-weight: 500; | |
| 1268 | +} | |
| 1269 | + | |
| 1270 | +.priority-high { | |
| 1271 | + background: rgba(239, 68, 68, 0.15); | |
| 1272 | + color: var(--color-danger-light); | |
| 1273 | +} | |
| 1274 | + | |
| 1275 | +.priority-medium { | |
| 1276 | + background: rgba(245, 158, 11, 0.15); | |
| 1277 | + color: var(--color-warning-light); | |
| 1278 | +} | |
| 1279 | + | |
| 1280 | +.priority-low { | |
| 1281 | + background: rgba(16, 185, 129, 0.15); | |
| 1282 | + color: var(--color-success-light); | |
| 1283 | +} | |
| 1284 | + | |
| 1285 | +/* ===================== | |
| 1286 | + Back Button | |
| 1287 | + ===================== */ | |
| 1288 | +.back-link { | |
| 1289 | + display: inline-flex; | |
| 1290 | + align-items: center; | |
| 1291 | + gap: var(--spacing-sm); | |
| 1292 | + color: var(--text-secondary); | |
| 1293 | + font-size: 0.875rem; | |
| 1294 | + margin-bottom: var(--spacing-lg); | |
| 1295 | + transition: color var(--transition-fast); | |
| 1296 | +} | |
| 1297 | + | |
| 1298 | +.back-link:hover { | |
| 1299 | + color: var(--color-primary); | |
| 1300 | +} | |
| 1301 | + | |
| 1302 | +.back-link svg { | |
| 1303 | + width: 18px; | |
| 1304 | + height: 18px; | |
| 1305 | +} | |
| 1306 | + | |
| 1307 | +/* ===================== | |
| 1308 | + Report Sections | |
| 1309 | + ===================== */ | |
| 1310 | +.report-section { | |
| 1311 | + padding: var(--spacing-lg); | |
| 1312 | +} | |
| 1313 | + | |
| 1314 | +.report-section .markdown-content { | |
| 1315 | + line-height: 1.8; | |
| 1316 | +} | |
| 1317 | + | |
| 1318 | +.report-section .markdown-content h1, | |
| 1319 | +.report-section .markdown-content h2, | |
| 1320 | +.report-section .markdown-content h3 { | |
| 1321 | + margin-top: var(--spacing-lg); | |
| 1322 | + margin-bottom: var(--spacing-md); | |
| 1323 | +} | |
| 1324 | + | |
| 1325 | +.report-section .markdown-content p { | |
| 1326 | + margin-bottom: var(--spacing-md); | |
| 1327 | +} | |
| 1328 | + | |
| 1329 | +.report-section .markdown-content ul, | |
| 1330 | +.report-section .markdown-content ol { | |
| 1331 | + margin-bottom: var(--spacing-md); | |
| 1332 | + padding-left: var(--spacing-xl); | |
| 1333 | +} | |
| 1334 | + | |
| 1335 | +.report-section .markdown-content li { | |
| 1336 | + margin-bottom: var(--spacing-xs); | |
| 1337 | +} | |
| 1338 | + | |
| 1339 | +.report-section .markdown-content pre:empty, | |
| 1340 | +.report-section .markdown-content pre:blank { | |
| 1341 | + display: none; | |
| 1342 | +} | |
| 1343 | + | |
| 1344 | +/* ===================== | |
| 1345 | + Part Tags | |
| 1346 | + ===================== */ | |
| 1347 | +.part-tag { | |
| 1348 | + display: inline-flex; | |
| 1349 | + align-items: center; | |
| 1350 | + padding: 2px 8px; | |
| 1351 | + font-size: 0.7rem; | |
| 1352 | + font-weight: 600; | |
| 1353 | + border-radius: var(--radius-full); | |
| 1354 | + white-space: nowrap; | |
| 1355 | +} | |
| 1356 | + | |
| 1357 | +.tag-stagnant { | |
| 1358 | + background: rgba(239, 68, 68, 0.15); | |
| 1359 | + color: var(--color-danger-light); | |
| 1360 | +} | |
| 1361 | + | |
| 1362 | +.tag-low-freq { | |
| 1363 | + background: rgba(245, 158, 11, 0.15); | |
| 1364 | + color: var(--color-warning-light); | |
| 1365 | +} | |
| 1366 | + | |
| 1367 | +.tag-shortage { | |
| 1368 | + background: rgba(249, 115, 22, 0.15); | |
| 1369 | + color: #fb923c; | |
| 1370 | +} | |
| 1371 | + | |
| 1372 | +/* ===================== | |
| 1373 | + Timeline | |
| 1374 | + ===================== */ | |
| 1375 | +.timeline { | |
| 1376 | + position: relative; | |
| 1377 | + padding: var(--spacing-md) 0; | |
| 1378 | +} | |
| 1379 | + | |
| 1380 | +.timeline-item { | |
| 1381 | + display: flex; | |
| 1382 | + gap: var(--spacing-lg); | |
| 1383 | + padding: var(--spacing-md) 0; | |
| 1384 | +} | |
| 1385 | + | |
| 1386 | +.timeline-marker { | |
| 1387 | + display: flex; | |
| 1388 | + flex-direction: column; | |
| 1389 | + align-items: center; | |
| 1390 | + flex-shrink: 0; | |
| 1391 | +} | |
| 1392 | + | |
| 1393 | +.timeline-icon { | |
| 1394 | + width: 36px; | |
| 1395 | + height: 36px; | |
| 1396 | + border-radius: 50%; | |
| 1397 | + display: flex; | |
| 1398 | + align-items: center; | |
| 1399 | + justify-content: center; | |
| 1400 | + z-index: 1; | |
| 1401 | +} | |
| 1402 | + | |
| 1403 | +.timeline-item-success .timeline-icon { | |
| 1404 | + background: rgba(16, 185, 129, 0.15); | |
| 1405 | + color: var(--color-success); | |
| 1406 | +} | |
| 1407 | + | |
| 1408 | +.timeline-item-error .timeline-icon { | |
| 1409 | + background: rgba(239, 68, 68, 0.15); | |
| 1410 | + color: var(--color-danger); | |
| 1411 | +} | |
| 1412 | + | |
| 1413 | +.timeline-icon svg { | |
| 1414 | + width: 18px; | |
| 1415 | + height: 18px; | |
| 1416 | +} | |
| 1417 | + | |
| 1418 | +.timeline-line { | |
| 1419 | + width: 2px; | |
| 1420 | + flex: 1; | |
| 1421 | + min-height: 24px; | |
| 1422 | + background: var(--border-color-light); | |
| 1423 | + margin-top: var(--spacing-sm); | |
| 1424 | +} | |
| 1425 | + | |
| 1426 | +.timeline-content { | |
| 1427 | + flex: 1; | |
| 1428 | + background: var(--bg-surface); | |
| 1429 | + border: 1px solid var(--border-color); | |
| 1430 | + border-radius: var(--radius-md); | |
| 1431 | + padding: var(--spacing-md) var(--spacing-lg); | |
| 1432 | + transition: all var(--transition-fast); | |
| 1433 | +} | |
| 1434 | + | |
| 1435 | +.timeline-content:hover { | |
| 1436 | + border-color: var(--border-color-light); | |
| 1437 | +} | |
| 1438 | + | |
| 1439 | +.timeline-header { | |
| 1440 | + display: flex; | |
| 1441 | + align-items: center; | |
| 1442 | + justify-content: space-between; | |
| 1443 | + gap: var(--spacing-md); | |
| 1444 | + margin-bottom: var(--spacing-sm); | |
| 1445 | +} | |
| 1446 | + | |
| 1447 | +.timeline-title { | |
| 1448 | + font-weight: 600; | |
| 1449 | + font-size: 1rem; | |
| 1450 | +} | |
| 1451 | + | |
| 1452 | +.timeline-meta { | |
| 1453 | + display: flex; | |
| 1454 | + align-items: center; | |
| 1455 | + flex-wrap: wrap; | |
| 1456 | + gap: var(--spacing-lg); | |
| 1457 | + color: var(--text-secondary); | |
| 1458 | + font-size: 0.875rem; | |
| 1459 | +} | |
| 1460 | + | |
| 1461 | +.meta-item { | |
| 1462 | + display: flex; | |
| 1463 | + align-items: center; | |
| 1464 | + gap: var(--spacing-xs); | |
| 1465 | +} | |
| 1466 | + | |
| 1467 | +.meta-item svg { | |
| 1468 | + width: 14px; | |
| 1469 | + height: 14px; | |
| 1470 | +} | |
| 1471 | + | |
| 1472 | +.meta-warning { | |
| 1473 | + color: var(--color-warning); | |
| 1474 | +} | |
| 1475 | + | |
| 1476 | +.timeline-error { | |
| 1477 | + margin-top: var(--spacing-md); | |
| 1478 | + padding: var(--spacing-md); | |
| 1479 | + background: rgba(239, 68, 68, 0.1); | |
| 1480 | + border: 1px solid rgba(239, 68, 68, 0.2); | |
| 1481 | + border-radius: var(--radius-sm); | |
| 1482 | + color: var(--color-danger-light); | |
| 1483 | + font-size: 0.875rem; | |
| 1484 | + display: flex; | |
| 1485 | + align-items: flex-start; | |
| 1486 | + gap: var(--spacing-sm); | |
| 1487 | +} | |
| 1488 | + | |
| 1489 | +.timeline-error svg { | |
| 1490 | + width: 16px; | |
| 1491 | + height: 16px; | |
| 1492 | + flex-shrink: 0; | |
| 1493 | + margin-top: 2px; | |
| 1494 | +} | |
| 1495 | + | |
| 1496 | +/* ===================== | |
| 1497 | + Responsive | |
| 1498 | + ===================== */ | |
| 1499 | +@media (max-width: 1024px) { | |
| 1500 | + .sidebar { | |
| 1501 | + transform: translateX(-100%); | |
| 1502 | + box-shadow: var(--shadow-xl); | |
| 1503 | + } | |
| 1504 | + | |
| 1505 | + .main-content { | |
| 1506 | + margin-left: 0; | |
| 1507 | + } | |
| 1508 | + | |
| 1509 | + .top-bar { | |
| 1510 | + padding: 0 var(--spacing-md); | |
| 1511 | + } | |
| 1512 | + | |
| 1513 | + .page-container { | |
| 1514 | + padding: var(--spacing-md); | |
| 1515 | + } | |
| 1516 | + | |
| 1517 | + .stats-grid { | |
| 1518 | + grid-template-columns: repeat(2, 1fr); | |
| 1519 | + } | |
| 1520 | +} | |
| 1521 | + | |
| 1522 | +/* 笔记本屏幕适配 (1024px - 1440px) */ | |
| 1523 | +@media (min-width: 1024px) and (max-width: 1440px) { | |
| 1524 | + .page-container { | |
| 1525 | + padding: var(--spacing-lg); | |
| 1526 | + } | |
| 1527 | + | |
| 1528 | + .table-container { | |
| 1529 | + overflow: hidden; /* 确保不撑开父容器 */ | |
| 1530 | + } | |
| 1531 | + | |
| 1532 | + .table-wrapper { | |
| 1533 | + width: 100%; | |
| 1534 | + overflow-x: auto; | |
| 1535 | + } | |
| 1536 | + | |
| 1537 | + th, td { | |
| 1538 | + padding: var(--spacing-sm) var(--spacing-md); | |
| 1539 | + font-size: 0.85rem; | |
| 1540 | + } | |
| 1541 | +} | |
| 1542 | + | |
| 1543 | +@media (max-width: 768px) { | |
| 1544 | + .stats-grid { | |
| 1545 | + grid-template-columns: 1fr; | |
| 1546 | + } | |
| 1547 | + | |
| 1548 | + .detail-grid { | |
| 1549 | + grid-template-columns: 1fr; | |
| 1550 | + } | |
| 1551 | + | |
| 1552 | + .table-wrapper { | |
| 1553 | + overflow-x: auto; | |
| 1554 | + } | |
| 1555 | + | |
| 1556 | + th, td { | |
| 1557 | + padding: var(--spacing-sm); | |
| 1558 | + font-size: 0.8rem; | |
| 1559 | + } | |
| 1560 | + | |
| 1561 | + /* 隐藏次要列 */ | |
| 1562 | + @media (max-width: 640px) { | |
| 1563 | + .table-cell-secondary { | |
| 1564 | + display: none; | |
| 1565 | + } | |
| 1566 | + } | |
| 1567 | + | |
| 1568 | + /* 调整面包屑显示 */ | |
| 1569 | + .breadcrumb-item:not(:last-child) { | |
| 1570 | + display: none; | |
| 1571 | + } | |
| 1572 | + .breadcrumb-item:last-child::before { | |
| 1573 | + content: ''; | |
| 1574 | + margin: 0; | |
| 1575 | + } | |
| 1576 | +} | |
| 1577 | + | |
| 1578 | +/* ===================== | |
| 1579 | + Report JSON Sections | |
| 1580 | + ===================== */ | |
| 1581 | +.report-section-header { | |
| 1582 | + display: flex; | |
| 1583 | + align-items: center; | |
| 1584 | + gap: var(--spacing-md); | |
| 1585 | + margin-bottom: var(--spacing-lg); | |
| 1586 | +} | |
| 1587 | + | |
| 1588 | +.report-section-icon { | |
| 1589 | + width: 40px; | |
| 1590 | + height: 40px; | |
| 1591 | + border-radius: var(--radius-md); | |
| 1592 | + display: flex; | |
| 1593 | + align-items: center; | |
| 1594 | + justify-content: center; | |
| 1595 | + flex-shrink: 0; | |
| 1596 | +} | |
| 1597 | + | |
| 1598 | +.report-section-icon svg { | |
| 1599 | + width: 20px; | |
| 1600 | + height: 20px; | |
| 1601 | +} | |
| 1602 | + | |
| 1603 | +.report-section-icon.summary { | |
| 1604 | + background: rgba(99, 102, 241, 0.15); | |
| 1605 | + color: var(--color-primary); | |
| 1606 | +} | |
| 1607 | + | |
| 1608 | +.report-section-icon.analysis { | |
| 1609 | + background: rgba(59, 130, 246, 0.15); | |
| 1610 | + color: var(--color-info); | |
| 1611 | +} | |
| 1612 | + | |
| 1613 | +.report-section-icon.risk { | |
| 1614 | + background: rgba(245, 158, 11, 0.15); | |
| 1615 | + color: var(--color-warning); | |
| 1616 | +} | |
| 1617 | + | |
| 1618 | +.report-section-icon.recommendation { | |
| 1619 | + background: rgba(16, 185, 129, 0.15); | |
| 1620 | + color: var(--color-success); | |
| 1621 | +} | |
| 1622 | + | |
| 1623 | +.report-section-icon.optimization { | |
| 1624 | + background: rgba(139, 92, 246, 0.15); | |
| 1625 | + color: #a78bfa; | |
| 1626 | +} | |
| 1627 | + | |
| 1628 | +.report-section-title { | |
| 1629 | + font-size: 1.125rem; | |
| 1630 | + font-weight: 600; | |
| 1631 | +} | |
| 1632 | + | |
| 1633 | +/* Summary Items */ | |
| 1634 | +.summary-items { | |
| 1635 | + display: grid; | |
| 1636 | + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| 1637 | + gap: var(--spacing-md); | |
| 1638 | + margin-bottom: var(--spacing-lg); | |
| 1639 | +} | |
| 1640 | + | |
| 1641 | +.summary-item { | |
| 1642 | + background: var(--bg-surface); | |
| 1643 | + border: 1px solid var(--border-color); | |
| 1644 | + border-radius: var(--radius-md); | |
| 1645 | + padding: var(--spacing-md); | |
| 1646 | +} | |
| 1647 | + | |
| 1648 | +.summary-item-label { | |
| 1649 | + font-size: 0.75rem; | |
| 1650 | + color: var(--text-secondary); | |
| 1651 | + margin-bottom: var(--spacing-xs); | |
| 1652 | +} | |
| 1653 | + | |
| 1654 | +.summary-item-value { | |
| 1655 | + font-size: 1.25rem; | |
| 1656 | + font-weight: 600; | |
| 1657 | +} | |
| 1658 | + | |
| 1659 | +.summary-item.status-normal .summary-item-value { color: var(--text-primary); } | |
| 1660 | +.summary-item.status-highlight .summary-item-value { color: var(--color-primary-light); } | |
| 1661 | +.summary-item.status-warning .summary-item-value { color: var(--color-warning); } | |
| 1662 | +.summary-item.status-danger .summary-item-value { color: var(--color-danger); } | |
| 1663 | + | |
| 1664 | +.summary-text { | |
| 1665 | + color: var(--text-secondary); | |
| 1666 | + line-height: 1.6; | |
| 1667 | +} | |
| 1668 | + | |
| 1669 | +/* Risk Items */ | |
| 1670 | +.risk-list { | |
| 1671 | + display: grid; | |
| 1672 | + gap: var(--spacing-md); | |
| 1673 | +} | |
| 1674 | + | |
| 1675 | +.risk-item { | |
| 1676 | + display: flex; | |
| 1677 | + gap: var(--spacing-md); | |
| 1678 | + padding: var(--spacing-md); | |
| 1679 | + background: var(--bg-surface); | |
| 1680 | + border-radius: var(--radius-md); | |
| 1681 | + border-left: 4px solid; | |
| 1682 | +} | |
| 1683 | + | |
| 1684 | +.risk-item.level-high { border-left-color: var(--color-danger); } | |
| 1685 | +.risk-item.level-medium { border-left-color: var(--color-warning); } | |
| 1686 | +.risk-item.level-low { border-left-color: var(--color-success); } | |
| 1687 | + | |
| 1688 | +.risk-level { | |
| 1689 | + display: inline-flex; | |
| 1690 | + align-items: center; | |
| 1691 | + padding: var(--spacing-xs) var(--spacing-sm); | |
| 1692 | + border-radius: var(--radius-sm); | |
| 1693 | + font-size: 0.7rem; | |
| 1694 | + font-weight: 600; | |
| 1695 | + text-transform: uppercase; | |
| 1696 | + flex-shrink: 0; | |
| 1697 | + height: fit-content; | |
| 1698 | +} | |
| 1699 | + | |
| 1700 | +.risk-level.high { | |
| 1701 | + background: rgba(239, 68, 68, 0.15); | |
| 1702 | + color: var(--color-danger-light); | |
| 1703 | +} | |
| 1704 | + | |
| 1705 | +.risk-level.medium { | |
| 1706 | + background: rgba(245, 158, 11, 0.15); | |
| 1707 | + color: var(--color-warning-light); | |
| 1708 | +} | |
| 1709 | + | |
| 1710 | +.risk-level.low { | |
| 1711 | + background: rgba(16, 185, 129, 0.15); | |
| 1712 | + color: var(--color-success-light); | |
| 1713 | +} | |
| 1714 | + | |
| 1715 | +.risk-content { | |
| 1716 | + flex: 1; | |
| 1717 | +} | |
| 1718 | + | |
| 1719 | +.risk-category { | |
| 1720 | + font-weight: 600; | |
| 1721 | + margin-bottom: var(--spacing-xs); | |
| 1722 | +} | |
| 1723 | + | |
| 1724 | +.risk-description { | |
| 1725 | + color: var(--text-secondary); | |
| 1726 | + font-size: 0.875rem; | |
| 1727 | +} | |
| 1728 | + | |
| 1729 | +/* Recommendation Items */ | |
| 1730 | +.recommendation-list { | |
| 1731 | + display: grid; | |
| 1732 | + gap: var(--spacing-md); | |
| 1733 | +} | |
| 1734 | + | |
| 1735 | +.recommendation-item { | |
| 1736 | + display: flex; | |
| 1737 | + gap: var(--spacing-md); | |
| 1738 | + padding: var(--spacing-md); | |
| 1739 | + background: var(--bg-surface); | |
| 1740 | + border: 1px solid var(--border-color); | |
| 1741 | + border-radius: var(--radius-md); | |
| 1742 | +} | |
| 1743 | + | |
| 1744 | +.recommendation-priority { | |
| 1745 | + width: 28px; | |
| 1746 | + height: 28px; | |
| 1747 | + border-radius: 50%; | |
| 1748 | + display: flex; | |
| 1749 | + align-items: center; | |
| 1750 | + justify-content: center; | |
| 1751 | + font-size: 0.875rem; | |
| 1752 | + font-weight: 700; | |
| 1753 | + flex-shrink: 0; | |
| 1754 | +} | |
| 1755 | + | |
| 1756 | +.recommendation-priority.priority-1 { | |
| 1757 | + background: var(--color-danger); | |
| 1758 | + color: white; | |
| 1759 | +} | |
| 1760 | + | |
| 1761 | +.recommendation-priority.priority-2 { | |
| 1762 | + background: var(--color-warning); | |
| 1763 | + color: white; | |
| 1764 | +} | |
| 1765 | + | |
| 1766 | +.recommendation-priority.priority-3 { | |
| 1767 | + background: var(--color-info); | |
| 1768 | + color: white; | |
| 1769 | +} | |
| 1770 | + | |
| 1771 | +.recommendation-content { | |
| 1772 | + flex: 1; | |
| 1773 | +} | |
| 1774 | + | |
| 1775 | +.recommendation-action { | |
| 1776 | + font-weight: 600; | |
| 1777 | + margin-bottom: var(--spacing-xs); | |
| 1778 | +} | |
| 1779 | + | |
| 1780 | +.recommendation-reason { | |
| 1781 | + color: var(--text-secondary); | |
| 1782 | + font-size: 0.875rem; | |
| 1783 | +} | |
| 1784 | + | |
| 1785 | +/* Suggestion Items */ | |
| 1786 | +.suggestion-list { | |
| 1787 | + display: grid; | |
| 1788 | + gap: var(--spacing-sm); | |
| 1789 | +} | |
| 1790 | + | |
| 1791 | +.suggestion-item { | |
| 1792 | + display: flex; | |
| 1793 | + align-items: flex-start; | |
| 1794 | + gap: var(--spacing-md); | |
| 1795 | + padding: var(--spacing-md); | |
| 1796 | + background: var(--bg-surface); | |
| 1797 | + border-radius: var(--radius-md); | |
| 1798 | +} | |
| 1799 | + | |
| 1800 | +.suggestion-icon { | |
| 1801 | + color: var(--color-success); | |
| 1802 | + flex-shrink: 0; | |
| 1803 | +} | |
| 1804 | + | |
| 1805 | +.suggestion-icon svg { | |
| 1806 | + width: 18px; | |
| 1807 | + height: 18px; | |
| 1808 | +} | |
| 1809 | + | |
| 1810 | +.suggestion-text { | |
| 1811 | + flex: 1; | |
| 1812 | + line-height: 1.5; | |
| 1813 | +} | |
| 1814 | + | |
| 1815 | +/* Highlight Items */ | |
| 1816 | +.highlight-list { | |
| 1817 | + display: grid; | |
| 1818 | + gap: var(--spacing-sm); | |
| 1819 | + margin-top: var(--spacing-md); | |
| 1820 | +} | |
| 1821 | + | |
| 1822 | +.highlight-item { | |
| 1823 | + display: flex; | |
| 1824 | + justify-content: space-between; | |
| 1825 | + align-items: center; | |
| 1826 | + padding: var(--spacing-sm) var(--spacing-md); | |
| 1827 | + background: rgba(99, 102, 241, 0.1); | |
| 1828 | + border-radius: var(--radius-sm); | |
| 1829 | +} | |
| 1830 | + | |
| 1831 | +.highlight-label { | |
| 1832 | + color: var(--text-secondary); | |
| 1833 | + font-size: 0.875rem; | |
| 1834 | +} | |
| 1835 | + | |
| 1836 | +.highlight-value { | |
| 1837 | + font-weight: 600; | |
| 1838 | + color: var(--color-primary-light); | |
| 1839 | +} | |
| 1840 | + | |
| 1841 | +/* Analysis Paragraphs */ | |
| 1842 | +.analysis-paragraphs { | |
| 1843 | + display: grid; | |
| 1844 | + gap: var(--spacing-md); | |
| 1845 | +} | |
| 1846 | + | |
| 1847 | +.analysis-paragraph { | |
| 1848 | + color: var(--text-secondary); | |
| 1849 | + line-height: 1.7; | |
| 1850 | +} | |
| 1851 | + | |
| 1852 | +/* ===================== | |
| 1853 | + Risk Section | |
| 1854 | + ===================== */ | |
| 1855 | +.risk-grid { | |
| 1856 | + display: grid; | |
| 1857 | + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| 1858 | + gap: var(--spacing-lg); | |
| 1859 | + margin-top: var(--spacing-md); | |
| 1860 | +} | |
| 1861 | + | |
| 1862 | +.risk-card { | |
| 1863 | + background: var(--bg-surface); | |
| 1864 | + border: 1px solid var(--border-color); | |
| 1865 | + border-radius: var(--radius-lg); | |
| 1866 | + padding: var(--spacing-lg); | |
| 1867 | + transition: all var(--transition-base); | |
| 1868 | + display: flex; | |
| 1869 | + flex-direction: column; | |
| 1870 | + height: 100%; | |
| 1871 | + position: relative; | |
| 1872 | + overflow: hidden; | |
| 1873 | +} | |
| 1874 | + | |
| 1875 | +.risk-card::before { | |
| 1876 | + content: ''; | |
| 1877 | + position: absolute; | |
| 1878 | + top: 0; | |
| 1879 | + left: 0; | |
| 1880 | + width: 100%; | |
| 1881 | + height: 4px; | |
| 1882 | + background: var(--border-color); | |
| 1883 | + transition: background var(--transition-base); | |
| 1884 | +} | |
| 1885 | + | |
| 1886 | +.risk-card:hover { | |
| 1887 | + transform: translateY(-4px); | |
| 1888 | + box-shadow: var(--shadow-lg); | |
| 1889 | + border-color: var(--border-color-light); | |
| 1890 | +} | |
| 1891 | + | |
| 1892 | +/* Severity Styles */ | |
| 1893 | +.risk-card.level-high { | |
| 1894 | + background: linear-gradient(180deg, rgba(239, 68, 68, 0.05) 0%, rgba(239, 68, 68, 0.01) 100%); | |
| 1895 | + border-color: rgba(239, 68, 68, 0.2); | |
| 1896 | +} | |
| 1897 | +.risk-card.level-high::before { background: var(--color-danger); } | |
| 1898 | + | |
| 1899 | +.risk-card.level-medium { | |
| 1900 | + background: linear-gradient(180deg, rgba(245, 158, 11, 0.05) 0%, rgba(245, 158, 11, 0.01) 100%); | |
| 1901 | + border-color: rgba(245, 158, 11, 0.2); | |
| 1902 | +} | |
| 1903 | +.risk-card.level-medium::before { background: var(--color-warning); } | |
| 1904 | + | |
| 1905 | +.risk-card.level-low { | |
| 1906 | + background: linear-gradient(180deg, rgba(59, 130, 246, 0.05) 0%, rgba(59, 130, 246, 0.01) 100%); | |
| 1907 | + border-color: rgba(59, 130, 246, 0.2); | |
| 1908 | +} | |
| 1909 | +.risk-card.level-low::before { background: var(--color-info); } | |
| 1910 | + | |
| 1911 | +/* Header Elements */ | |
| 1912 | +.risk-card-header { | |
| 1913 | + display: flex; | |
| 1914 | + align-items: center; | |
| 1915 | + justify-content: space-between; | |
| 1916 | + margin-bottom: var(--spacing-md); | |
| 1917 | +} | |
| 1918 | + | |
| 1919 | +.risk-badge { | |
| 1920 | + font-size: 0.75rem; | |
| 1921 | + font-weight: 700; | |
| 1922 | + padding: 4px 10px; | |
| 1923 | + border-radius: var(--radius-full); | |
| 1924 | + text-transform: uppercase; | |
| 1925 | + letter-spacing: 0.05em; | |
| 1926 | + display: flex; | |
| 1927 | + align-items: center; | |
| 1928 | + gap: 4px; | |
| 1929 | +} | |
| 1930 | + | |
| 1931 | +.risk-badge::before { | |
| 1932 | + content: ''; | |
| 1933 | + width: 6px; | |
| 1934 | + height: 6px; | |
| 1935 | + border-radius: 50%; | |
| 1936 | + background: currentColor; | |
| 1937 | +} | |
| 1938 | + | |
| 1939 | +.risk-badge.high { | |
| 1940 | + color: var(--color-danger); | |
| 1941 | + background: rgba(239, 68, 68, 0.1); | |
| 1942 | + border: 1px solid rgba(239, 68, 68, 0.2); | |
| 1943 | +} | |
| 1944 | + | |
| 1945 | +.risk-badge.medium { | |
| 1946 | + color: var(--color-warning); | |
| 1947 | + background: rgba(245, 158, 11, 0.1); | |
| 1948 | + border: 1px solid rgba(245, 158, 11, 0.2); | |
| 1949 | +} | |
| 1950 | + | |
| 1951 | +.risk-badge.low { | |
| 1952 | + color: var(--color-info); | |
| 1953 | + background: rgba(59, 130, 246, 0.1); | |
| 1954 | + border: 1px solid rgba(59, 130, 246, 0.2); | |
| 1955 | +} | |
| 1956 | + | |
| 1957 | +.risk-category-tag { | |
| 1958 | + font-size: 0.75rem; | |
| 1959 | + color: var(--text-secondary); | |
| 1960 | + background: var(--bg-elevated); | |
| 1961 | + padding: 4px 8px; | |
| 1962 | + border-radius: var(--radius-md); | |
| 1963 | + border: 1px solid var(--border-color); | |
| 1964 | +} | |
| 1965 | + | |
| 1966 | +.risk-card-content { | |
| 1967 | + flex: 1; | |
| 1968 | +} | |
| 1969 | + | |
| 1970 | +.risk-description { | |
| 1971 | + font-size: 0.95rem; | |
| 1972 | + color: var(--text-primary); | |
| 1973 | + line-height: 1.6; | |
| 1974 | +} | |
| 1975 | + | |
| 1976 | +/* ===================== | |
| 1977 | + Analysis Section | |
| 1978 | + ===================== */ | |
| 1979 | +.analysis-highlights { | |
| 1980 | + display: grid; | |
| 1981 | + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| 1982 | + gap: var(--spacing-md); | |
| 1983 | + margin: var(--spacing-lg) 0; | |
| 1984 | +} | |
| 1985 | + | |
| 1986 | +.analysis-highlight-card { | |
| 1987 | + background: var(--bg-elevated); | |
| 1988 | + padding: var(--spacing-md); | |
| 1989 | + border-radius: var(--radius-md); | |
| 1990 | + display: flex; | |
| 1991 | + flex-direction: column; | |
| 1992 | + gap: 4px; | |
| 1993 | +} | |
| 1994 | + | |
| 1995 | +.highlight-label { | |
| 1996 | + font-size: 0.75rem; | |
| 1997 | + color: var(--text-secondary); | |
| 1998 | +} | |
| 1999 | + | |
| 2000 | +.highlight-value { | |
| 2001 | + font-size: 1.25rem; | |
| 2002 | + font-weight: 600; | |
| 2003 | + color: var(--text-primary); | |
| 2004 | +} | |
| 2005 | + | |
| 2006 | +.analysis-paragraphs { | |
| 2007 | + display: flex; | |
| 2008 | + flex-direction: column; | |
| 2009 | + gap: var(--spacing-sm); | |
| 2010 | +} | |
| 2011 | + | |
| 2012 | +.analysis-paragraph { | |
| 2013 | + font-size: 0.95rem; | |
| 2014 | + color: var(--text-secondary); | |
| 2015 | + line-height: 1.6; | |
| 2016 | + text-align: justify; | |
| 2017 | +} | |
| 2018 | + | |
| 2019 | +/* ===================== | |
| 2020 | + Table Sorting | |
| 2021 | + ===================== */ | |
| 2022 | +.sortable-th { | |
| 2023 | + cursor: pointer; | |
| 2024 | + user-select: none; | |
| 2025 | + transition: background var(--transition-fast), color var(--transition-fast); | |
| 2026 | +} | |
| 2027 | + | |
| 2028 | +.sortable-th:hover { | |
| 2029 | + background: var(--bg-hover); | |
| 2030 | + color: var(--text-primary); | |
| 2031 | +} | |
| 2032 | + | |
| 2033 | +.sort-icon { | |
| 2034 | + width: 14px; | |
| 2035 | + height: 14px; | |
| 2036 | + vertical-align: middle; | |
| 2037 | + margin-left: var(--spacing-xs); | |
| 2038 | +} | |
| 2039 | + | |
| 2040 | +.sort-icon-inactive { | |
| 2041 | + opacity: 0.3; | |
| 2042 | +} | |
| 2043 | + | |
| 2044 | +.sort-icon-active { | |
| 2045 | + color: var(--color-primary); | |
| 2046 | + opacity: 1; | |
| 2047 | +} | |
| 2048 | + | |
| 2049 | +.table-header-hint { | |
| 2050 | + display: flex; | |
| 2051 | + align-items: center; | |
| 2052 | + gap: var(--spacing-xs); | |
| 2053 | + font-size: 0.75rem; | |
| 2054 | + color: var(--text-muted); | |
| 2055 | +} | ... | ... |
ui/index.html
0 → 100644
| 1 | +++ a/ui/index.html | |
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>AI 补货建议系统</title> | |
| 7 | + <meta name="description" content="AI 驱动的智能补货建议管理系统"> | |
| 8 | + | |
| 9 | + <!-- Fonts --> | |
| 10 | + <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| 11 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| 12 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| 13 | + | |
| 14 | + <!-- Icons --> | |
| 15 | + <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script> | |
| 16 | + | |
| 17 | + <!-- Markdown Parser --> | |
| 18 | + <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| 19 | + | |
| 20 | + <!-- Styles --> | |
| 21 | + <link rel="stylesheet" href="/css/style.css"> | |
| 22 | +</head> | |
| 23 | +<body> | |
| 24 | + <!-- 侧边栏 --> | |
| 25 | + <aside class="sidebar"> | |
| 26 | + <div class="sidebar-header"> | |
| 27 | + <div class="logo"> | |
| 28 | + <i data-lucide="cpu" class="logo-icon"></i> | |
| 29 | + <span class="logo-text">AI 补货系统</span> | |
| 30 | + </div> | |
| 31 | + <button class="btn btn-icon btn-sm sidebar-collapse-btn" id="sidebar-collapse-btn" title="收起/展开菜单"> | |
| 32 | + <i data-lucide="chevron-left"></i> | |
| 33 | + </button> | |
| 34 | + </div> | |
| 35 | + | |
| 36 | + <nav class="sidebar-nav"> | |
| 37 | + <a href="#/" class="nav-item active" data-page="dashboard"> | |
| 38 | + <i data-lucide="layout-dashboard"></i> | |
| 39 | + <span>概览</span> | |
| 40 | + </a> | |
| 41 | + <a href="#/tasks" class="nav-item" data-page="tasks"> | |
| 42 | + <i data-lucide="list-checks"></i> | |
| 43 | + <span>任务列表</span> | |
| 44 | + </a> | |
| 45 | + </nav> | |
| 46 | + | |
| 47 | + <div class="sidebar-footer"> | |
| 48 | + <div class="version-info"> | |
| 49 | + <span>v1.0.0</span> | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + </aside> | |
| 53 | + | |
| 54 | + <!-- 侧边栏遮罩 --> | |
| 55 | + <div class="sidebar-overlay" id="sidebar-overlay"></div> | |
| 56 | + | |
| 57 | + <!-- 主内容区 --> | |
| 58 | + <main class="main-content"> | |
| 59 | + <!-- 顶部导航栏 --> | |
| 60 | + <header class="top-bar"> | |
| 61 | + <!-- 移动端菜单按钮 --> | |
| 62 | + <button class="btn btn-icon menu-toggle" id="menu-toggle"> | |
| 63 | + <i data-lucide="menu"></i> | |
| 64 | + </button> | |
| 65 | + | |
| 66 | + <div class="breadcrumb" id="breadcrumb"> | |
| 67 | + <span class="breadcrumb-item">首页</span> | |
| 68 | + </div> | |
| 69 | + <div class="top-bar-actions"> | |
| 70 | + <button class="btn btn-icon" id="refresh-btn" title="刷新"> | |
| 71 | + <i data-lucide="refresh-cw"></i> | |
| 72 | + </button> | |
| 73 | + </div> | |
| 74 | + </header> | |
| 75 | + | |
| 76 | + <!-- 页面内容容器 --> | |
| 77 | + <div class="page-container" id="page-container"> | |
| 78 | + <!-- 动态加载的页面内容 --> | |
| 79 | + </div> | |
| 80 | + </main> | |
| 81 | + | |
| 82 | + <!-- 加载遮罩 --> | |
| 83 | + <div class="loading-overlay" id="loading-overlay"> | |
| 84 | + <div class="loading-spinner"> | |
| 85 | + <i data-lucide="loader-2" class="spin"></i> | |
| 86 | + <span>加载中...</span> | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + | |
| 90 | + <!-- Toast 通知容器 --> | |
| 91 | + <div class="toast-container" id="toast-container"></div> | |
| 92 | + | |
| 93 | + <!-- 模态框容器 --> | |
| 94 | + <div class="modal-overlay" id="modal-overlay"> | |
| 95 | + <div class="modal" id="modal"> | |
| 96 | + <div class="modal-header"> | |
| 97 | + <h3 class="modal-title" id="modal-title"></h3> | |
| 98 | + <button class="btn btn-icon modal-close" id="modal-close"> | |
| 99 | + <i data-lucide="x"></i> | |
| 100 | + </button> | |
| 101 | + </div> | |
| 102 | + <div class="modal-body" id="modal-body"></div> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 105 | + | |
| 106 | + <!-- Scripts --> | |
| 107 | + <script src="/js/api.js"></script> | |
| 108 | + <script src="/js/components.js"></script> | |
| 109 | + <script src="/js/app.js"></script> | |
| 110 | +</body> | |
| 111 | +</html> | ... | ... |
ui/js/api.js
0 → 100644
| 1 | +++ a/ui/js/api.js | |
| 1 | +/** | |
| 2 | + * API 调用封装 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const API = { | |
| 6 | + baseUrl: '/api', | |
| 7 | + | |
| 8 | + /** | |
| 9 | + * 通用请求方法 | |
| 10 | + */ | |
| 11 | + async request(endpoint, options = {}) { | |
| 12 | + const url = `${this.baseUrl}${endpoint}`; | |
| 13 | + | |
| 14 | + try { | |
| 15 | + const response = await fetch(url, { | |
| 16 | + ...options, | |
| 17 | + headers: { | |
| 18 | + 'Content-Type': 'application/json', | |
| 19 | + ...options.headers, | |
| 20 | + }, | |
| 21 | + }); | |
| 22 | + | |
| 23 | + if (!response.ok) { | |
| 24 | + const error = await response.json().catch(() => ({})); | |
| 25 | + throw new Error(error.detail || `请求失败: ${response.status}`); | |
| 26 | + } | |
| 27 | + | |
| 28 | + return await response.json(); | |
| 29 | + } catch (error) { | |
| 30 | + console.error('API Error:', error); | |
| 31 | + throw error; | |
| 32 | + } | |
| 33 | + }, | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * GET 请求 | |
| 37 | + */ | |
| 38 | + async get(endpoint, params = {}) { | |
| 39 | + const queryString = new URLSearchParams(params).toString(); | |
| 40 | + const url = queryString ? `${endpoint}?${queryString}` : endpoint; | |
| 41 | + return this.request(url); | |
| 42 | + }, | |
| 43 | + | |
| 44 | + /** | |
| 45 | + * 获取任务列表 | |
| 46 | + */ | |
| 47 | + async getTasks(params = {}) { | |
| 48 | + return this.get('/tasks', params); | |
| 49 | + }, | |
| 50 | + | |
| 51 | + /** | |
| 52 | + * 获取任务详情 | |
| 53 | + */ | |
| 54 | + async getTask(taskNo) { | |
| 55 | + return this.get(`/tasks/${taskNo}`); | |
| 56 | + }, | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * 获取任务配件明细 | |
| 60 | + */ | |
| 61 | + async getTaskDetails(taskNo, params = {}) { | |
| 62 | + return this.get(`/tasks/${taskNo}/details`, params); | |
| 63 | + }, | |
| 64 | + | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * 获取统计摘要 | |
| 68 | + */ | |
| 69 | + async getStatsSummary() { | |
| 70 | + return this.get('/stats/summary'); | |
| 71 | + }, | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * 获取任务执行日志 | |
| 75 | + */ | |
| 76 | + async getTaskLogs(taskNo) { | |
| 77 | + return this.get(`/tasks/${taskNo}/logs`); | |
| 78 | + }, | |
| 79 | + | |
| 80 | + /** | |
| 81 | + * 获取任务配件汇总列表 | |
| 82 | + */ | |
| 83 | + async getPartSummaries(taskNo, params = {}) { | |
| 84 | + return this.get(`/tasks/${taskNo}/part-summaries`, params); | |
| 85 | + }, | |
| 86 | + | |
| 87 | + /** | |
| 88 | + * 获取配件的门店明细 | |
| 89 | + */ | |
| 90 | + async getPartShopDetails(taskNo, partCode) { | |
| 91 | + return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`); | |
| 92 | + }, | |
| 93 | +}; | |
| 94 | + | |
| 95 | +// 导出到全局 | |
| 96 | +window.API = API; | ... | ... |
ui/js/app.js
0 → 100644
| 1 | +++ a/ui/js/app.js | |
| 1 | +/** | |
| 2 | + * 主应用逻辑 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const App = { | |
| 6 | + currentPage: 'dashboard', | |
| 7 | + currentTaskNo: null, | |
| 8 | + | |
| 9 | + // 配件汇总表排序状态 | |
| 10 | + partSummarySort: { | |
| 11 | + sortBy: 'total_suggest_amount', | |
| 12 | + sortOrder: 'desc' | |
| 13 | + }, | |
| 14 | + | |
| 15 | + // 配件汇总表筛选状态 | |
| 16 | + partSummaryFilters: { | |
| 17 | + page: 1, | |
| 18 | + page_size: 50, | |
| 19 | + part_code: '', | |
| 20 | + priority: '' | |
| 21 | + }, | |
| 22 | + | |
| 23 | + // 侧边栏折叠状态 | |
| 24 | + isSidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true', | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * 切换侧边栏折叠状态 | |
| 28 | + */ | |
| 29 | + toggleSidebarCollapse() { | |
| 30 | + this.isSidebarCollapsed = !this.isSidebarCollapsed; | |
| 31 | + localStorage.setItem('sidebar-collapsed', this.isSidebarCollapsed); | |
| 32 | + this.applySidebarState(); | |
| 33 | + }, | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * 应用侧边栏状态 | |
| 37 | + */ | |
| 38 | + applySidebarState() { | |
| 39 | + const sidebar = document.querySelector('.sidebar'); | |
| 40 | + // 主内容区通过 CSS 选择器 .sidebar.collapsed + ... .main-content 自动调整 | |
| 41 | + // 或者我们需要手动给 main-content 加类,但 CSS 中用了兄弟选择器, | |
| 42 | + // 不过兄弟选择器在这里可能不生效,因为中间隔了 .sidebar-overlay | |
| 43 | + // 让我们看看 index.html 结构: sidebar, sidebar-overlay, main-content | |
| 44 | + // CSS 选择器是 .sidebar.collapsed + .sidebar-overlay + .main-content | |
| 45 | + // 这样是可以的。 | |
| 46 | + | |
| 47 | + if (this.isSidebarCollapsed) { | |
| 48 | + sidebar.classList.add('collapsed'); | |
| 49 | + } else { | |
| 50 | + sidebar.classList.remove('collapsed'); | |
| 51 | + } | |
| 52 | + | |
| 53 | + // 触发 icon 刷新以确保显示正确(虽然 CSS 旋转已处理) | |
| 54 | + lucide.createIcons(); | |
| 55 | + }, | |
| 56 | + | |
| 57 | + /** | |
| 58 | + * 切换侧边栏显示状态(移动端) | |
| 59 | + */ | |
| 60 | + toggleSidebar(show) { | |
| 61 | + const sidebar = document.querySelector('.sidebar'); | |
| 62 | + const overlay = document.getElementById('sidebar-overlay'); | |
| 63 | + | |
| 64 | + if (show) { | |
| 65 | + sidebar.classList.add('visible'); | |
| 66 | + overlay.classList.add('active'); | |
| 67 | + } else { | |
| 68 | + sidebar.classList.remove('visible'); | |
| 69 | + overlay.classList.remove('active'); | |
| 70 | + } | |
| 71 | + }, | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * 应用配件筛选 | |
| 75 | + */ | |
| 76 | + applyPartFilters() { | |
| 77 | + const partCode = document.getElementById('filter-part-code')?.value || ''; | |
| 78 | + const priorityElement = document.getElementById('filter-priority'); | |
| 79 | + const priority = priorityElement ? priorityElement.value : ''; | |
| 80 | + | |
| 81 | + this.partSummaryFilters.part_code = partCode; | |
| 82 | + this.partSummaryFilters.priority = priority; | |
| 83 | + this.partSummaryFilters.page = 1; // 重置到第一页 | |
| 84 | + | |
| 85 | + this.loadPartSummaries(); | |
| 86 | + }, | |
| 87 | + | |
| 88 | + /** | |
| 89 | + * 重置配件筛选 | |
| 90 | + */ | |
| 91 | + resetPartFilters() { | |
| 92 | + this.partSummaryFilters.part_code = ''; | |
| 93 | + this.partSummaryFilters.priority = ''; | |
| 94 | + this.partSummaryFilters.page = 1; | |
| 95 | + | |
| 96 | + this.loadPartSummaries(); | |
| 97 | + }, | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * 初始化应用 | |
| 101 | + */ | |
| 102 | + init() { | |
| 103 | + this.bindEvents(); | |
| 104 | + this.handleRoute(); | |
| 105 | + this.applySidebarState(); // 初始化侧边栏状态 | |
| 106 | + window.addEventListener('hashchange', () => this.handleRoute()); | |
| 107 | + lucide.createIcons(); | |
| 108 | + }, | |
| 109 | + | |
| 110 | + /** | |
| 111 | + * 绑定全局事件 | |
| 112 | + */ | |
| 113 | + bindEvents() { | |
| 114 | + // 刷新按钮 | |
| 115 | + document.getElementById('refresh-btn').addEventListener('click', () => { | |
| 116 | + this.handleRoute(); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + // 模态框关闭 | |
| 120 | + document.getElementById('modal-close').addEventListener('click', () => { | |
| 121 | + Components.closeModal(); | |
| 122 | + }); | |
| 123 | + document.getElementById('modal-overlay').addEventListener('click', (e) => { | |
| 124 | + if (e.target.id === 'modal-overlay') { | |
| 125 | + Components.closeModal(); | |
| 126 | + } | |
| 127 | + }); | |
| 128 | + | |
| 129 | + // 侧边栏切换 | |
| 130 | + const menuToggle = document.getElementById('menu-toggle'); | |
| 131 | + const sidebarOverlay = document.getElementById('sidebar-overlay'); | |
| 132 | + | |
| 133 | + if (menuToggle) { | |
| 134 | + menuToggle.addEventListener('click', () => { | |
| 135 | + this.toggleSidebar(true); | |
| 136 | + }); | |
| 137 | + } | |
| 138 | + | |
| 139 | + if (sidebarOverlay) { | |
| 140 | + sidebarOverlay.addEventListener('click', () => { | |
| 141 | + this.toggleSidebar(false); | |
| 142 | + }); | |
| 143 | + } | |
| 144 | + | |
| 145 | + // 桌面端侧边栏折叠按钮 | |
| 146 | + const collapseBtn = document.getElementById('sidebar-collapse-btn'); | |
| 147 | + if (collapseBtn) { | |
| 148 | + collapseBtn.addEventListener('click', () => { | |
| 149 | + this.toggleSidebarCollapse(); | |
| 150 | + }); | |
| 151 | + } | |
| 152 | + | |
| 153 | + // 导航点击自动关闭侧边栏(移动端) | |
| 154 | + document.querySelectorAll('.nav-item').forEach(item => { | |
| 155 | + item.addEventListener('click', () => { | |
| 156 | + if (window.innerWidth <= 1024) { | |
| 157 | + this.toggleSidebar(false); | |
| 158 | + } | |
| 159 | + }); | |
| 160 | + }); | |
| 161 | + }, | |
| 162 | + | |
| 163 | + /** | |
| 164 | + * 路由处理 | |
| 165 | + */ | |
| 166 | + handleRoute() { | |
| 167 | + const hash = window.location.hash || '#/'; | |
| 168 | + const [, path, param] = hash.match(/#\/([^/]*)(?:\/(.*))?/) || [, '', '']; | |
| 169 | + | |
| 170 | + // 更新导航状态 | |
| 171 | + document.querySelectorAll('.nav-item').forEach(item => { | |
| 172 | + item.classList.remove('active'); | |
| 173 | + if (item.dataset.page === (path || 'dashboard')) { | |
| 174 | + item.classList.add('active'); | |
| 175 | + } | |
| 176 | + }); | |
| 177 | + | |
| 178 | + // 路由分发 | |
| 179 | + switch (path) { | |
| 180 | + case 'tasks': | |
| 181 | + if (param) { | |
| 182 | + this.showTaskDetail(param); | |
| 183 | + } else { | |
| 184 | + this.showTaskList(); | |
| 185 | + } | |
| 186 | + break; | |
| 187 | + case '': | |
| 188 | + default: | |
| 189 | + this.showDashboard(); | |
| 190 | + break; | |
| 191 | + } | |
| 192 | + }, | |
| 193 | + | |
| 194 | + /** | |
| 195 | + * 更新面包屑 | |
| 196 | + */ | |
| 197 | + updateBreadcrumb(items) { | |
| 198 | + const breadcrumb = document.getElementById('breadcrumb'); | |
| 199 | + breadcrumb.innerHTML = items.map((item, index) => { | |
| 200 | + if (item.href) { | |
| 201 | + return `<span class="breadcrumb-item"><a href="${item.href}">${item.text}</a></span>`; | |
| 202 | + } | |
| 203 | + return `<span class="breadcrumb-item">${item.text}</span>`; | |
| 204 | + }).join(''); | |
| 205 | + }, | |
| 206 | + | |
| 207 | + /** | |
| 208 | + * 显示概览页面 | |
| 209 | + */ | |
| 210 | + async showDashboard() { | |
| 211 | + this.currentPage = 'dashboard'; | |
| 212 | + this.updateBreadcrumb([{ text: '概览' }]); | |
| 213 | + | |
| 214 | + const container = document.getElementById('page-container'); | |
| 215 | + container.innerHTML = '<div class="stats-grid" id="stats-grid"></div><div id="recent-tasks"></div>'; | |
| 216 | + | |
| 217 | + try { | |
| 218 | + // 获取统计数据 | |
| 219 | + const [stats, tasksData] = await Promise.all([ | |
| 220 | + API.getStatsSummary().catch(() => ({})), | |
| 221 | + API.getTasks({ page: 1, page_size: 5 }).catch(() => ({ items: [] })), | |
| 222 | + ]); | |
| 223 | + | |
| 224 | + // 渲染统计卡片 | |
| 225 | + const statsGrid = document.getElementById('stats-grid'); | |
| 226 | + statsGrid.innerHTML = ` | |
| 227 | + ${Components.renderStatCard('list-checks', '总任务数', stats.total_tasks || 0, 'primary')} | |
| 228 | + ${Components.renderStatCard('check-circle', '成功任务', stats.success_tasks || 0, 'success')} | |
| 229 | + ${Components.renderStatCard('x-circle', '失败任务', stats.failed_tasks || 0, 'danger')} | |
| 230 | + ${Components.renderStatCard('package', '建议配件', stats.total_parts || 0, 'info')} | |
| 231 | + ${Components.renderStatCard('dollar-sign', '建议金额', Components.formatAmount(stats.total_suggest_amount), 'warning')} | |
| 232 | + `; | |
| 233 | + | |
| 234 | + // 渲染最近任务 | |
| 235 | + this.renderRecentTasks(tasksData.items || []); | |
| 236 | + | |
| 237 | + lucide.createIcons(); | |
| 238 | + } catch (error) { | |
| 239 | + Components.showToast('加载数据失败: ' + error.message, 'error'); | |
| 240 | + } | |
| 241 | + }, | |
| 242 | + | |
| 243 | + /** | |
| 244 | + * 渲染最近任务 | |
| 245 | + */ | |
| 246 | + renderRecentTasks(tasks) { | |
| 247 | + const container = document.getElementById('recent-tasks'); | |
| 248 | + | |
| 249 | + if (!tasks.length) { | |
| 250 | + container.innerHTML = ` | |
| 251 | + <div class="card"> | |
| 252 | + <div class="card-header"> | |
| 253 | + <h3 class="card-title"> | |
| 254 | + <i data-lucide="clock"></i> | |
| 255 | + 最近任务 | |
| 256 | + </h3> | |
| 257 | + </div> | |
| 258 | + ${Components.renderEmptyState('inbox', '暂无任务', '还没有执行过任何补货建议任务')} | |
| 259 | + </div> | |
| 260 | + `; | |
| 261 | + return; | |
| 262 | + } | |
| 263 | + | |
| 264 | + container.innerHTML = ` | |
| 265 | + <div class="table-container"> | |
| 266 | + <div class="table-header"> | |
| 267 | + <h3 class="table-title">最近任务</h3> | |
| 268 | + <a href="#/tasks" class="btn btn-secondary btn-sm"> | |
| 269 | + 查看全部 | |
| 270 | + <i data-lucide="arrow-right"></i> | |
| 271 | + </a> | |
| 272 | + </div> | |
| 273 | + <div class="table-wrapper"> | |
| 274 | + <table> | |
| 275 | + <thead> | |
| 276 | + <tr> | |
| 277 | + <th>任务编号</th> | |
| 278 | + <th>商家组合</th> | |
| 279 | + <th>状态</th> | |
| 280 | + <th>配件数</th> | |
| 281 | + <th>建议金额</th> | |
| 282 | + <th>执行时间</th> | |
| 283 | + </tr> | |
| 284 | + </thead> | |
| 285 | + <tbody> | |
| 286 | + ${tasks.map(task => ` | |
| 287 | + <tr> | |
| 288 | + <td> | |
| 289 | + <a href="#/tasks/${task.task_no}" class="table-cell-link table-cell-mono"> | |
| 290 | + ${task.task_no} | |
| 291 | + </a> | |
| 292 | + </td> | |
| 293 | + <td>${task.dealer_grouping_name || '-'}</td> | |
| 294 | + <td>${Components.getStatusBadge(task.status, task.status_text)}</td> | |
| 295 | + <td>${task.part_count}</td> | |
| 296 | + <td class="table-cell-amount">${Components.formatAmount(task.actual_amount)}</td> | |
| 297 | + <td class="table-cell-secondary">${Components.formatDuration(task.duration_seconds)}</td> | |
| 298 | + </tr> | |
| 299 | + `).join('')} | |
| 300 | + </tbody> | |
| 301 | + </table> | |
| 302 | + </div> | |
| 303 | + </div> | |
| 304 | + `; | |
| 305 | + }, | |
| 306 | + | |
| 307 | + /** | |
| 308 | + * 显示任务列表页面 | |
| 309 | + */ | |
| 310 | + async showTaskList(page = 1) { | |
| 311 | + this.currentPage = 'tasks'; | |
| 312 | + this.updateBreadcrumb([{ text: '任务列表' }]); | |
| 313 | + | |
| 314 | + const container = document.getElementById('page-container'); | |
| 315 | + container.innerHTML = '<div id="task-list-container"></div>'; | |
| 316 | + | |
| 317 | + try { | |
| 318 | + Components.showLoading(); | |
| 319 | + const data = await API.getTasks({ page, page_size: 20 }); | |
| 320 | + Components.hideLoading(); | |
| 321 | + | |
| 322 | + this.renderTaskList(data); | |
| 323 | + lucide.createIcons(); | |
| 324 | + } catch (error) { | |
| 325 | + Components.hideLoading(); | |
| 326 | + Components.showToast('加载任务列表失败: ' + error.message, 'error'); | |
| 327 | + } | |
| 328 | + }, | |
| 329 | + | |
| 330 | + /** | |
| 331 | + * 渲染任务列表 | |
| 332 | + */ | |
| 333 | + renderTaskList(data) { | |
| 334 | + const container = document.getElementById('task-list-container'); | |
| 335 | + const { items, total, page, page_size } = data; | |
| 336 | + | |
| 337 | + if (!items.length) { | |
| 338 | + container.innerHTML = ` | |
| 339 | + <div class="card"> | |
| 340 | + ${Components.renderEmptyState('inbox', '暂无任务', '还没有执行过任何补货建议任务')} | |
| 341 | + </div> | |
| 342 | + `; | |
| 343 | + return; | |
| 344 | + } | |
| 345 | + | |
| 346 | + container.innerHTML = ` | |
| 347 | + <div class="table-container"> | |
| 348 | + <div class="table-header"> | |
| 349 | + <h3 class="table-title">任务列表 (${total})</h3> | |
| 350 | + </div> | |
| 351 | + <div class="table-wrapper"> | |
| 352 | + <table> | |
| 353 | + <thead> | |
| 354 | + <tr> | |
| 355 | + <th>任务编号</th> | |
| 356 | + <th>商家组合</th> | |
| 357 | + <th>状态</th> | |
| 358 | + <th>配件数</th> | |
| 359 | + <th>建议金额</th> | |
| 360 | + <th>基准库销比</th> | |
| 361 | + <th>统计日期</th> | |
| 362 | + <th>执行时长</th> | |
| 363 | + <th>操作</th> | |
| 364 | + </tr> | |
| 365 | + </thead> | |
| 366 | + <tbody> | |
| 367 | + ${items.map(task => ` | |
| 368 | + <tr> | |
| 369 | + <td> | |
| 370 | + <span class="table-cell-mono">${task.task_no}</span> | |
| 371 | + </td> | |
| 372 | + <td>${task.dealer_grouping_name || '-'}</td> | |
| 373 | + <td>${Components.getStatusBadge(task.status, task.status_text)}</td> | |
| 374 | + <td>${task.part_count}</td> | |
| 375 | + <td class="table-cell-amount">${Components.formatAmount(task.actual_amount)}</td> | |
| 376 | + <td>${Components.formatRatio(task.base_ratio)}</td> | |
| 377 | + <td>${task.statistics_date || '-'}</td> | |
| 378 | + <td class="table-cell-secondary">${Components.formatDuration(task.duration_seconds)}</td> | |
| 379 | + <td> | |
| 380 | + <a href="#/tasks/${task.task_no}" class="btn btn-ghost btn-sm"> | |
| 381 | + <i data-lucide="eye"></i> | |
| 382 | + 查看 | |
| 383 | + </a> | |
| 384 | + </td> | |
| 385 | + </tr> | |
| 386 | + `).join('')} | |
| 387 | + </tbody> | |
| 388 | + </table> | |
| 389 | + </div> | |
| 390 | + <div id="pagination-container"></div> | |
| 391 | + </div> | |
| 392 | + `; | |
| 393 | + | |
| 394 | + // 渲染分页 | |
| 395 | + const paginationContainer = document.getElementById('pagination-container'); | |
| 396 | + paginationContainer.innerHTML = Components.renderPagination(page, total, page_size); | |
| 397 | + | |
| 398 | + // 绑定分页事件 | |
| 399 | + paginationContainer.querySelectorAll('.pagination-btn[data-page]').forEach(btn => { | |
| 400 | + btn.addEventListener('click', () => { | |
| 401 | + const targetPage = parseInt(btn.dataset.page); | |
| 402 | + if (targetPage && targetPage !== page) { | |
| 403 | + this.showTaskList(targetPage); | |
| 404 | + } | |
| 405 | + }); | |
| 406 | + }); | |
| 407 | + | |
| 408 | + lucide.createIcons(); | |
| 409 | + }, | |
| 410 | + | |
| 411 | + /** | |
| 412 | + * 显示任务详情页面 | |
| 413 | + */ | |
| 414 | + async showTaskDetail(taskNo) { | |
| 415 | + this.currentPage = 'task-detail'; | |
| 416 | + this.currentTaskNo = taskNo; | |
| 417 | + this.updateBreadcrumb([ | |
| 418 | + { text: '任务列表', href: '#/tasks' }, | |
| 419 | + { text: taskNo }, | |
| 420 | + ]); | |
| 421 | + | |
| 422 | + const container = document.getElementById('page-container'); | |
| 423 | + container.innerHTML = '<div id="task-detail-container"></div>'; | |
| 424 | + | |
| 425 | + try { | |
| 426 | + Components.showLoading(); | |
| 427 | + | |
| 428 | + const [task, partSummaries, logs] = await Promise.all([ | |
| 429 | + API.getTask(taskNo), | |
| 430 | + API.getPartSummaries(taskNo, { page: 1, page_size: 100 }).catch(() => ({ items: [], total: 0 })), | |
| 431 | + API.getTaskLogs(taskNo).catch(() => ({ items: [] })), | |
| 432 | + ]); | |
| 433 | + | |
| 434 | + Components.hideLoading(); | |
| 435 | + this.renderTaskDetail(task, partSummaries, logs); | |
| 436 | + lucide.createIcons(); | |
| 437 | + } catch (error) { | |
| 438 | + Components.hideLoading(); | |
| 439 | + Components.showToast('加载任务详情失败: ' + error.message, 'error'); | |
| 440 | + } | |
| 441 | + }, | |
| 442 | + | |
| 443 | + /** | |
| 444 | + * 渲染任务详情 | |
| 445 | + */ | |
| 446 | + renderTaskDetail(task, partSummaries, logs) { | |
| 447 | + this._currentLogs = logs; | |
| 448 | + this._partSummaries = partSummaries; | |
| 449 | + const container = document.getElementById('task-detail-container'); | |
| 450 | + | |
| 451 | + container.innerHTML = ` | |
| 452 | + <!-- 返回链接 --> | |
| 453 | + <a href="#/tasks" class="back-link"> | |
| 454 | + <i data-lucide="arrow-left"></i> | |
| 455 | + 返回任务列表 | |
| 456 | + </a> | |
| 457 | + | |
| 458 | + <!-- 任务头部 --> | |
| 459 | + <div class="detail-header"> | |
| 460 | + <div> | |
| 461 | + <h1 class="detail-title"> | |
| 462 | + ${task.task_no} | |
| 463 | + ${Components.getStatusBadge(task.status, task.status_text)} | |
| 464 | + </h1> | |
| 465 | + <div class="detail-meta"> | |
| 466 | + <span class="detail-meta-item"> | |
| 467 | + <i data-lucide="building-2"></i> | |
| 468 | + ${task.dealer_grouping_name || '未知商家组合'} | |
| 469 | + </span> | |
| 470 | + <span class="detail-meta-item"> | |
| 471 | + <i data-lucide="calendar"></i> | |
| 472 | + ${task.statistics_date || '-'} | |
| 473 | + </span> | |
| 474 | + <span class="detail-meta-item"> | |
| 475 | + <i data-lucide="clock"></i> | |
| 476 | + ${Components.formatDuration(task.duration_seconds)} | |
| 477 | + </span> | |
| 478 | + </div> | |
| 479 | + </div> | |
| 480 | + </div> | |
| 481 | + | |
| 482 | + <!-- 统计卡片 --> | |
| 483 | + <div class="stats-grid"> | |
| 484 | + ${Components.renderStatCard('package', '建议配件数', task.part_count, 'primary')} | |
| 485 | + ${Components.renderStatCard('dollar-sign', '建议金额', Components.formatAmount(task.actual_amount), 'success')} | |
| 486 | + ${Components.renderStatCard('percent', '基准库销比', Components.formatRatio(task.base_ratio), 'info')} | |
| 487 | + ${Components.renderStatCard('cpu', 'LLM Tokens', task.llm_total_tokens || 0, 'warning')} | |
| 488 | + </div> | |
| 489 | + | |
| 490 | + <!-- 标签页 --> | |
| 491 | + <div class="tabs" id="detail-tabs"> | |
| 492 | + <button class="tab active" data-tab="details"> | |
| 493 | + <i data-lucide="list"></i> | |
| 494 | + 配件明细 | |
| 495 | + </button> | |
| 496 | + | |
| 497 | + <button class="tab" data-tab="logs"> | |
| 498 | + <i data-lucide="activity"></i> | |
| 499 | + 执行日志 | |
| 500 | + </button> | |
| 501 | + <button class="tab" data-tab="info"> | |
| 502 | + <i data-lucide="info"></i> | |
| 503 | + 任务信息 | |
| 504 | + </button> | |
| 505 | + </div> | |
| 506 | + | |
| 507 | + <!-- 标签页内容 --> | |
| 508 | + <div id="tab-content"></div> | |
| 509 | + `; | |
| 510 | + | |
| 511 | + // 绑定标签页事件 | |
| 512 | + const tabs = container.querySelectorAll('.tab'); | |
| 513 | + tabs.forEach(tab => { | |
| 514 | + tab.addEventListener('click', () => { | |
| 515 | + tabs.forEach(t => t.classList.remove('active')); | |
| 516 | + tab.classList.add('active'); | |
| 517 | + this.renderTabContent(tab.dataset.tab, task, partSummaries); | |
| 518 | + }); | |
| 519 | + }); | |
| 520 | + | |
| 521 | + // 默认显示配件汇总 | |
| 522 | + this.renderTabContent('details', task, partSummaries); | |
| 523 | + }, | |
| 524 | + | |
| 525 | + /** | |
| 526 | + * 渲染标签页内容 | |
| 527 | + */ | |
| 528 | + renderTabContent(tabName, task, details) { | |
| 529 | + const container = document.getElementById('tab-content'); | |
| 530 | + | |
| 531 | + switch (tabName) { | |
| 532 | + case 'details': | |
| 533 | + this.renderDetailsTab(container, details); | |
| 534 | + break; | |
| 535 | + | |
| 536 | + case 'logs': | |
| 537 | + this.renderLogsTab(container, this._currentLogs); | |
| 538 | + break; | |
| 539 | + case 'info': | |
| 540 | + this.renderInfoTab(container, task); | |
| 541 | + break; | |
| 542 | + } | |
| 543 | + | |
| 544 | + lucide.createIcons(); | |
| 545 | + }, | |
| 546 | + | |
| 547 | + /** | |
| 548 | + * 加载配件汇总数据(支持排序和筛选) | |
| 549 | + */ | |
| 550 | + async loadPartSummaries() { | |
| 551 | + if (!this.currentTaskNo) return; | |
| 552 | + | |
| 553 | + try { | |
| 554 | + const params = { | |
| 555 | + page: this.partSummaryFilters.page, | |
| 556 | + page_size: this.partSummaryFilters.page_size, | |
| 557 | + sort_by: this.partSummarySort.sortBy, | |
| 558 | + sort_order: this.partSummarySort.sortOrder, | |
| 559 | + part_code: this.partSummaryFilters.part_code, | |
| 560 | + priority: this.partSummaryFilters.priority | |
| 561 | + }; | |
| 562 | + | |
| 563 | + // 移除空值参数 | |
| 564 | + Object.keys(params).forEach(key => { | |
| 565 | + if (params[key] === '' || params[key] === null || params[key] === undefined) { | |
| 566 | + delete params[key]; | |
| 567 | + } | |
| 568 | + }); | |
| 569 | + | |
| 570 | + const data = await API.getPartSummaries(this.currentTaskNo, params); | |
| 571 | + this._partSummaries = data; | |
| 572 | + | |
| 573 | + const container = document.getElementById('tab-content'); | |
| 574 | + if (container) { | |
| 575 | + this.renderDetailsTab(container, data); | |
| 576 | + lucide.createIcons(); | |
| 577 | + } | |
| 578 | + } catch (error) { | |
| 579 | + Components.showToast('加载配件数据失败: ' + error.message, 'error'); | |
| 580 | + } | |
| 581 | + }, | |
| 582 | + | |
| 583 | + /** | |
| 584 | + * 切换配件汇总排序 | |
| 585 | + */ | |
| 586 | + togglePartSummarySort(field) { | |
| 587 | + if (this.partSummarySort.sortBy === field) { | |
| 588 | + this.partSummarySort.sortOrder = this.partSummarySort.sortOrder === 'desc' ? 'asc' : 'desc'; | |
| 589 | + } else { | |
| 590 | + this.partSummarySort.sortBy = field; | |
| 591 | + this.partSummarySort.sortOrder = 'desc'; | |
| 592 | + } | |
| 593 | + this.loadPartSummaries(); | |
| 594 | + }, | |
| 595 | + | |
| 596 | + /** | |
| 597 | + * 获取排序图标 | |
| 598 | + */ | |
| 599 | + getSortIcon(field) { | |
| 600 | + if (this.partSummarySort.sortBy !== field) { | |
| 601 | + return '<i data-lucide="arrow-up-down" class="sort-icon sort-icon-inactive"></i>'; | |
| 602 | + } | |
| 603 | + if (this.partSummarySort.sortOrder === 'desc') { | |
| 604 | + return '<i data-lucide="arrow-down" class="sort-icon sort-icon-active"></i>'; | |
| 605 | + } | |
| 606 | + return '<i data-lucide="arrow-up" class="sort-icon sort-icon-active"></i>'; | |
| 607 | + }, | |
| 608 | + | |
| 609 | + /** | |
| 610 | + * 渲染配件明细标签页 | |
| 611 | + */ | |
| 612 | + renderDetailsTab(container, partSummaries) { | |
| 613 | + const items = partSummaries.items || []; | |
| 614 | + const { total, page, page_size } = partSummaries; | |
| 615 | + | |
| 616 | + container.innerHTML = ` | |
| 617 | + <div class="table-container"> | |
| 618 | + <div class="table-header" style="flex-wrap: wrap; gap: 1rem; height: auto;"> | |
| 619 | + <div style="display: flex; align-items: center; gap: 1rem; flex: 1;"> | |
| 620 | + <h3 class="table-title">配件补货建议 (商家组合维度) - ${total}个配件</h3> | |
| 621 | + <div class="table-header-hint"> | |
| 622 | + <i data-lucide="info" style="width:14px;height:14px;"></i> | |
| 623 | + <span>点击表头可排序</span> | |
| 624 | + </div> | |
| 625 | + </div> | |
| 626 | + | |
| 627 | + <div class="filter-toolbar" style="display: flex; gap: 0.5rem; align-items: center;"> | |
| 628 | + <input type="text" | |
| 629 | + id="filter-part-code" | |
| 630 | + class="input input-sm" | |
| 631 | + placeholder="搜索配件编码..." | |
| 632 | + value="${this.partSummaryFilters.part_code || ''}" | |
| 633 | + style="width: 150px;" | |
| 634 | + > | |
| 635 | + <select id="filter-priority" class="input input-sm" style="width: 120px;"> | |
| 636 | + <option value="">所有优先级</option> | |
| 637 | + <option value="1" ${this.partSummaryFilters.priority == 1 ? 'selected' : ''}>急需补货</option> | |
| 638 | + <option value="2" ${this.partSummaryFilters.priority == 2 ? 'selected' : ''}>建议补货</option> | |
| 639 | + <option value="3" ${this.partSummaryFilters.priority == 3 ? 'selected' : ''}>可选补货</option> | |
| 640 | + <option value="0" ${this.partSummaryFilters.priority === '0' || this.partSummaryFilters.priority === 0 ? 'selected' : ''}>无需补货</option> | |
| 641 | + </select> | |
| 642 | + <button class="btn btn-secondary btn-sm" onclick="App.applyPartFilters()"> | |
| 643 | + <i data-lucide="search" style="width: 14px; height: 14px; margin-right: 4px;"></i> | |
| 644 | + 查询 | |
| 645 | + </button> | |
| 646 | + <button class="btn btn-ghost btn-sm" onclick="App.resetPartFilters()"> | |
| 647 | + 重置 | |
| 648 | + </button> | |
| 649 | + </div> | |
| 650 | + </div> | |
| 651 | + <div class="table-wrapper"> | |
| 652 | + <table> | |
| 653 | + <thead> | |
| 654 | + <tr> | |
| 655 | + <th style="width: 40px;"></th> | |
| 656 | + <th class="sortable-th" onclick="App.togglePartSummarySort('part_code')"> | |
| 657 | + 配件编码 ${this.getSortIcon('part_code')} | |
| 658 | + </th> | |
| 659 | + <th>配件名称</th> | |
| 660 | + <th class="sortable-th" onclick="App.togglePartSummarySort('cost_price')"> | |
| 661 | + 成本价 ${this.getSortIcon('cost_price')} | |
| 662 | + </th> | |
| 663 | + <th class="sortable-th" onclick="App.togglePartSummarySort('total_storage_cnt')"> | |
| 664 | + 总库存 ${this.getSortIcon('total_storage_cnt')} | |
| 665 | + </th> | |
| 666 | + <th class="sortable-th" onclick="App.togglePartSummarySort('total_avg_sales_cnt')"> | |
| 667 | + 总销量 ${this.getSortIcon('total_avg_sales_cnt')} | |
| 668 | + </th> | |
| 669 | + <th class="sortable-th" onclick="App.togglePartSummarySort('group_current_ratio')"> | |
| 670 | + 商家组合库销比 ${this.getSortIcon('group_current_ratio')} | |
| 671 | + </th> | |
| 672 | + <th class="sortable-th" onclick="App.togglePartSummarySort('group_post_plan_ratio')"> | |
| 673 | + 计划后库销比 ${this.getSortIcon('group_post_plan_ratio')} | |
| 674 | + </th> | |
| 675 | + <th class="sortable-th" onclick="App.togglePartSummarySort('shop_count')"> | |
| 676 | + 门店数 ${this.getSortIcon('shop_count')} | |
| 677 | + </th> | |
| 678 | + <th class="sortable-th" onclick="App.togglePartSummarySort('need_replenishment_shop_count')"> | |
| 679 | + 需补货门店 ${this.getSortIcon('need_replenishment_shop_count')} | |
| 680 | + </th> | |
| 681 | + <th class="sortable-th" onclick="App.togglePartSummarySort('total_suggest_cnt')"> | |
| 682 | + 总建议数量 ${this.getSortIcon('total_suggest_cnt')} | |
| 683 | + </th> | |
| 684 | + <th class="sortable-th" onclick="App.togglePartSummarySort('total_suggest_amount')"> | |
| 685 | + 总建议金额 ${this.getSortIcon('total_suggest_amount')} | |
| 686 | + </th> | |
| 687 | + </tr> | |
| 688 | + </thead> | |
| 689 | + <tbody> | |
| 690 | + ${items.length > 0 ? items.map((item, index) => ` | |
| 691 | + <tr class="part-summary-row" data-part-code="${item.part_code}" data-index="${index}"> | |
| 692 | + <td> | |
| 693 | + <button class="btn btn-ghost btn-sm expand-btn" onclick="App.togglePartShops('${item.part_code}', ${index})"> | |
| 694 | + <i data-lucide="chevron-right" class="expand-icon"></i> | |
| 695 | + </button> | |
| 696 | + </td> | |
| 697 | + <td class="table-cell-mono">${item.part_code}</td> | |
| 698 | + <td>${item.part_name || '-'}</td> | |
| 699 | + <td>${Components.formatAmount(item.cost_price)}</td> | |
| 700 | + <td>${Components.formatNumber(item.total_storage_cnt)}</td> | |
| 701 | + <td>${Components.formatNumber(item.total_avg_sales_cnt)}</td> | |
| 702 | + <td>${Components.getRatioIndicator(item.group_current_ratio, 1.1)}</td> | |
| 703 | + <td>${Components.formatRatio(item.group_post_plan_ratio)}</td> | |
| 704 | + <td>${item.shop_count}</td> | |
| 705 | + <td><strong style="color: var(--color-warning);">${item.need_replenishment_shop_count}</strong></td> | |
| 706 | + <td><strong>${item.total_suggest_cnt}</strong></td> | |
| 707 | + <td class="table-cell-amount">${Components.formatAmount(item.total_suggest_amount)}</td> | |
| 708 | + </tr> | |
| 709 | + <tr class="part-shops-row" id="shops-${index}" style="display: none;"> | |
| 710 | + <td colspan="12" style="padding: 0;"> | |
| 711 | + <div class="shops-container" id="shops-container-${index}"> | |
| 712 | + <div class="loading-shops">加载中...</div> | |
| 713 | + </div> | |
| 714 | + </td> | |
| 715 | + </tr> | |
| 716 | + `).join('') : ` | |
| 717 | + <tr> | |
| 718 | + <td colspan="12" style="text-align: center; padding: 2rem; color: var(--text-muted);"> | |
| 719 | + 暂无符合条件的配件建议 | |
| 720 | + </td> | |
| 721 | + </tr> | |
| 722 | + `} | |
| 723 | + </tbody> | |
| 724 | + </table> | |
| 725 | + </div> | |
| 726 | + <div id="part-summary-pagination"></div> | |
| 727 | + </div> | |
| 728 | + | |
| 729 | + <style> | |
| 730 | + .part-summary-row { cursor: pointer; } | |
| 731 | + .part-summary-row:hover { background: var(--bg-hover); } | |
| 732 | + .expand-icon { transition: transform 0.2s; } | |
| 733 | + .expand-icon.expanded { transform: rotate(90deg); } | |
| 734 | + .shops-container { | |
| 735 | + background: var(--bg-elevated); | |
| 736 | + padding: var(--spacing-md); | |
| 737 | + border-left: 3px solid var(--color-primary); | |
| 738 | + margin-left: var(--spacing-lg); | |
| 739 | + } | |
| 740 | + .loading-shops { | |
| 741 | + color: var(--text-muted); | |
| 742 | + padding: var(--spacing-sm); | |
| 743 | + } | |
| 744 | + .shop-items-table { | |
| 745 | + width: 100%; | |
| 746 | + font-size: 0.875rem; | |
| 747 | + } | |
| 748 | + .shop-items-table th { | |
| 749 | + background: var(--bg-subtle); | |
| 750 | + font-weight: 600; | |
| 751 | + padding: var(--spacing-xs) var(--spacing-sm); | |
| 752 | + } | |
| 753 | + .shop-items-table td { | |
| 754 | + padding: var(--spacing-xs) var(--spacing-sm); | |
| 755 | + } | |
| 756 | + .part-decision-reason { | |
| 757 | + margin-bottom: var(--spacing-sm); | |
| 758 | + padding: var(--spacing-sm); | |
| 759 | + background: var(--bg-subtle); | |
| 760 | + border-radius: var(--radius-sm); | |
| 761 | + font-size: 0.875rem; | |
| 762 | + color: var(--text-secondary); | |
| 763 | + } | |
| 764 | + </style> | |
| 765 | + `; | |
| 766 | + | |
| 767 | + // 渲染分页 | |
| 768 | + const paginationContainer = document.getElementById('part-summary-pagination'); | |
| 769 | + if (paginationContainer) { | |
| 770 | + paginationContainer.innerHTML = Components.renderPagination(page, total, page_size); | |
| 771 | + | |
| 772 | + // 绑定分页事件 | |
| 773 | + paginationContainer.querySelectorAll('.pagination-btn[data-page]').forEach(btn => { | |
| 774 | + btn.addEventListener('click', () => { | |
| 775 | + const targetPage = parseInt(btn.dataset.page); | |
| 776 | + if (targetPage && targetPage !== page) { | |
| 777 | + this.partSummaryFilters.page = targetPage; | |
| 778 | + this.loadPartSummaries(); | |
| 779 | + } | |
| 780 | + }); | |
| 781 | + }); | |
| 782 | + } | |
| 783 | + | |
| 784 | + // 绑定搜索框回车事件 | |
| 785 | + const partCodeInput = document.getElementById('filter-part-code'); | |
| 786 | + if (partCodeInput) { | |
| 787 | + partCodeInput.addEventListener('keypress', (e) => { | |
| 788 | + if (e.key === 'Enter') { | |
| 789 | + App.applyPartFilters(); | |
| 790 | + } | |
| 791 | + }); | |
| 792 | + } | |
| 793 | + }, | |
| 794 | + | |
| 795 | + /** | |
| 796 | + * 切换配件门店展开/收起 | |
| 797 | + */ | |
| 798 | + async togglePartShops(partCode, index) { | |
| 799 | + const row = document.getElementById(`shops-${index}`); | |
| 800 | + const container = document.getElementById(`shops-container-${index}`); | |
| 801 | + const btn = document.querySelector(`tr[data-index="${index}"] .expand-icon`); | |
| 802 | + | |
| 803 | + if (row.style.display === 'none') { | |
| 804 | + row.style.display = 'table-row'; | |
| 805 | + btn.classList.add('expanded'); | |
| 806 | + | |
| 807 | + try { | |
| 808 | + const data = await API.getPartShopDetails(this.currentTaskNo, partCode); | |
| 809 | + const partSummary = this._partSummaries.items.find(p => p.part_code === partCode); | |
| 810 | + this.renderPartShops(container, data.items, partSummary); | |
| 811 | + lucide.createIcons(); | |
| 812 | + } catch (error) { | |
| 813 | + container.innerHTML = `<div class="error-text">加载失败: ${error.message}</div>`; | |
| 814 | + } | |
| 815 | + } else { | |
| 816 | + row.style.display = 'none'; | |
| 817 | + btn.classList.remove('expanded'); | |
| 818 | + } | |
| 819 | + }, | |
| 820 | + | |
| 821 | + /** | |
| 822 | + * 渲染配件门店明细 | |
| 823 | + */ | |
| 824 | + renderPartShops(container, shops, partSummary) { | |
| 825 | + if (!shops || shops.length === 0) { | |
| 826 | + container.innerHTML = '<div class="text-muted">无门店建议数据</div>'; | |
| 827 | + return; | |
| 828 | + } | |
| 829 | + | |
| 830 | + container.innerHTML = ` | |
| 831 | + ${partSummary && partSummary.part_decision_reason ? ` | |
| 832 | + <div class="part-decision-reason"> | |
| 833 | + <strong><i data-lucide="message-square" style="width:14px;height:14px;"></i> 配件补货理由:</strong> | |
| 834 | + ${partSummary.part_decision_reason} | |
| 835 | + </div> | |
| 836 | + ` : ''} | |
| 837 | + <table class="shop-items-table"> | |
| 838 | + <thead> | |
| 839 | + <tr> | |
| 840 | + <th>库房</th> | |
| 841 | + <th>有效库存</th> | |
| 842 | + <th>月均销量</th> | |
| 843 | + <th>当前库销比</th> | |
| 844 | + <th>计划后库销比</th> | |
| 845 | + <th>建议数量</th> | |
| 846 | + <th>建议金额</th> | |
| 847 | + <th>建议理由</th> | |
| 848 | + </tr> | |
| 849 | + </thead> | |
| 850 | + <tbody> | |
| 851 | + ${shops.map(shop => ` | |
| 852 | + <tr> | |
| 853 | + <td>${shop.shop_name || '-'}</td> | |
| 854 | + <td>${Components.formatNumber(shop.valid_storage_cnt)}</td> | |
| 855 | + <td>${Components.formatNumber(shop.avg_sales_cnt)}</td> | |
| 856 | + <td>${Components.getRatioIndicator(shop.current_ratio, shop.base_ratio)}</td> | |
| 857 | + <td>${Components.formatRatio(shop.post_plan_ratio)}</td> | |
| 858 | + <td><strong>${shop.suggest_cnt}</strong></td> | |
| 859 | + <td class="table-cell-amount">${Components.formatAmount(shop.suggest_amount)}</td> | |
| 860 | + <td style="min-width: 200px;"> | |
| 861 | + ${shop.suggestion_reason || '-'} | |
| 862 | + </td> | |
| 863 | + </tr> | |
| 864 | + `).join('')} | |
| 865 | + </tbody> | |
| 866 | + </table> | |
| 867 | + `; | |
| 868 | + }, | |
| 869 | + | |
| 870 | + /** | |
| 871 | + * 渲染执行日志标签页 | |
| 872 | + */ | |
| 873 | + renderLogsTab(container, logs) { | |
| 874 | + if (!logs || !logs.items || logs.items.length === 0) { | |
| 875 | + container.innerHTML = ` | |
| 876 | + <div class="card"> | |
| 877 | + ${Components.renderEmptyState('activity', '暂无执行日志', '该任务没有执行日志记录')} | |
| 878 | + </div> | |
| 879 | + `; | |
| 880 | + return; | |
| 881 | + } | |
| 882 | + | |
| 883 | + const items = logs.items; | |
| 884 | + const totalTokens = items.reduce((sum, item) => sum + (item.llm_tokens || 0), 0); | |
| 885 | + const totalTime = items.reduce((sum, item) => sum + (item.execution_time_ms || 0), 0); | |
| 886 | + | |
| 887 | + container.innerHTML = ` | |
| 888 | + <div class="card"> | |
| 889 | + <div class="card-header"> | |
| 890 | + <h3 class="card-title"> | |
| 891 | + <i data-lucide="activity"></i> | |
| 892 | + 执行日志时间线 | |
| 893 | + </h3> | |
| 894 | + <div class="card-actions"> | |
| 895 | + <span class="text-muted">总耗时: ${Components.formatDuration(totalTime / 1000)} | 总Tokens: ${totalTokens}</span> | |
| 896 | + </div> | |
| 897 | + </div> | |
| 898 | + <div class="timeline"> | |
| 899 | + ${items.map((log, index) => ` | |
| 900 | + <div class="timeline-item ${log.status === 2 ? 'timeline-item-error' : 'timeline-item-success'}"> | |
| 901 | + <div class="timeline-marker"> | |
| 902 | + <div class="timeline-icon"> | |
| 903 | + ${log.status === 1 ? '<i data-lucide="check-circle"></i>' : | |
| 904 | + log.status === 2 ? '<i data-lucide="x-circle"></i>' : | |
| 905 | + '<i data-lucide="loader"></i>'} | |
| 906 | + </div> | |
| 907 | + ${index < items.length - 1 ? '<div class="timeline-line"></div>' : ''} | |
| 908 | + </div> | |
| 909 | + <div class="timeline-content"> | |
| 910 | + <div class="timeline-header"> | |
| 911 | + <span class="timeline-title">${Components.getStepNameDisplay(log.step_name)}</span> | |
| 912 | + ${Components.getLogStatusBadge(log.status)} | |
| 913 | + </div> | |
| 914 | + <div class="timeline-meta"> | |
| 915 | + <span class="meta-item"> | |
| 916 | + <i data-lucide="clock"></i> | |
| 917 | + ${log.execution_time_ms ? Components.formatDuration(log.execution_time_ms / 1000) : '-'} | |
| 918 | + </span> | |
| 919 | + ${log.llm_tokens > 0 ? ` | |
| 920 | + <span class="meta-item"> | |
| 921 | + <i data-lucide="cpu"></i> | |
| 922 | + ${log.llm_tokens} tokens | |
| 923 | + </span> | |
| 924 | + ` : ''} | |
| 925 | + ${log.retry_count > 0 ? ` | |
| 926 | + <span class="meta-item meta-warning"> | |
| 927 | + <i data-lucide="refresh-cw"></i> | |
| 928 | + 重试 ${log.retry_count} 次 | |
| 929 | + </span> | |
| 930 | + ` : ''} | |
| 931 | + </div> | |
| 932 | + ${log.error_message ? ` | |
| 933 | + <div class="timeline-error"> | |
| 934 | + <i data-lucide="alert-triangle"></i> | |
| 935 | + ${log.error_message} | |
| 936 | + </div> | |
| 937 | + ` : ''} | |
| 938 | + </div> | |
| 939 | + </div> | |
| 940 | + `).join('')} | |
| 941 | + </div> | |
| 942 | + </div> | |
| 943 | + `; | |
| 944 | + }, | |
| 945 | + | |
| 946 | + /** | |
| 947 | + * 渲染任务信息标签页 | |
| 948 | + */ | |
| 949 | + renderInfoTab(container, task) { | |
| 950 | + container.innerHTML = ` | |
| 951 | + <div class="detail-grid"> | |
| 952 | + <div class="card"> | |
| 953 | + <div class="card-header"> | |
| 954 | + <h3 class="card-title"> | |
| 955 | + <i data-lucide="info"></i> | |
| 956 | + 基本信息 | |
| 957 | + </h3> | |
| 958 | + </div> | |
| 959 | + <div class="info-list"> | |
| 960 | + ${Components.renderInfoItem('任务编号', task.task_no)} | |
| 961 | + ${Components.renderInfoItem('集团ID', task.group_id)} | |
| 962 | + ${Components.renderInfoItem('商家组合ID', task.dealer_grouping_id)} | |
| 963 | + ${Components.renderInfoItem('商家组合名称', task.dealer_grouping_name)} | |
| 964 | + ${Components.renderInfoItem('品牌组合ID', task.brand_grouping_id)} | |
| 965 | + ${Components.renderInfoItem('统计日期', task.statistics_date)} | |
| 966 | + </div> | |
| 967 | + </div> | |
| 968 | + | |
| 969 | + <div class="card"> | |
| 970 | + <div class="card-header"> | |
| 971 | + <h3 class="card-title"> | |
| 972 | + <i data-lucide="activity"></i> | |
| 973 | + 执行信息 | |
| 974 | + </h3> | |
| 975 | + </div> | |
| 976 | + <div class="info-list"> | |
| 977 | + ${Components.renderInfoItem('状态', Components.getStatusBadge(task.status, task.status_text))} | |
| 978 | + ${Components.renderInfoItem('开始时间', task.start_time)} | |
| 979 | + ${Components.renderInfoItem('结束时间', task.end_time)} | |
| 980 | + ${Components.renderInfoItem('执行时长', Components.formatDuration(task.duration_seconds))} | |
| 981 | + ${Components.renderInfoItem('创建时间', task.create_time)} | |
| 982 | + </div> | |
| 983 | + </div> | |
| 984 | + | |
| 985 | + <div class="card"> | |
| 986 | + <div class="card-header"> | |
| 987 | + <h3 class="card-title"> | |
| 988 | + <i data-lucide="cpu"></i> | |
| 989 | + LLM 信息 | |
| 990 | + </h3> | |
| 991 | + </div> | |
| 992 | + <div class="info-list"> | |
| 993 | + ${Components.renderInfoItem('LLM 提供商', task.llm_provider || '-')} | |
| 994 | + ${Components.renderInfoItem('模型名称', task.llm_model || '-')} | |
| 995 | + ${Components.renderInfoItem('Token 消耗', task.llm_total_tokens)} | |
| 996 | + </div> | |
| 997 | + </div> | |
| 998 | + | |
| 999 | + ${task.error_message ? ` | |
| 1000 | + <div class="card"> | |
| 1001 | + <div class="card-header"> | |
| 1002 | + <h3 class="card-title" style="color: var(--color-danger)"> | |
| 1003 | + <i data-lucide="alert-triangle"></i> | |
| 1004 | + 错误信息 | |
| 1005 | + </h3> | |
| 1006 | + </div> | |
| 1007 | + <pre style="background: var(--bg-elevated); padding: var(--spacing-md); border-radius: var(--radius-md); overflow-x: auto; color: var(--color-danger-light);">${task.error_message}</pre> | |
| 1008 | + </div> | |
| 1009 | + ` : ''} | |
| 1010 | + </div> | |
| 1011 | + `; | |
| 1012 | + }, | |
| 1013 | +}; | |
| 1014 | + | |
| 1015 | +// DOM 加载完成后初始化 | |
| 1016 | +document.addEventListener('DOMContentLoaded', () => { | |
| 1017 | + App.init(); | |
| 1018 | +}); | |
| 1019 | + | |
| 1020 | +// 导出到全局 | |
| 1021 | +window.App = App; | ... | ... |
ui/js/components.js
0 → 100644
| 1 | +++ a/ui/js/components.js | |
| 1 | +/** | |
| 2 | + * UI 组件库 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const Components = { | |
| 6 | + /** | |
| 7 | + * 格式化金额 | |
| 8 | + */ | |
| 9 | + formatAmount(value) { | |
| 10 | + if (value === null || value === undefined) return '-'; | |
| 11 | + return `¥${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; | |
| 12 | + }, | |
| 13 | + | |
| 14 | + /** | |
| 15 | + * 格式化数字 | |
| 16 | + */ | |
| 17 | + formatNumber(value, decimals = 2) { | |
| 18 | + if (value === null || value === undefined) return '-'; | |
| 19 | + return Number(value).toLocaleString('zh-CN', { | |
| 20 | + minimumFractionDigits: decimals, | |
| 21 | + maximumFractionDigits: decimals | |
| 22 | + }); | |
| 23 | + }, | |
| 24 | + | |
| 25 | + /** | |
| 26 | + * 格式化库销比 | |
| 27 | + */ | |
| 28 | + formatRatio(value) { | |
| 29 | + if (value === null || value === undefined || value === 999) return '-'; | |
| 30 | + if (value === 0) return '0.00'; | |
| 31 | + return Number(value).toFixed(2); | |
| 32 | + }, | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * 获取配件标签 | |
| 36 | + * 呆滞:有库存,滚动90天没有销量 | |
| 37 | + * 低频:无库存,滚动90天有出库,月均销量<1 | |
| 38 | + * 缺货:无库存,滚动90天有出库,月均销量≥1 | |
| 39 | + */ | |
| 40 | + getPartTag(validStorage, avgSales) { | |
| 41 | + const storage = Number(validStorage) || 0; | |
| 42 | + const sales = Number(avgSales) || 0; | |
| 43 | + | |
| 44 | + if (storage > 0 && sales === 0) { | |
| 45 | + return { type: 'stagnant', text: '呆滞', class: 'tag-stagnant' }; | |
| 46 | + } | |
| 47 | + if (storage <= 0 && sales > 0 && sales < 1) { | |
| 48 | + return { type: 'low-freq', text: '低频', class: 'tag-low-freq' }; | |
| 49 | + } | |
| 50 | + if (storage <= 0 && sales >= 1) { | |
| 51 | + return { type: 'shortage', text: '缺货', class: 'tag-shortage' }; | |
| 52 | + } | |
| 53 | + return null; | |
| 54 | + }, | |
| 55 | + | |
| 56 | + /** | |
| 57 | + * 渲染配件标签HTML | |
| 58 | + */ | |
| 59 | + renderPartTag(validStorage, avgSales) { | |
| 60 | + const tag = this.getPartTag(validStorage, avgSales); | |
| 61 | + if (!tag) return ''; | |
| 62 | + return `<span class="part-tag ${tag.class}">${tag.text}</span>`; | |
| 63 | + }, | |
| 64 | + | |
| 65 | + /** | |
| 66 | + * 获取步骤名称显示 | |
| 67 | + */ | |
| 68 | + getStepNameDisplay(stepName) { | |
| 69 | + const stepMap = { | |
| 70 | + 'fetch_part_ratio': '获取配件数据', | |
| 71 | + 'sql_agent': 'AI分析建议', | |
| 72 | + 'allocate_budget': '分配预算', | |
| 73 | + 'generate_report': '生成报告', | |
| 74 | + }; | |
| 75 | + return stepMap[stepName] || stepName; | |
| 76 | + }, | |
| 77 | + | |
| 78 | + /** | |
| 79 | + * 获取日志状态徽章 | |
| 80 | + */ | |
| 81 | + getLogStatusBadge(status) { | |
| 82 | + const statusMap = { | |
| 83 | + 0: { class: 'badge-info', text: '运行中' }, | |
| 84 | + 1: { class: 'badge-success', text: '成功' }, | |
| 85 | + 2: { class: 'badge-danger', text: '失败' }, | |
| 86 | + 3: { class: 'badge-warning', text: '跳过' }, | |
| 87 | + }; | |
| 88 | + const config = statusMap[status] || { class: 'badge-neutral', text: '未知' }; | |
| 89 | + return `<span class="badge ${config.class}"><span class="badge-dot"></span>${config.text}</span>`; | |
| 90 | + }, | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * 格式化时长 | |
| 94 | + */ | |
| 95 | + formatDuration(seconds) { | |
| 96 | + if (!seconds) return '-'; | |
| 97 | + seconds = Math.floor(seconds); | |
| 98 | + if (seconds < 60) return `${seconds}秒`; | |
| 99 | + const minutes = Math.floor(seconds / 60); | |
| 100 | + const secs = seconds % 60; | |
| 101 | + return `${minutes}分${secs}秒`; | |
| 102 | + }, | |
| 103 | + | |
| 104 | + /** | |
| 105 | + * 获取状态徽章 HTML | |
| 106 | + */ | |
| 107 | + getStatusBadge(status, statusText) { | |
| 108 | + const statusMap = { | |
| 109 | + 0: { class: 'badge-info', icon: 'loader-2', text: statusText || '运行中' }, | |
| 110 | + 1: { class: 'badge-success', icon: 'check-circle', text: statusText || '成功' }, | |
| 111 | + 2: { class: 'badge-danger', icon: 'x-circle', text: statusText || '失败' }, | |
| 112 | + }; | |
| 113 | + const config = statusMap[status] || statusMap[0]; | |
| 114 | + return ` | |
| 115 | + <span class="badge ${config.class}"> | |
| 116 | + <span class="badge-dot"></span> | |
| 117 | + ${config.text} | |
| 118 | + </span> | |
| 119 | + `; | |
| 120 | + }, | |
| 121 | + | |
| 122 | + /** | |
| 123 | + * 获取优先级徽章 HTML | |
| 124 | + */ | |
| 125 | + getPriorityBadge(priority) { | |
| 126 | + const priorityMap = { | |
| 127 | + 1: { class: 'priority-high', text: '高' }, | |
| 128 | + 2: { class: 'priority-medium', text: '中' }, | |
| 129 | + 3: { class: 'priority-low', text: '低' }, | |
| 130 | + }; | |
| 131 | + const config = priorityMap[priority] || priorityMap[2]; | |
| 132 | + return `<span class="priority-badge ${config.class}">${config.text}</span>`; | |
| 133 | + }, | |
| 134 | + | |
| 135 | + /** | |
| 136 | + * 获取库销比指示器 HTML | |
| 137 | + */ | |
| 138 | + getRatioIndicator(current, base) { | |
| 139 | + if (current === null || current === undefined || current === 999) { | |
| 140 | + return '<span class="text-muted">-</span>'; | |
| 141 | + } | |
| 142 | + | |
| 143 | + const percentage = Math.min((current / (base || 1.5)) * 100, 100); | |
| 144 | + let className = 'ratio-normal'; | |
| 145 | + if (current < 0.5) className = 'ratio-low'; | |
| 146 | + else if (current > 2) className = 'ratio-high'; | |
| 147 | + | |
| 148 | + return ` | |
| 149 | + <div class="ratio-indicator ${className}"> | |
| 150 | + <span>${this.formatRatio(current)}</span> | |
| 151 | + <div class="ratio-bar"> | |
| 152 | + <div class="ratio-bar-fill" style="width: ${percentage}%"></div> | |
| 153 | + </div> | |
| 154 | + </div> | |
| 155 | + `; | |
| 156 | + }, | |
| 157 | + | |
| 158 | + /** | |
| 159 | + * 渲染分页控件 | |
| 160 | + */ | |
| 161 | + renderPagination(current, total, pageSize, onChange) { | |
| 162 | + const totalPages = Math.ceil(total / pageSize); | |
| 163 | + const start = (current - 1) * pageSize + 1; | |
| 164 | + const end = Math.min(current * pageSize, total); | |
| 165 | + | |
| 166 | + return ` | |
| 167 | + <div class="pagination"> | |
| 168 | + <div class="pagination-info"> | |
| 169 | + 显示 ${start}-${end} 条,共 ${total} 条 | |
| 170 | + </div> | |
| 171 | + <div class="pagination-controls"> | |
| 172 | + <button class="pagination-btn" ${current <= 1 ? 'disabled' : ''} data-page="${current - 1}"> | |
| 173 | + <i data-lucide="chevron-left"></i> | |
| 174 | + </button> | |
| 175 | + ${this.getPaginationNumbers(current, totalPages)} | |
| 176 | + <button class="pagination-btn" ${current >= totalPages ? 'disabled' : ''} data-page="${current + 1}"> | |
| 177 | + <i data-lucide="chevron-right"></i> | |
| 178 | + </button> | |
| 179 | + </div> | |
| 180 | + </div> | |
| 181 | + `; | |
| 182 | + }, | |
| 183 | + | |
| 184 | + /** | |
| 185 | + * 获取分页数字 | |
| 186 | + */ | |
| 187 | + getPaginationNumbers(current, total) { | |
| 188 | + const pages = []; | |
| 189 | + const maxVisible = 5; | |
| 190 | + | |
| 191 | + let start = Math.max(1, current - Math.floor(maxVisible / 2)); | |
| 192 | + let end = Math.min(total, start + maxVisible - 1); | |
| 193 | + start = Math.max(1, end - maxVisible + 1); | |
| 194 | + | |
| 195 | + for (let i = start; i <= end; i++) { | |
| 196 | + pages.push(` | |
| 197 | + <button class="pagination-btn ${i === current ? 'active' : ''}" data-page="${i}"> | |
| 198 | + ${i} | |
| 199 | + </button> | |
| 200 | + `); | |
| 201 | + } | |
| 202 | + | |
| 203 | + return pages.join(''); | |
| 204 | + }, | |
| 205 | + | |
| 206 | + /** | |
| 207 | + * 渲染统计卡片 | |
| 208 | + */ | |
| 209 | + renderStatCard(icon, label, value, iconClass = 'primary', change = null) { | |
| 210 | + let changeHtml = ''; | |
| 211 | + if (change !== null) { | |
| 212 | + const changeClass = change >= 0 ? 'positive' : 'negative'; | |
| 213 | + const changeIcon = change >= 0 ? 'trending-up' : 'trending-down'; | |
| 214 | + changeHtml = ` | |
| 215 | + <div class="stat-change ${changeClass}"> | |
| 216 | + <i data-lucide="${changeIcon}"></i> | |
| 217 | + ${Math.abs(change)}% | |
| 218 | + </div> | |
| 219 | + `; | |
| 220 | + } | |
| 221 | + | |
| 222 | + return ` | |
| 223 | + <div class="stat-card"> | |
| 224 | + <div class="stat-icon ${iconClass}"> | |
| 225 | + <i data-lucide="${icon}"></i> | |
| 226 | + </div> | |
| 227 | + <div class="stat-content"> | |
| 228 | + <div class="stat-label">${label}</div> | |
| 229 | + <div class="stat-value">${value}</div> | |
| 230 | + ${changeHtml} | |
| 231 | + </div> | |
| 232 | + </div> | |
| 233 | + `; | |
| 234 | + }, | |
| 235 | + | |
| 236 | + /** | |
| 237 | + * 渲染空状态 | |
| 238 | + */ | |
| 239 | + renderEmptyState(icon = 'inbox', title = '暂无数据', description = '') { | |
| 240 | + return ` | |
| 241 | + <div class="empty-state"> | |
| 242 | + <i data-lucide="${icon}"></i> | |
| 243 | + <div class="empty-state-title">${title}</div> | |
| 244 | + ${description ? `<div class="empty-state-description">${description}</div>` : ''} | |
| 245 | + </div> | |
| 246 | + `; | |
| 247 | + }, | |
| 248 | + | |
| 249 | + /** | |
| 250 | + * 渲染信息列表项 | |
| 251 | + */ | |
| 252 | + renderInfoItem(label, value) { | |
| 253 | + return ` | |
| 254 | + <div class="info-item"> | |
| 255 | + <span class="info-label">${label}</span> | |
| 256 | + <span class="info-value">${value || '-'}</span> | |
| 257 | + </div> | |
| 258 | + `; | |
| 259 | + }, | |
| 260 | + | |
| 261 | + /** | |
| 262 | + * 渲染 Markdown 内容 | |
| 263 | + */ | |
| 264 | + renderMarkdown(content) { | |
| 265 | + if (!content) return ''; | |
| 266 | + if (typeof marked !== 'undefined') { | |
| 267 | + let html = marked.parse(content); | |
| 268 | + html = html.replace(/<pre>\s*<code[^>]*>[\s\\n]*<\/code>\s*<\/pre>/gi, ''); | |
| 269 | + html = html.replace(/<pre>[\s\\n]*<\/pre>/gi, ''); | |
| 270 | + html = html.replace(/<pre>\s*<code[^>]*>\n<\/code>\s*<\/pre>/gi, ''); | |
| 271 | + return `<div class="markdown-content">${html}</div>`; | |
| 272 | + } | |
| 273 | + return `<div class="markdown-content"><pre>${content}</pre></div>`; | |
| 274 | + }, | |
| 275 | + | |
| 276 | + /** | |
| 277 | + * 渲染结构化报告 Section | |
| 278 | + */ | |
| 279 | + renderReportSection(section) { | |
| 280 | + if (!section) return ''; | |
| 281 | + | |
| 282 | + const iconMap = { | |
| 283 | + 'executive_summary': { icon: 'file-text', class: 'summary' }, | |
| 284 | + 'inventory_analysis': { icon: 'bar-chart-2', class: 'analysis' }, | |
| 285 | + 'risk_assessment': { icon: 'alert-triangle', class: 'risk' }, | |
| 286 | + 'purchase_recommendations': { icon: 'shopping-cart', class: 'recommendation' }, | |
| 287 | + 'optimization_suggestions': { icon: 'lightbulb', class: 'optimization' }, | |
| 288 | + }; | |
| 289 | + | |
| 290 | + const config = iconMap[section.type] || { icon: 'file', class: 'summary' }; | |
| 291 | + | |
| 292 | + let contentHtml = ''; | |
| 293 | + | |
| 294 | + switch (section.type) { | |
| 295 | + case 'executive_summary': | |
| 296 | + contentHtml = this.renderSummarySection(section); | |
| 297 | + break; | |
| 298 | + case 'inventory_analysis': | |
| 299 | + contentHtml = this.renderAnalysisSection(section); | |
| 300 | + break; | |
| 301 | + case 'risk_assessment': | |
| 302 | + contentHtml = this.renderRiskSection(section); | |
| 303 | + break; | |
| 304 | + case 'purchase_recommendations': | |
| 305 | + contentHtml = this.renderRecommendationSection(section); | |
| 306 | + break; | |
| 307 | + case 'optimization_suggestions': | |
| 308 | + contentHtml = this.renderSuggestionSection(section); | |
| 309 | + break; | |
| 310 | + default: | |
| 311 | + contentHtml = `<div class="summary-text">${JSON.stringify(section)}</div>`; | |
| 312 | + } | |
| 313 | + | |
| 314 | + return ` | |
| 315 | + <div class="card"> | |
| 316 | + <div class="report-section-header"> | |
| 317 | + <div class="report-section-icon ${config.class}"> | |
| 318 | + <i data-lucide="${config.icon}"></i> | |
| 319 | + </div> | |
| 320 | + <div class="report-section-title">${section.title || ''}</div> | |
| 321 | + </div> | |
| 322 | + <div class="report-section-content"> | |
| 323 | + ${contentHtml} | |
| 324 | + </div> | |
| 325 | + </div> | |
| 326 | + `; | |
| 327 | + }, | |
| 328 | + | |
| 329 | + /** | |
| 330 | + * 渲染执行摘要 | |
| 331 | + */ | |
| 332 | + renderSummarySection(section) { | |
| 333 | + const items = section.items || []; | |
| 334 | + const text = section.text || ''; | |
| 335 | + | |
| 336 | + const itemsHtml = items.length > 0 ? ` | |
| 337 | + <div class="summary-items"> | |
| 338 | + ${items.map(item => ` | |
| 339 | + <div class="summary-item status-${item.status || 'normal'}"> | |
| 340 | + <div class="summary-item-label">${item.label || ''}</div> | |
| 341 | + <div class="summary-item-value">${item.value || ''}</div> | |
| 342 | + </div> | |
| 343 | + `).join('')} | |
| 344 | + </div> | |
| 345 | + ` : ''; | |
| 346 | + | |
| 347 | + const textHtml = text ? `<div class="summary-text">${text}</div>` : ''; | |
| 348 | + | |
| 349 | + return itemsHtml + textHtml; | |
| 350 | + }, | |
| 351 | + | |
| 352 | + /** | |
| 353 | + * 渲染库存分析 | |
| 354 | + */ | |
| 355 | + renderAnalysisSection(section) { | |
| 356 | + const paragraphs = section.paragraphs || []; | |
| 357 | + const highlights = section.highlights || []; | |
| 358 | + | |
| 359 | + const highlightsHtml = highlights.length > 0 ? ` | |
| 360 | + <div class="analysis-highlights"> | |
| 361 | + ${highlights.map(h => ` | |
| 362 | + <div class="analysis-highlight-card"> | |
| 363 | + <span class="highlight-label">${h.label || ''}</span> | |
| 364 | + <span class="highlight-value">${h.value || ''}</span> | |
| 365 | + </div> | |
| 366 | + `).join('')} | |
| 367 | + </div> | |
| 368 | + ` : ''; | |
| 369 | + | |
| 370 | + const paragraphsHtml = paragraphs.length > 0 ? ` | |
| 371 | + <div class="analysis-paragraphs"> | |
| 372 | + ${paragraphs.map(p => ` | |
| 373 | + <div class="analysis-paragraph"> | |
| 374 | + ${p} | |
| 375 | + </div> | |
| 376 | + `).join('')} | |
| 377 | + </div> | |
| 378 | + ` : ''; | |
| 379 | + | |
| 380 | + // Put highlights first for better visibility | |
| 381 | + return highlightsHtml + paragraphsHtml; | |
| 382 | + }, | |
| 383 | + | |
| 384 | + /** | |
| 385 | + * 渲染风险评估 | |
| 386 | + */ | |
| 387 | + renderRiskSection(section) { | |
| 388 | + const risks = section.risks || []; | |
| 389 | + | |
| 390 | + if (risks.length === 0) { | |
| 391 | + return '<div class="summary-text">暂无风险评估</div>'; | |
| 392 | + } | |
| 393 | + | |
| 394 | + const levelText = { high: '高', medium: '中', low: '低' }; | |
| 395 | + | |
| 396 | + // 按风险等级排序:high > medium > low | |
| 397 | + const sortOrder = { high: 0, medium: 1, low: 2 }; | |
| 398 | + const sortedRisks = [...risks].sort((a, b) => { | |
| 399 | + const orderA = sortOrder[a.level] || 99; | |
| 400 | + const orderB = sortOrder[b.level] || 99; | |
| 401 | + return orderA - orderB; | |
| 402 | + }); | |
| 403 | + | |
| 404 | + return ` | |
| 405 | + <div class="risk-grid"> | |
| 406 | + ${sortedRisks.map(risk => ` | |
| 407 | + <div class="risk-card level-${risk.level || 'medium'}"> | |
| 408 | + <div class="risk-card-header"> | |
| 409 | + <span class="risk-badge ${risk.level || 'medium'}">${levelText[risk.level] || '中'}风险</span> | |
| 410 | + <span class="risk-category-tag">${risk.category || '通用'}</span> | |
| 411 | + </div> | |
| 412 | + <div class="risk-card-content"> | |
| 413 | + <div class="risk-description">${risk.description || ''}</div> | |
| 414 | + </div> | |
| 415 | + </div> | |
| 416 | + `).join('')} | |
| 417 | + </div> | |
| 418 | + `; | |
| 419 | + }, | |
| 420 | + | |
| 421 | + /** | |
| 422 | + * 渲染采购建议 | |
| 423 | + */ | |
| 424 | + renderRecommendationSection(section) { | |
| 425 | + const recommendations = section.recommendations || []; | |
| 426 | + | |
| 427 | + if (recommendations.length === 0) { | |
| 428 | + return '<div class="summary-text">暂无采购建议</div>'; | |
| 429 | + } | |
| 430 | + | |
| 431 | + return ` | |
| 432 | + <div class="recommendation-list"> | |
| 433 | + ${recommendations.map(rec => ` | |
| 434 | + <div class="recommendation-item"> | |
| 435 | + <div class="recommendation-priority priority-${rec.priority || 3}">${rec.priority || 3}</div> | |
| 436 | + <div class="recommendation-content"> | |
| 437 | + <div class="recommendation-action">${rec.action || ''}</div> | |
| 438 | + <div class="recommendation-reason">${rec.reason || ''}</div> | |
| 439 | + </div> | |
| 440 | + </div> | |
| 441 | + `).join('')} | |
| 442 | + </div> | |
| 443 | + `; | |
| 444 | + }, | |
| 445 | + | |
| 446 | + /** | |
| 447 | + * 渲染优化建议 | |
| 448 | + */ | |
| 449 | + renderSuggestionSection(section) { | |
| 450 | + const suggestions = section.suggestions || []; | |
| 451 | + | |
| 452 | + if (suggestions.length === 0) { | |
| 453 | + return '<div class="summary-text">暂无优化建议</div>'; | |
| 454 | + } | |
| 455 | + | |
| 456 | + return ` | |
| 457 | + <div class="suggestion-list"> | |
| 458 | + ${suggestions.map(s => ` | |
| 459 | + <div class="suggestion-item"> | |
| 460 | + <div class="suggestion-icon"><i data-lucide="check-circle"></i></div> | |
| 461 | + <div class="suggestion-text">${s}</div> | |
| 462 | + </div> | |
| 463 | + `).join('')} | |
| 464 | + </div> | |
| 465 | + `; | |
| 466 | + }, | |
| 467 | + | |
| 468 | + /** | |
| 469 | + * 显示 Toast 通知 | |
| 470 | + */ | |
| 471 | + showToast(message, type = 'info') { | |
| 472 | + const container = document.getElementById('toast-container'); | |
| 473 | + const icons = { | |
| 474 | + success: 'check-circle', | |
| 475 | + error: 'x-circle', | |
| 476 | + warning: 'alert-triangle', | |
| 477 | + info: 'info', | |
| 478 | + }; | |
| 479 | + | |
| 480 | + const toast = document.createElement('div'); | |
| 481 | + toast.className = `toast ${type}`; | |
| 482 | + toast.innerHTML = ` | |
| 483 | + <i data-lucide="${icons[type]}" class="toast-icon"></i> | |
| 484 | + <span class="toast-message">${message}</span> | |
| 485 | + `; | |
| 486 | + | |
| 487 | + container.appendChild(toast); | |
| 488 | + lucide.createIcons({ icons: { [icons[type]]: lucide.icons[icons[type]] }, attrs: {} }); | |
| 489 | + | |
| 490 | + setTimeout(() => { | |
| 491 | + toast.style.animation = 'slideIn 0.25s ease reverse'; | |
| 492 | + setTimeout(() => toast.remove(), 250); | |
| 493 | + }, 3000); | |
| 494 | + }, | |
| 495 | + | |
| 496 | + /** | |
| 497 | + * 显示加载遮罩 | |
| 498 | + */ | |
| 499 | + showLoading() { | |
| 500 | + document.getElementById('loading-overlay').classList.add('active'); | |
| 501 | + }, | |
| 502 | + | |
| 503 | + /** | |
| 504 | + * 隐藏加载遮罩 | |
| 505 | + */ | |
| 506 | + hideLoading() { | |
| 507 | + document.getElementById('loading-overlay').classList.remove('active'); | |
| 508 | + }, | |
| 509 | + | |
| 510 | + /** | |
| 511 | + * 显示模态框 | |
| 512 | + */ | |
| 513 | + showModal(title, content) { | |
| 514 | + document.getElementById('modal-title').textContent = title; | |
| 515 | + document.getElementById('modal-body').innerHTML = content; | |
| 516 | + document.getElementById('modal-overlay').classList.add('active'); | |
| 517 | + lucide.createIcons(); | |
| 518 | + }, | |
| 519 | + | |
| 520 | + /** | |
| 521 | + * 关闭模态框 | |
| 522 | + */ | |
| 523 | + closeModal() { | |
| 524 | + document.getElementById('modal-overlay').classList.remove('active'); | |
| 525 | + }, | |
| 526 | +}; | |
| 527 | + | |
| 528 | +// 导出到全局 | |
| 529 | +window.Components = Components; | ... | ... |