Commit 70aa7f1240579b8f78535c4fd93e80990215c82f
0 parents
feat: 初始化AI驱动的PMS模块,包含LLM集成、智能代理、API接口和前端界面。
Showing
65 changed files
with
10123 additions
and
0 deletions
Too many changes to show.
To preserve performance only 44 of 65 files are displayed.
.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 | + |