Commit 70aa7f1240579b8f78535c4fd93e80990215c82f

Authored by 朱焱飞
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
  1 +++ a/sql/v3_add_suggestion_reason.sql
  1 +-- AI补货建议明细表 - 添加建议理由字段
  2 +-- 版本: 3.0
  3 +-- 创建日期: 2026-01-29
  4 +
  5 +ALTER TABLE ai_replenishment_detail
  6 +ADD COLUMN suggestion_reason TEXT COMMENT 'LLM建议理由' AFTER suggest_amount;
... ...
sql/v4_cleanup_unused_tables.sql 0 → 100644
  1 +++ a/sql/v4_cleanup_unused_tables.sql
  1 +-- 清理无效的 AI 业务表
  2 +-- 版本: 4.0
  3 +-- 创建日期: 2026-01-30
  4 +-- 描述: 删除不再使用的 ai_llm_suggestion_detail 和 ai_pre_plan_detail 表
  5 +
  6 +DROP TABLE IF EXISTS ai_llm_suggestion_detail;
  7 +DROP TABLE IF EXISTS ai_pre_plan_detail;
... ...
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
  1 +++ a/sql/v7_add_low_frequency_count.sql
  1 +-- v7: 添加低频件统计字段
  2 +-- 用于记录出库次数<3 或 平均出库间隔>=30天的配件数量
  3 +
  4 +ALTER TABLE ai_replenishment_report
  5 +ADD COLUMN low_frequency_part_count INT DEFAULT 0 COMMENT '低频件数量'
  6 +AFTER stagnant_part_count;
... ...
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
  1 +++ a/src/fw_pms_ai/__init__.py
  1 +"""
  2 +fw-pms-ai: AI 补货建议系统
  3 +"""
  4 +
  5 +__version__ = "0.1.0"
... ...
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
  1 +++ a/src/fw_pms_ai/api/__init__.py
  1 +# API 模块
... ...
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
  1 +++ a/src/fw_pms_ai/api/routes/__init__.py
  1 +# API 路由模块
... ...
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
  1 +++ a/src/fw_pms_ai/config/__init__.py
  1 +"""配置模块"""
  2 +
  3 +from .settings import Settings, get_settings
  4 +
  5 +__all__ = ["Settings", "get_settings"]
... ...
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 +
... ...