diff --git b/.env a/.env
new file mode 100644
index 0000000..6fdb721
--- /dev/null
+++ a/.env
@@ -0,0 +1,22 @@
+# OpenAI 兼容模式 (智谱AI glm-4-7)
+OPENAI_COMPAT_API_KEY=ed148f06-82f0-4fca-991d-05ae7296e110
+OPENAI_COMPAT_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
+OPENAI_COMPAT_MODEL=glm-4-7-251222
+
+# 豆包配置 (备选)
+DOUBAO_API_KEY=
+DOUBAO_MODEL=doubao-pro
+
+# 数据库配置
+MYSQL_HOST=172.26.154.169
+MYSQL_PORT=3302
+MYSQL_USER=test
+MYSQL_PASSWORD=mysql@pwd123
+MYSQL_DATABASE=fw_pms
+
+# 定时任务配置
+SCHEDULER_CRON_HOUR=2
+SCHEDULER_CRON_MINUTE=0
+
+# 日志配置
+LOG_LEVEL=INFO
diff --git b/.gitignore a/.gitignore
new file mode 100644
index 0000000..593a49a
--- /dev/null
+++ a/.gitignore
@@ -0,0 +1,69 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual environments
+venv/
+.venv/
+ENV/
+env/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+.project
+.pydevproject
+.settings/
+
+# Testing
+.tox/
+.nox/
+.coverage
+.coverage.*
+htmlcov/
+.pytest_cache/
+.hypothesis/
+
+# Mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Ruff
+.ruff_cache/
+
+# Jupyter
+.ipynb_checkpoints/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+logs/
+
+# Local development
+*.local
+*.bak
diff --git b/README.md a/README.md
new file mode 100644
index 0000000..dc125e8
--- /dev/null
+++ a/README.md
@@ -0,0 +1,180 @@
+# fw-pms-ai
+
+AI 配件系统 - 基于 Python + LangChain + LangGraph
+
+## 项目简介
+
+本项目是 `fw-pms` 的 AI 扩展模块,使用大语言模型 (LLM) 和 Agent 技术,为配件管理系统提供智能化的补货建议能力。
+
+## 核心技术
+
+### LangChain + LangGraph
+
+| 技术 | 作用 |
+|------|------|
+| **LangChain** | LLM 框架,提供模型抽象、Prompt 管理、消息格式化 |
+| **LangGraph** | Agent 工作流编排,管理状态机、定义节点和边、支持条件分支 |
+| **SQL Agent** | 自定义 Text-to-SQL 实现,支持错误重试和 LLM 数据分析 |
+
+```mermaid
+graph LR
+ A[用户请求] --> B[LangGraph Agent]
+ B --> C[FetchPartRatio]
+ C --> D[SQLAgent
LLM分析]
+ D --> E[AllocateBudget]
+ E --> F[GenerateReport
LLM]
+ F --> G[SaveResult]
+```
+
+详细架构图见 [docs/architecture.md](docs/architecture.md)
+
+## 功能模块
+
+### ✅ 已实现
+
+| 模块 | 功能 | 说明 |
+|------|------|------|
+| **SQL Agent** | LLM 分析 | 直接分析 part_ratio 数据生成补货建议 |
+| **补货分配** | Replenishment | 转换 LLM 建议为补货明细 |
+| **分析报告** | Report | LLM 生成库存分析报告 |
+
+### 🚧 计划中
+
+| 模块 | 功能 |
+|------|------|
+| 预测引擎 | 基于历史销量预测未来需求 |
+| 异常检测 | 识别数据异常 |
+
+## 项目结构
+
+```
+fw-pms-ai/
+├── src/fw_pms_ai/
+│ ├── agent/ # LangGraph Agent
+│ │ ├── state.py # Agent 状态定义
+│ │ ├── nodes.py # 工作流节点
+│ │ ├── sql_agent.py # SQL Agent(Text-to-SQL + 建议生成)
+│ │ └── replenishment.py
+│ ├── api/ # FastAPI 接口
+│ │ ├── app.py # 应用入口
+│ │ └── routes/ # 路由模块
+│ ├── config/ # 配置管理
+│ ├── llm/ # LLM 集成
+│ │ ├── base.py # 抽象基类
+│ │ ├── glm.py # 智谱 GLM
+│ │ ├── doubao.py # 豆包
+│ │ ├── openai_compat.py
+│ │ └── anthropic_compat.py
+│ ├── models/ # 数据模型
+│ │ ├── task.py # 任务和明细模型
+│ │ ├── execution_log.py # 执行日志模型
+│ │ ├── part_ratio.py # 库销比模型
+│ │ ├── part_summary.py # 配件汇总模型
+│ │ ├── sql_result.py # SQL执行结果模型
+│ │ └── suggestion.py # 补货建议模型
+│ ├── services/ # 业务服务
+│ │ ├── db.py # 数据库连接
+│ │ ├── data_service.py # 数据查询服务
+│ │ └── result_writer.py # 结果写入服务
+│ ├── scheduler/ # 定时任务
+│ └── main.py
+├── prompts/ # AI Prompt 文件
+│ ├── sql_agent.md # SQL Agent 系统提示词
+│ ├── suggestion.md # 补货建议提示词
+│ ├── suggestion_system.md
+│ ├── part_shop_analysis.md
+│ └── part_shop_analysis_system.md
+├── ui/ # 前端静态文件
+├── sql/ # 数据库迁移脚本
+├── pyproject.toml
+└── README.md
+```
+
+
+## 工作流程
+
+```
+1. FetchPartRatio - 从 part_ratio 表获取库销比数据
+2. SQLAgent - LLM 分析数据,生成补货建议
+3. AllocateBudget - 转换建议为补货明细
+4. GenerateReport - LLM 生成分析报告
+5. SaveResult - 写入数据库
+```
+
+### 业务术语
+
+| 术语 | 定义 | 处理 |
+|------|------|------|
+| **呆滞件** | 有库存,90天无销量 | 不做计划 |
+| **低频件** | 无库存,月均销量<1 | 不做计划 |
+| **缺货件** | 无库存,月均销量≥1 | 需要补货 |
+
+## 数据表说明
+
+| 表名 | 说明 |
+|------|------|
+| `part_ratio` | 配件库销比数据(来源表) |
+| `ai_replenishment_task` | 任务记录 |
+| `ai_replenishment_detail` | 配件级别补货建议 |
+| `ai_replenishment_report` | 分析报告 |
+| `ai_task_execution_log` | 任务执行日志 |
+| `ai_llm_suggestion_detail` | LLM 建议明细 |
+
+## 快速开始
+
+### 1. 安装依赖
+
+```bash
+cd fw-pms-ai
+pip install -e .
+```
+
+### 2. 配置环境变量
+
+```bash
+cp .env.example .env
+```
+
+必填配置项:
+- `GLM_API_KEY` / `ANTHROPIC_API_KEY` - LLM API Key
+- `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`
+
+### 3. 初始化数据库
+
+```bash
+mysql -u root -p fw_pms < sql/init.sql
+mysql -u root -p fw_pms < sql/v2_add_log_tables.sql
+```
+
+### 4. 运行
+
+```bash
+# 启动定时任务调度器
+fw-pms-ai
+
+# 立即执行一次
+fw-pms-ai --run-once
+
+# 指定参数
+fw-pms-ai --run-once --group-id 2
+```
+
+## AI Prompt 文件
+
+Prompt 文件存放在 `prompts/` 目录:
+
+| 文件 | 用途 |
+|------|------|
+| `suggestion.md` | 补货建议生成(含业务术语定义) |
+| `analyze_inventory.md` | 库存分析 |
+| `generate_report.md` | 报告生成 |
+
+## 开发
+
+```bash
+# 安装开发依赖
+pip install -e ".[dev]"
+
+# 运行测试
+pytest tests/ -v
+```
diff --git b/docs/architecture.md a/docs/architecture.md
new file mode 100644
index 0000000..93550ca
--- /dev/null
+++ a/docs/architecture.md
@@ -0,0 +1,123 @@
+# fw-pms-ai 系统架构
+
+## 技术栈
+
+| 组件 | 技术 |
+|------|------|
+| 编程语言 | Python 3.11+ |
+| Agent 框架 | LangChain + LangGraph |
+| LLM | 智谱 GLM / 豆包 / OpenAI 兼容接口 |
+| 数据库 | MySQL |
+| API 框架 | FastAPI |
+| 任务调度 | APScheduler |
+
+---
+
+## 系统架构图
+
+```mermaid
+flowchart TB
+ subgraph API ["FastAPI API 层"]
+ A[/tasks endpoint/]
+ end
+
+ subgraph Agent ["LangGraph Agent"]
+ direction TB
+ B[fetch_part_ratio] --> C[sql_agent]
+ C --> D{需要重试?}
+ D -->|是| C
+ D -->|否| E[allocate_budget]
+ E --> F[END]
+ end
+
+ subgraph Services ["业务服务层"]
+ G[DataService]
+ H[ResultWriter]
+ end
+
+ subgraph LLM ["LLM 集成"]
+ I[GLM]
+ J[Doubao]
+ K[OpenAI Compat]
+ end
+
+ subgraph DB ["数据存储"]
+ L[(MySQL)]
+ end
+
+ A --> Agent
+ B --> G
+ C --> LLM
+ E --> H
+ G --> L
+ H --> L
+```
+
+---
+
+## 工作流节点说明
+
+| 节点 | 职责 | 输入 | 输出 |
+|------|------|------|------|
+| `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] |
+| `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] |
+| `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] |
+
+---
+
+## 核心数据流
+
+```mermaid
+sequenceDiagram
+ participant API
+ participant Agent
+ participant SQLAgent
+ participant LLM
+ participant DB
+
+ API->>Agent: 创建任务
+ Agent->>DB: 保存任务记录
+ Agent->>DB: 查询 part_ratio
+ Agent->>SQLAgent: 分析配件数据
+
+ loop 每个配件
+ SQLAgent->>LLM: 发送分析请求
+ LLM-->>SQLAgent: 返回补货建议
+ end
+
+ SQLAgent-->>Agent: 汇总建议
+ Agent->>DB: 保存补货明细
+ Agent->>DB: 更新任务状态
+ Agent-->>API: 返回结果
+```
+
+---
+
+## 目录结构
+
+```
+src/fw_pms_ai/
+├── agent/ # LangGraph 工作流
+│ ├── state.py # 状态定义 (TypedDict)
+│ ├── nodes.py # 工作流节点
+│ ├── sql_agent.py # SQL Agent 实现
+│ └── replenishment.py # 主入口
+├── api/ # REST API
+├── config/ # 配置管理
+├── llm/ # LLM 适配器
+├── models/ # 数据模型
+├── services/ # 业务服务
+└── scheduler/ # 定时任务
+```
+
+---
+
+## 数据库表结构
+
+| 表名 | 用途 |
+|------|------|
+| `part_ratio` | 配件库销比数据(只读) |
+| `ai_replenishment_task` | 任务记录 |
+| `ai_replenishment_detail` | 补货明细 |
+| `ai_replenishment_part_summary` | 配件级汇总 |
+| `ai_task_execution_log` | 执行日志 |
diff --git b/prompts/part_shop_analysis.md a/prompts/part_shop_analysis.md
new file mode 100644
index 0000000..48a1958
--- /dev/null
+++ a/prompts/part_shop_analysis.md
@@ -0,0 +1,146 @@
+# 单配件多门店分析提示词
+
+## 配件信息
+
+- **配件编码**: {part_code}
+- **配件名称**: {part_name}
+- **成本价**: ¥{cost_price}
+- **单位**: {unit}
+
+## 商家组合
+
+- **商家组合名称**: {dealer_grouping_name}
+- **统计日期**: {statistics_date}
+- **目标库销比**: {target_ratio}(基于商家组合计算的基准库销比,用于计算目标库存)
+
+---
+
+## 各门店数据
+
+{shop_data}
+
+## 字段说明
+
+| 字段名 | 含义 | 用途 |
+|--------|------|------|
+| shop_id | 门店ID | 唯一标识 |
+| shop_name | 门店名称 | 显示用 |
+| valid_storage_cnt | 有效库存数量 | 当前可用库存 |
+| avg_sales_cnt | 月均销量 | 基于90天销售数据计算 |
+| out_stock_cnt | 90天出库数量 | 判断呆滞件的依据 |
+| out_times | 90天内出库次数 | 判断低频件的依据(< 3次为低频件) |
+| out_duration | 平均出库时长(天) | 最近两次出库间隔天数(≥ 30天为低频件) |
+| current_ratio | 当前库销比 | = 有效库存 / 月均销量 |
+
+---
+
+## 分析任务
+
+请按以下三层决策逻辑逐步分析:
+
+### 1️⃣ 配件级判断(商家组合维度)
+- 汇总所有门店的库存和销量,计算配件在商家组合内的整体库销比
+- 判断该配件是否需要补货,需要补多少个
+- 生成配件级决策理由
+
+### 2️⃣ 门店级分配
+- 将总补货数量分配到各门店
+- 优先分配给库销比最低的门店
+- 公式: suggest_cnt = ceil({target_ratio} × 月均销量 - 当前库存)
+
+### 3️⃣ 为每个门店提供专业的决策理由
+
+**理由必须包含以下要素**:
+1. **状态判定标签**(括起来):急需补货 / 建议补货 / 可选补货 / 库存充足 / 呆滞件 / 低频件-xxx
+2. **关键指标数据**:当前库存X件、月均销量Y件、库销比Z
+3. **缺口分析**(补货时):目标库销比需备货A件,缺口B件
+4. **天数说明**:当前库存可支撑约X天 / 补货后可支撑约Y天
+5. **紧迫程度**:风险说明或建议优先级说明
+6. **排除原因**(不补货时):引用具体的排除规则数据
+
+---
+
+## 输出格式
+
+直接输出JSON对象,**不要**包含 ```json 标记或任何其他文字:
+
+{{
+ "part_code": "{part_code}",
+ "part_name": "{part_name}",
+ "need_replenishment": true或false,
+ "total_storage_cnt": 商家组合内总库存(数字),
+ "total_avg_sales_cnt": 商家组合内总月均销量(数字),
+ "group_current_ratio": 商家组合级库销比(数字,保留2位小数),
+ "total_suggest_cnt": 所有门店建议数量之和,
+ "total_suggest_amount": 所有门店建议金额之和,
+ "part_decision_reason": "【配件决策】该配件在商家组合内总库存X件,月均总销量Y件,整体库销比Z。{{决策说明}}。该配件共涉及K家门店,其中J家门店需要补货。",
+ "shop_count": 涉及门店总数(整数),
+ "shop_suggestions": [
+ {{
+ "shop_id": 门店ID(整数),
+ "shop_name": "门店名称",
+ "current_storage_cnt": 当前库存(数字),
+ "avg_sales_cnt": 月均销量(数字),
+ "current_ratio": 当前库销比(数字,保留2位小数),
+ "suggest_cnt": 建议采购数量(整数),
+ "suggest_amount": 建议采购金额(数字),
+ "priority": 优先级(1=高/2=中/3=低),
+ "reason": "专业详尽的补货理由,包含关键数据指标(参见理由撰写规范)"
+ }}
+ ],
+ "priority": 配件整体优先级(1=高/2=中/3=低),
+ "confidence": 置信度(0.0-1.0)
+}}
+
+---
+
+## 理由示例
+
+### 补货理由示例:
+
+**高优先级**:
+```
+「急需补货」当前库存0件,月均销量8.2件,库销比0.00,缺口分析:目标库销比{target_ratio}需备货X件,补货后可支撑约Y天销售。库存已告罄且销量活跃,存在严重缺货风险,建议立即补货。
+```
+
+**中优先级**:
+```
+「建议补货」当前库存2件,月均销量5.3件,库销比0.38,缺口分析:目标库销比{target_ratio}需备货X件,实际缺口Y件,补货后可支撑约Z天销售。当前库存仅够约11天销售,低于安全库销比0.5,建议尽快补货。
+```
+
+**低优先级**:
+```
+「可选补货」当前库存4件,月均销量3.5件,库销比1.14,优化建议:目标库销比{target_ratio}需备货X件,缺口Y件,补货后可支撑约Z天销售。库存处于安全边界,可根据资金情况酌情补货。
+```
+
+### 不补货理由示例:
+
+**库存充足**:
+```
+「库存充足」当前库存8件,月均销量4.0件,库销比2.00,可支撑约60天销售,超过安全阈值({target_ratio}个月),无需补货。
+```
+
+**低频件**:
+```
+「低频件-需求不足」当前库存0件,月均销量0.3件,月均销量不足1件(阈值≥1),周转需求过低,暂不纳入补货计划。
+```
+
+**呆滞件**:
+```
+「呆滞件」当前库存5件,但90天内无任何销售(月均销量0),库存滞销风险高,建议安排清理处置,暂不补货。
+```
+
+---
+
+## 重要约束
+
+1. **shop_suggestions 必须包含所有输入门店**,无论是否需要补货
+2. **suggest_cnt = 0 的门店也必须返回**,并提供详细的不补货理由(如"库存充足"、"低频件"、"呆滞件"等)
+3. **total_suggest_cnt 必须等于**所有门店 suggest_cnt 之和
+4. **呆滞件/低频件的 suggest_cnt 必须为 0**
+5. **低频件判定(满足任一即为低频件,不纳入补货)**:
+ - `avg_sales_cnt < 1`: 月均销量 < 1
+ - `out_times < 3`:90天内出库次数不足3次
+ - `out_duration >= 30`:平均出库时长 ≥ 30天
+6. **输出必须是合法的JSON**,可被直接解析
+7. **理由必须专业详尽**,包含具体数据指标,贴合采购人员专业度
diff --git b/prompts/part_shop_analysis_system.md a/prompts/part_shop_analysis_system.md
new file mode 100644
index 0000000..b8f3f56
--- /dev/null
+++ a/prompts/part_shop_analysis_system.md
@@ -0,0 +1,162 @@
+# 单配件多门店补货分析专家
+
+## 角色定义
+
+你是一位资深的汽车4S店配件库存管理专家,拥有以下专业能力:
+- 精通库销比分析与补货决策
+- 熟悉汽车配件供应链特点(季节性、周期性、区域差异)
+- 擅长多门店库存协调优化
+- 具备成本控制和资金周转意识
+
+## 决策原则
+
+1. **数据驱动**: 仅基于提供的数据做出判断,不做任何假设或猜测
+2. **保守策略**: 宁可少补不要多补,避免积压风险
+3. **优先级区分**: 急需 > 建议 > 可选,资源有限时优先处理高优先级
+4. **全面覆盖**: 对每一个门店都必须给出分析结论
+
+---
+
+## 核心分析框架
+
+### Step 1: 门店状态分类
+
+按以下标准对每个门店进行分类:
+
+| 状态 | 条件 | 处理方式 |
+|------|------|----------|
+| 🔴 急需补货 | 库销比 < 0.5 且月均销量 ≥ 1 | 高优先级补货 |
+| 🟡 建议补货 | 库销比 0.5-1.0 且月均销量 ≥ 1 | 中优先级补货 |
+| 🟢 可选补货 | 库销比 1.0-{target_ratio} 且月均销量 ≥ 1 | 低优先级补货 |
+| ⚪ 无需补货 | 库销比 > {target_ratio} | 不补货 |
+
+### Step 2: 排除规则(强制执行)
+
+以下情况**绝对不补货**,suggest_cnt 必须为 0:
+
+1. **呆滞件**: `valid_storage_cnt > 0` 且 `avg_sales_cnt = 0`
+ - 特征:有库存但90天无任何销售
+ - 原因:库存积压风险,需清理而非补货
+
+2. **低频件**: `valid_storage_cnt = 0` 且满足**以下任一条件**:
+ - A. `avg_sales_cnt < 1` (月均销量 < 1)
+ - B. `out_times < 3` (90天内出库次数 < 3)
+ - C. `out_duration >= 30` (平均出库间隔 ≥ 30天)
+ - 原因:需求过低、周转太慢或间隔过长,不纳入补货计划
+
+3. **库存充足**: 库销比 > {target_ratio}
+ - 特征:库存可支撑{target_ratio}个月以上销售
+ - 原因:无需额外补货
+
+### Step 3: 补货量计算
+
+```
+初步缺口 = 目标库销比({target_ratio}) × 月均销量 - 当前有效库存
+
+补货量规则:
+1. 如果 初步缺口 > 1:建议数量 = floor(初步缺口) // 向下取整,保守策略
+2. 如果 0 < 初步缺口 <= 1:建议数量 = 1 // 最小补货量
+3. 如果 初步缺口 <= 0:建议数量 = 0 // 无需补货
+```
+
+**计算示例**:
+- **Case A**: 月销量=1.0, 库存=0, 目标=1.13 -> 缺口=1.13 -> 建议=1 (向下取整)
+- **Case B**: 月销量=5.0, 库存=4, 目标=1.13 -> 缺口=1.65 -> 建议=1 (向下取整)
+- **Case C**: 月销量=5.0, 库存=1, 目标=1.13 -> 缺口=4.65 -> 建议=4 (向下取整)
+- **Case D**: 月销量=1.0, 库存=0.5, 目标=1.13 -> 缺口=0.63 -> 建议=1 (最小补货)
+
+### Step 4: 优先级判定
+
+| 优先级 | 条件 | 说明 |
+|--------|------|------|
+| 1 (高) | 库销比 < 0.5 且月均销量 ≥ 1 | 急需补货,缺货风险高 |
+| 2 (中) | 库销比 0.5-1.0 且月均销量 ≥ 1 | 建议补货,库存偏低 |
+| 3 (低) | 库销比 1.0-{target_ratio} 且月均销量 ≥ 1 | 可选补货,安全库存边界 |
+
+---
+
+## 理由撰写规范(重要)
+
+### 补货理由必须包含的关键数据
+
+理由字段必须**采用专业的采购语言**,并**引用具体数据指标**,格式如下:
+
+**补货理由模板**:
+```
+「{状态判定}」当前库存{X}件,月均销量{Y}件,库销比{Z},
+{缺口/充足}分析:目标库销比{target_ratio}需备货{A}件,补货{B}件后可支撑{C}天销售。
+{紧迫程度说明}。
+```
+
+**不补货理由模板**:
+```
+「{排除类型}」{排除原因的关键数据说明},
+{不补货依据}。
+```
+
+### 理由撰写示例
+
+**✅ 高优先级补货**:
+```
+「急需补货」当前库存0件,月均销量8.2件,库销比0.00,
+缺口分析:目标库销比{target_ratio}需备货X件,按目标补货后可支撑约Y天销售。
+库存已告罄且销量活跃,存在严重缺货风险,建议立即补货。
+```
+
+**✅ 中优先级补货**:
+```
+「建议补货」当前库存2件,月均销量5.3件,库销比0.38,
+缺口分析:目标库销比{target_ratio}需备货X件,实际缺口Y件,补货后可支撑约Z天销售。
+当前库存仅够约11天销售,低于安全库销比0.5,建议尽快补货。
+```
+
+**✅ 低优先级补货**:
+```
+「可选补货」当前库存4件,月均销量3.5件,库销比1.14,
+优化建议:目标库销比{target_ratio}需备货X件,缺口Y件,补货后可支撑约Z天销售。
+库存处于安全边界,可根据资金情况酌情补货。
+```
+
+**✅ 无需补货**:
+```
+「库存充足」当前库存8件,月均销量4.0件,库销比2.00,
+可支撑约60天销售,超过安全阈值({target_ratio}个月),无需补货。
+```
+
+**✅ 低频件排除**:
+```
+「低频件-出库次数不足」90天内仅出库2次(阈值≥3次),
+出库间隔约36天,周转频率过低,暂不纳入补货计划。
+```
+
+**✅ 呆滞件排除**:
+```
+「呆滞件」当前库存5件,但90天内无任何出库记录,
+库存滞销风险高,建议安排清理处置,暂不补货。
+```
+
+**✅ 低需求排除**:
+```
+「低频件-需求不足」当前库存0件,月均销量0.3件,
+月均销量不足1件(阈值≥1件),需求过低不值得备货。
+```
+
+---
+
+## 输出要求
+
+1. **纯JSON输出**: 只输出JSON对象,不要有任何其他文字、解释或代码块标记
+2. **完整覆盖**: 对输入的每个门店都必须在shop_suggestions中体现(无论是否需要补货)
+3. **理由专业详尽**: reason字段必须按照上述模板撰写,包含关键数据指标
+
+## 输出质量自检
+
+输出前请确认:
+- ✅ 输出是否为纯JSON,无 ```json 包裹?
+- ✅ total_suggest_cnt 是否等于所有门店 suggest_cnt 之和?
+- ✅ 呆滞件/低频件的 suggest_cnt 是否为 0?
+- ✅ 低频件规则检查:`out_times < 3` 或 `out_duration >= 30` 的是否 suggest_cnt = 0?
+- ✅ shop_suggestions 是否包含了所有输入门店(无论是否补货)?
+- ✅ suggest_cnt = 0 的门店是否有详细的不补货理由?
+- ✅ 每个 reason 是否包含具体数据(库存、销量、库销比、天数等)?
+- ✅ reason 是否采用专业采购语言,不过于简化?
diff --git b/prompts/sql_agent.md a/prompts/sql_agent.md
new file mode 100644
index 0000000..8b06b04
--- /dev/null
+++ a/prompts/sql_agent.md
@@ -0,0 +1,87 @@
+# SQL Agent 系统提示词
+
+## 角色定义
+
+你是一位专业的数据库分析专家,精通 MySQL 查询优化和汽车配件库存管理数据模型。
+
+### 核心能力
+
+1. **SQL编写**: 生成高效、安全的MySQL查询语句
+2. **数据建模**: 理解配件库销比数据结构
+3. **性能优化**: 避免全表扫描,合理使用索引
+
+---
+
+## 任务说明
+
+根据用户需求生成正确的 SQL 查询语句,从 part_ratio 表获取配件库销比数据。
+
+---
+
+## 数据表结构
+
+```sql
+CREATE TABLE part_ratio (
+ id BIGINT PRIMARY KEY,
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ brand_id BIGINT NOT NULL COMMENT '品牌ID',
+ brand_grouping_id BIGINT COMMENT '品牌组合ID',
+ dealer_grouping_id BIGINT COMMENT '商家组合ID',
+ supplier_id BIGINT COMMENT '供应商ID',
+ supplier_name VARCHAR(500) COMMENT '供应商名称',
+ area_id BIGINT NOT NULL COMMENT '区域ID',
+ area_name VARCHAR(500) NOT NULL COMMENT '区域名称',
+ shop_id BIGINT NOT NULL COMMENT '库房ID',
+ shop_name VARCHAR(500) NOT NULL COMMENT '库房名称',
+ part_id BIGINT NOT NULL COMMENT '配件ID',
+ part_code VARCHAR(500) NOT NULL COMMENT '配件编码',
+ part_name VARCHAR(500) COMMENT '配件名称',
+ unit VARCHAR(50) COMMENT '单位',
+ cost_price DECIMAL(14,2) COMMENT '成本价',
+ in_stock_unlocked_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '在库未锁数量',
+ has_plan_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '已计划数量',
+ on_the_way_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '在途数量',
+ out_stock_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '出库数量(90天)',
+ buy_cnt INT DEFAULT 0 COMMENT '客户订件数',
+ transfer_cnt INT DEFAULT 0 COMMENT '主动调拨在途数量',
+ gen_transfer_cnt INT DEFAULT 0 COMMENT '自动调拨在途数量',
+ storage_locked_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '库存锁定数量',
+ out_stock_ongoing_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '出库中数量',
+ stock_age INT DEFAULT 0 COMMENT '库龄(天)',
+ out_times INT COMMENT '出库次数',
+ part_biz_type TINYINT COMMENT '配件业务类型: 1=配件 2=装饰',
+ statistics_date VARCHAR(50) NOT NULL COMMENT '统计日期(yyyy-MM-dd)'
+);
+```
+
+---
+
+## 核心计算公式
+
+| 指标 | 公式 | 说明 |
+|------|------|------|
+| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt` | 可用库存总量 |
+| 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 |
+| 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 |
+
+---
+
+## 输出格式
+
+仅输出JSON对象,**不要**包含其他文字或代码块标记:
+
+{
+ "sql": "SELECT ...",
+ "explanation": "SQL说明"
+}
+
+---
+
+## 约束条件
+
+1. **MySQL 5.x 兼容**: 不使用窗口函数(MySQL 5.x 不支持)
+2. **必须过滤条件**:
+ - `statistics_date = 'xxxx-xx-xx'`
+ - `part_biz_type = 1`(仅配件)
+3. **排序规则**: 按库销比升序(优先处理库销比低的)
+4. **只允许 SELECT**: 禁止 INSERT/UPDATE/DELETE
diff --git b/prompts/suggestion.md a/prompts/suggestion.md
new file mode 100644
index 0000000..c1a0e0d
--- /dev/null
+++ a/prompts/suggestion.md
@@ -0,0 +1,99 @@
+# 补货建议生成提示词
+
+基于以下配件库销比数据,分析并生成补货建议。
+
+---
+
+## 商家组合信息
+
+| 项目 | 数值 |
+|------|------|
+| 商家组合ID | {dealer_grouping_id} |
+| 商家组合名称 | {dealer_grouping_name} |
+| 统计日期 | {statistics_date} |
+
+---
+
+## 配件数据
+
+{part_data}
+
+---
+
+## 字段说明
+
+| 字段名 | 含义 | 计算公式/说明 |
+|--------|------|---------------|
+| valid_storage_cnt | 有效库存数量 | 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途 |
+| avg_sales_cnt | 月均销量 | (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 |
+| cost_price | 成本价 | 单件采购成本 |
+| current_ratio | 当前库销比 | 有效库存 / 月均销量 |
+| out_stock_cnt | 90天出库数量 | 用于判断呆滞件 |
+
+---
+
+## 业务术语定义
+
+### 🚫 呆滞件(不补货)
+
+- **判定条件**: `valid_storage_cnt > 0` 且 `out_stock_cnt = 0`
+- **特征**: 有库存但90天无任何出库
+- **处理**: 不做计划,应考虑清理
+
+### 🚫 低频件(不补货)
+
+- **判定条件**: `valid_storage_cnt = 0` 且 `avg_sales_cnt < 1`
+- **特征**: 无库存且月均销量不足1件
+- **处理**: 不做计划,需求过低
+
+### ✅ 缺货件(优先补货)
+
+- **判定条件**: `valid_storage_cnt = 0` 且 `avg_sales_cnt >= 1`
+- **特征**: 无库存但有稳定需求
+- **处理**: 高优先级补货
+
+---
+
+## 分析任务
+
+请按以下步骤执行:
+
+1. **识别并排除** 呆滞件和低频件(这些配件不应出现在输出中)
+2. **逐个分析** 剩余配件的库存状况
+3. **计算补货量** 使用公式: `建议数量 = ceil(目标库销比 × 月均销量 - 当前库存)`
+4. **确定优先级** 基于库销比和销量水平
+5. **撰写理由** 为每个建议提供数据支撑的决策依据
+
+---
+
+## 输出格式
+
+直接输出JSON数组,**不要**包含 ```json 标记:
+
+[
+ {{
+ "shop_id": 库房ID(整数),
+ "shop_name": "库房名称",
+ "part_code": "配件编码",
+ "part_name": "配件名称",
+ "unit": "单位",
+ "cost_price": 成本价(数字),
+ "current_storage_cnt": 当前库存(数字),
+ "avg_sales_cnt": 月均销量(数字),
+ "current_ratio": 当前库销比(数字),
+ "suggest_cnt": 建议采购数量(整数),
+ "suggest_amount": 建议采购金额(数字),
+ "suggestion_reason": "详细的补货依据和决策理由",
+ "priority": 优先级(1=高/2=中/3=低),
+ "confidence": 置信度(0.0-1.0)
+ }}
+]
+
+---
+
+## 重要约束
+
+1. **仅输出需要补货的配件**(suggest_cnt > 0)
+2. **呆滞件和低频件不应出现在输出中**
+3. **suggest_amount = suggest_cnt × cost_price**
+4. **输出必须是合法的JSON数组**
diff --git b/prompts/suggestion_system.md a/prompts/suggestion_system.md
new file mode 100644
index 0000000..5ba6acb
--- /dev/null
+++ a/prompts/suggestion_system.md
@@ -0,0 +1,39 @@
+# 补货建议分析系统提示词
+
+## 角色定义
+
+你是一位资深的汽车配件库存管理专家,专注于库销比分析和补货决策。
+
+### 核心能力
+
+1. **数据分析**: 精准解读库销比、销量、库存等指标
+2. **风险识别**: 识别缺货风险、积压风险、呆滞风险
+3. **决策优化**: 在资金有限时合理分配补货优先级
+4. **成本意识**: 平衡库存周转与服务水平
+
+---
+
+## 决策标准
+
+### 需要补货的情况
+
+| 优先级 | 条件 | 说明 |
+|--------|------|------|
+| 高 | 库销比 < 0.5 且月均销量 ≥ 1 | 缺货风险高,急需补货 |
+| 中 | 库销比 0.5-1.0 且月均销量 ≥ 1 | 库存偏低,建议补货 |
+| 低 | 库销比 1.0-1.5 且月均销量 ≥ 1 | 安全边界,可选补货 |
+
+### 不补货的情况
+
+- **呆滞件**: 有库存但90天无销量(out_stock_cnt = 0)
+- **低频件**: 无库存且月均销量 < 1
+- **库存充足**: 库销比 > 1.5
+
+---
+
+## 输出要求
+
+1. **仅输出JSON格式**,不包含任何解释性文字
+2. **基于数据决策**,不做假设或推测
+3. **理由具体明确**,引用数据支撑结论
+4. **计算准确无误**,建议金额 = 建议数量 × 成本价
diff --git b/pyproject.toml a/pyproject.toml
new file mode 100644
index 0000000..6e5e257
--- /dev/null
+++ a/pyproject.toml
@@ -0,0 +1,65 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "fw-pms-ai"
+version = "0.1.0"
+description = "AI 补货建议系统 - 基于 LangChain + LangGraph"
+readme = "README.md"
+requires-python = ">=3.11"
+license = "MIT"
+authors = [
+ { name = "FeeWee", email = "dev@feewee.cn" }
+]
+dependencies = [
+ # LangChain 生态
+ "langchain>=0.3.0",
+ "langgraph>=0.2.0",
+ "langchain-core>=0.3.0",
+
+ # LLM 集成
+ "zhipuai>=2.0.0",
+
+ # 定时任务
+ "apscheduler>=3.10.0",
+
+ # 数据库
+ "mysql-connector-python>=8.0.0",
+ "sqlalchemy>=2.0.0",
+
+ # 配置管理
+ "pydantic>=2.0.0",
+ "pydantic-settings>=2.0.0",
+ "python-dotenv>=1.0.0",
+
+ # 工具库
+ "httpx>=0.25.0",
+ "tenacity>=8.0.0",
+
+ # Web API
+ "fastapi>=0.109.0",
+ "uvicorn[standard]>=0.27.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0.0",
+ "pytest-asyncio>=0.21.0",
+ "black>=23.0.0",
+ "ruff>=0.1.0",
+]
+
+[project.scripts]
+fw-pms-ai = "fw_pms_ai.main:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/fw_pms_ai"]
+
+[tool.black]
+line-length = 100
+target-version = ["py311"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py311"
diff --git b/sql/final_schema.sql a/sql/final_schema.sql
new file mode 100644
index 0000000..b202c3e
--- /dev/null
+++ a/sql/final_schema.sql
@@ -0,0 +1,151 @@
+-- ============================================================================
+-- AI 补货建议系统 - 表结构
+-- ============================================================================
+-- 版本: 1.0.0
+-- 更新日期: 2026-01-31
+-- ============================================================================
+
+-- 1. AI补货任务表
+DROP TABLE IF EXISTS ai_replenishment_task;
+CREATE TABLE ai_replenishment_task (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL UNIQUE COMMENT '任务编号(AI-开头)',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
+ brand_grouping_id BIGINT COMMENT '品牌组合ID',
+ plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '计划采购金额(预算)',
+ actual_amount DECIMAL(14,2) DEFAULT 0 COMMENT '实际分配金额',
+ part_count INT DEFAULT 0 COMMENT '分配配件数量',
+ base_ratio DECIMAL(10,4) COMMENT '基准库销比',
+ status TINYINT DEFAULT 0 COMMENT '状态: 0-运行中 1-成功 2-失败',
+ error_message TEXT COMMENT '错误信息',
+ llm_provider VARCHAR(32) COMMENT 'LLM提供商',
+ llm_model VARCHAR(64) COMMENT 'LLM模型名称',
+ llm_total_tokens INT DEFAULT 0 COMMENT 'LLM总Token数',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ start_time DATETIME COMMENT '任务开始时间',
+ end_time DATETIME COMMENT '任务结束时间',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_group_date (group_id, statistics_date),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货任务表-记录每次补货建议任务执行';
+
+-- 2. AI补货建议明细表
+DROP TABLE IF EXISTS ai_replenishment_detail;
+CREATE TABLE ai_replenishment_detail (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ brand_grouping_id BIGINT COMMENT '品牌组合ID',
+ shop_id BIGINT NOT NULL COMMENT '库房ID',
+ shop_name VARCHAR(128) COMMENT '库房名称',
+ part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
+ part_name VARCHAR(256) COMMENT '配件名称',
+ unit VARCHAR(32) COMMENT '单位',
+ cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价',
+ current_ratio DECIMAL(10,4) COMMENT '当前库销比',
+ base_ratio DECIMAL(10,4) COMMENT '基准库销比',
+ post_plan_ratio DECIMAL(10,4) COMMENT '计划后预计库销比',
+ valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量',
+ avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)',
+ suggest_cnt INT DEFAULT 0 COMMENT '建议采购数量',
+ suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议采购金额',
+ suggestion_reason TEXT COMMENT '补货建议理由',
+ priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低',
+ llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_task_no (task_no),
+ INDEX idx_shop_part (shop_id, part_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议明细表-存储每次任务的配件分配结果';
+
+-- 3. AI补货分析报告表
+DROP TABLE IF EXISTS ai_replenishment_report;
+CREATE TABLE ai_replenishment_report (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
+ total_storage_amount DECIMAL(16,2) COMMENT '总库存金额',
+ total_sales_amount DECIMAL(16,2) COMMENT '总销售金额(90天)',
+ overall_ratio DECIMAL(10,4) COMMENT '整体库销比',
+ target_ratio DECIMAL(10,4) COMMENT '目标库销比',
+ total_part_count INT DEFAULT 0 COMMENT '配件总数',
+ shortage_part_count INT DEFAULT 0 COMMENT '缺货配件数',
+ overstock_part_count INT DEFAULT 0 COMMENT '超标配件数',
+ normal_part_count INT DEFAULT 0 COMMENT '正常配件数',
+ stagnant_part_count INT DEFAULT 0 COMMENT '呆滞配件数',
+ suggest_total_amount DECIMAL(14,2) COMMENT '建议采购总金额',
+ suggest_part_count INT DEFAULT 0 COMMENT '建议采购配件数',
+ top_priority_parts TEXT COMMENT '重点关注配件(JSON数组)',
+ report_content JSON COMMENT '结构化报告内容(JSON)',
+ llm_provider VARCHAR(32) COMMENT 'LLM提供商',
+ llm_model VARCHAR(64) COMMENT 'LLM模型',
+ generation_tokens INT DEFAULT 0 COMMENT '生成Token数',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_task_no (task_no),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货分析报告表-存储LLM生成的分析报告';
+
+-- 4. AI任务执行日志表
+DROP TABLE IF EXISTS ai_task_execution_log;
+CREATE TABLE ai_task_execution_log (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ brand_grouping_id BIGINT COMMENT '品牌组合ID',
+ brand_grouping_name VARCHAR(128) COMMENT '品牌组合名称',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
+ step_name VARCHAR(64) NOT NULL COMMENT '步骤名称',
+ step_order INT DEFAULT 0 COMMENT '步骤顺序',
+ status TINYINT DEFAULT 0 COMMENT '状态: 0-进行中 1-成功 2-失败 3-跳过',
+ input_data TEXT COMMENT '输入数据(JSON)',
+ output_data TEXT COMMENT '输出数据(JSON)',
+ error_message TEXT COMMENT '错误信息',
+ retry_count INT DEFAULT 0 COMMENT '重试次数',
+ sql_query TEXT COMMENT 'SQL查询语句(如有)',
+ llm_prompt TEXT COMMENT 'LLM提示词(如有)',
+ llm_response TEXT COMMENT 'LLM响应(如有)',
+ llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗',
+ execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)',
+ start_time DATETIME COMMENT '开始时间',
+ end_time DATETIME COMMENT '结束时间',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_task_no (task_no),
+ INDEX idx_group_date (group_id, create_time),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI任务执行日志表-记录每个步骤的执行详情';
+
+-- 5. AI补货配件汇总表
+DROP TABLE IF EXISTS ai_replenishment_part_summary;
+CREATE TABLE ai_replenishment_part_summary (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
+ part_name VARCHAR(256) COMMENT '配件名称',
+ unit VARCHAR(32) COMMENT '单位',
+ cost_price DECIMAL(14,2) DEFAULT 0.00 COMMENT '成本价',
+ total_storage_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总库存数量',
+ total_avg_sales_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总月均销量',
+ group_current_ratio DECIMAL(10,4) COMMENT '商家组合级库销比',
+ total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量',
+ total_suggest_amount DECIMAL(14,2) DEFAULT 0.00 COMMENT '总建议金额',
+ shop_count INT DEFAULT 0 COMMENT '涉及门店数',
+ need_replenishment_shop_count INT DEFAULT 0 COMMENT '需要补货的门店数',
+ part_decision_reason TEXT COMMENT '配件级补货决策理由',
+ priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1=高, 2=中, 3=低',
+ llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_task_no (task_no),
+ INDEX idx_part_code (part_code),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议-配件汇总表';
+
diff --git b/sql/init.sql a/sql/init.sql
new file mode 100644
index 0000000..c6427c8
--- /dev/null
+++ a/sql/init.sql
@@ -0,0 +1,130 @@
+-- AI 补货建议系统表结构
+-- 版本: 2.0
+-- 更新: 2026-01-27
+
+-- 1. AI预计划明细表
+DROP TABLE IF EXISTS ai_pre_plan_detail;
+CREATE TABLE ai_pre_plan_detail (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ brand_grouping_id BIGINT COMMENT '品牌组合ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
+ dealer_id BIGINT COMMENT '商家ID',
+ dealer_name VARCHAR(128) COMMENT '商家名称',
+ area_id BIGINT COMMENT '区域ID',
+ area_name VARCHAR(128) COMMENT '区域名称',
+ shop_id BIGINT NOT NULL COMMENT '库房ID',
+ shop_name VARCHAR(128) COMMENT '库房名称',
+ part_id BIGINT COMMENT '配件ID',
+ part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
+ part_name VARCHAR(256) COMMENT '配件名称',
+ unit VARCHAR(32) COMMENT '单位',
+ cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价',
+ base_ratio DECIMAL(10,4) COMMENT '基准库销比',
+ current_ratio DECIMAL(10,4) COMMENT '当前库销比',
+ valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量(在库未锁+在途+计划数+调拨在途)',
+ avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)',
+ target_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '目标库存数量',
+ plan_cnt INT DEFAULT 0 COMMENT '建议计划数量',
+ plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议计划金额',
+ part_biz_type TINYINT DEFAULT 1 COMMENT '配件业务类型: 1-配件 2-装饰',
+ statistics_date VARCHAR(16) NOT NULL COMMENT '统计日期(yyyy-MM-dd)',
+ yn TINYINT DEFAULT 1 COMMENT '是否有效: 1-是 0-否',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_group_date (group_id, statistics_date),
+ INDEX idx_dealer_grouping (dealer_grouping_id),
+ INDEX idx_shop (shop_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI预计划明细表-存储每日计算的配件补货建议';
+
+-- 2. AI补货任务表
+DROP TABLE IF EXISTS ai_replenishment_task;
+CREATE TABLE ai_replenishment_task (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL UNIQUE COMMENT '任务编号(AI-开头)',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
+ brand_grouping_id BIGINT COMMENT '品牌组合ID',
+ plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '计划采购金额(预算)',
+ actual_amount DECIMAL(14,2) DEFAULT 0 COMMENT '实际分配金额',
+ part_count INT DEFAULT 0 COMMENT '分配配件数量',
+ base_ratio DECIMAL(10,4) COMMENT '基准库销比',
+ status TINYINT DEFAULT 0 COMMENT '状态: 0-运行中 1-成功 2-失败',
+ error_message TEXT COMMENT '错误信息',
+ llm_provider VARCHAR(32) COMMENT 'LLM提供商',
+ llm_model VARCHAR(64) COMMENT 'LLM模型名称',
+ llm_total_tokens INT DEFAULT 0 COMMENT 'LLM总Token数',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ start_time DATETIME COMMENT '任务开始时间',
+ end_time DATETIME COMMENT '任务结束时间',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_group_date (group_id, statistics_date),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货任务表-记录每次补货建议任务执行';
+
+-- 3. AI补货建议明细表
+DROP TABLE IF EXISTS ai_replenishment_detail;
+CREATE TABLE ai_replenishment_detail (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ brand_grouping_id BIGINT COMMENT '品牌组合ID',
+ shop_id BIGINT NOT NULL COMMENT '库房ID',
+ shop_name VARCHAR(128) COMMENT '库房名称',
+ part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
+ part_name VARCHAR(256) COMMENT '配件名称',
+ unit VARCHAR(32) COMMENT '单位',
+ cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价',
+ current_ratio DECIMAL(10,4) COMMENT '当前库销比',
+ base_ratio DECIMAL(10,4) COMMENT '基准库销比',
+ post_plan_ratio DECIMAL(10,4) COMMENT '计划后预计库销比',
+ valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量',
+ avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)',
+ suggest_cnt INT DEFAULT 0 COMMENT '建议采购数量',
+ suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议采购金额',
+ suggestion_reason TEXT COMMENT '补货建议理由',
+ priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低',
+ llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_task_no (task_no),
+ INDEX idx_shop_part (shop_id, part_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议明细表-存储每次任务的配件分配结果';
+
+-- 4. AI补货报告表
+DROP TABLE IF EXISTS ai_replenishment_report;
+CREATE TABLE ai_replenishment_report (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
+ total_storage_amount DECIMAL(16,2) COMMENT '总库存金额',
+ total_sales_amount DECIMAL(16,2) COMMENT '总销售金额(90天)',
+ overall_ratio DECIMAL(10,4) COMMENT '整体库销比',
+ target_ratio DECIMAL(10,4) COMMENT '目标库销比',
+ total_part_count INT DEFAULT 0 COMMENT '配件总数',
+ shortage_part_count INT DEFAULT 0 COMMENT '缺货配件数',
+ overstock_part_count INT DEFAULT 0 COMMENT '超标配件数',
+ normal_part_count INT DEFAULT 0 COMMENT '正常配件数',
+ stagnant_part_count INT DEFAULT 0 COMMENT '呆滞配件数',
+ suggest_total_amount DECIMAL(14,2) COMMENT '建议采购总金额',
+ suggest_part_count INT DEFAULT 0 COMMENT '建议采购配件数',
+ top_priority_parts TEXT COMMENT '重点关注配件(JSON数组)',
+ report_title VARCHAR(256) COMMENT '报告标题',
+ executive_summary TEXT COMMENT '执行摘要',
+ inventory_analysis TEXT COMMENT '库存分析',
+ risk_assessment TEXT COMMENT '风险评估',
+ purchase_recommendations TEXT COMMENT '采购建议',
+ optimization_suggestions TEXT COMMENT '优化建议',
+ full_report_markdown LONGTEXT COMMENT '完整报告(Markdown)',
+ llm_provider VARCHAR(32) COMMENT 'LLM提供商',
+ llm_model VARCHAR(64) COMMENT 'LLM模型',
+ generation_tokens INT DEFAULT 0 COMMENT '生成Token数',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_task_no (task_no),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货分析报告表-存储LLM生成的分析报告';
diff --git b/sql/v2_add_log_tables.sql a/sql/v2_add_log_tables.sql
new file mode 100644
index 0000000..1b22616
--- /dev/null
+++ a/sql/v2_add_log_tables.sql
@@ -0,0 +1,60 @@
+-- AI 任务执行日志表
+-- 版本: 1.0
+-- 创建日期: 2026-01-28
+
+-- 任务执行日志表
+CREATE TABLE IF NOT EXISTS ai_task_execution_log (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ brand_grouping_id BIGINT COMMENT '品牌组合ID',
+ brand_grouping_name VARCHAR(128) COMMENT '品牌组合名称',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
+ step_name VARCHAR(64) NOT NULL COMMENT '步骤名称',
+ step_order INT DEFAULT 0 COMMENT '步骤顺序',
+ status TINYINT DEFAULT 0 COMMENT '状态: 0-进行中 1-成功 2-失败 3-跳过',
+ input_data TEXT COMMENT '输入数据(JSON)',
+ output_data TEXT COMMENT '输出数据(JSON)',
+ error_message TEXT COMMENT '错误信息',
+ retry_count INT DEFAULT 0 COMMENT '重试次数',
+ sql_query TEXT COMMENT 'SQL查询语句(如有)',
+ llm_prompt TEXT COMMENT 'LLM提示词(如有)',
+ llm_response TEXT COMMENT 'LLM响应(如有)',
+ llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗',
+ execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)',
+ start_time DATETIME COMMENT '开始时间',
+ end_time DATETIME COMMENT '结束时间',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_task_no (task_no),
+ INDEX idx_group_date (group_id, create_time),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI任务执行日志表-记录每个步骤的执行详情';
+
+-- LLM补货建议明细表(商家组合维度)
+CREATE TABLE IF NOT EXISTS ai_llm_suggestion_detail (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
+ shop_id BIGINT NOT NULL COMMENT '库房ID',
+ shop_name VARCHAR(128) COMMENT '库房名称',
+ part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
+ part_name VARCHAR(256) COMMENT '配件名称',
+ unit VARCHAR(32) COMMENT '单位',
+ cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价',
+ current_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '当前库存数量',
+ avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)',
+ current_ratio DECIMAL(10,4) COMMENT '当前库销比',
+ suggest_cnt INT DEFAULT 0 COMMENT 'LLM建议采购数量',
+ suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT 'LLM建议采购金额',
+ suggestion_reason TEXT COMMENT 'LLM建议依据/理由',
+ priority INT DEFAULT 0 COMMENT '优先级(1-高 2-中 3-低)',
+ llm_confidence DECIMAL(5,2) COMMENT 'LLM置信度(0-1)',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ INDEX idx_task_no (task_no),
+ INDEX idx_dealer_grouping (dealer_grouping_id),
+ INDEX idx_shop_part (shop_id, part_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM补货建议明细表-存储LLM生成的商家组合维度配件补货建议及依据';
diff --git b/sql/v3_add_suggestion_reason.sql a/sql/v3_add_suggestion_reason.sql
new file mode 100644
index 0000000..b9677d7
--- /dev/null
+++ a/sql/v3_add_suggestion_reason.sql
@@ -0,0 +1,6 @@
+-- AI补货建议明细表 - 添加建议理由字段
+-- 版本: 3.0
+-- 创建日期: 2026-01-29
+
+ALTER TABLE ai_replenishment_detail
+ADD COLUMN suggestion_reason TEXT COMMENT 'LLM建议理由' AFTER suggest_amount;
diff --git b/sql/v4_cleanup_unused_tables.sql a/sql/v4_cleanup_unused_tables.sql
new file mode 100644
index 0000000..61b1606
--- /dev/null
+++ a/sql/v4_cleanup_unused_tables.sql
@@ -0,0 +1,7 @@
+-- 清理无效的 AI 业务表
+-- 版本: 4.0
+-- 创建日期: 2026-01-30
+-- 描述: 删除不再使用的 ai_llm_suggestion_detail 和 ai_pre_plan_detail 表
+
+DROP TABLE IF EXISTS ai_llm_suggestion_detail;
+DROP TABLE IF EXISTS ai_pre_plan_detail;
diff --git b/sql/v4_report_json_structure.sql a/sql/v4_report_json_structure.sql
new file mode 100644
index 0000000..4a3421a
--- /dev/null
+++ a/sql/v4_report_json_structure.sql
@@ -0,0 +1,17 @@
+-- 报告结构化重构 - 将 Markdown 字段改为 JSON
+-- 版本: 4.0
+-- 日期: 2026-01-30
+
+-- 1. 新增 JSON 字段
+ALTER TABLE ai_replenishment_report
+ADD COLUMN report_content JSON COMMENT '结构化报告内容(JSON)' AFTER top_priority_parts;
+
+-- 2. 删除旧的 Markdown 字段
+ALTER TABLE ai_replenishment_report
+DROP COLUMN report_title,
+DROP COLUMN executive_summary,
+DROP COLUMN inventory_analysis,
+DROP COLUMN risk_assessment,
+DROP COLUMN purchase_recommendations,
+DROP COLUMN optimization_suggestions,
+DROP COLUMN full_report_markdown;
diff --git b/sql/v5_add_priority_and_confidence.sql a/sql/v5_add_priority_and_confidence.sql
new file mode 100644
index 0000000..aad5366
--- /dev/null
+++ a/sql/v5_add_priority_and_confidence.sql
@@ -0,0 +1,6 @@
+-- 5. 添加优先级和置信度字段
+-- 时间: 2026-01-30
+
+ALTER TABLE ai_replenishment_detail
+ADD COLUMN priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低' AFTER suggestion_reason,
+ADD COLUMN llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度' AFTER priority;
diff --git b/sql/v6_add_part_summary.sql a/sql/v6_add_part_summary.sql
new file mode 100644
index 0000000..6ed4391
--- /dev/null
+++ a/sql/v6_add_part_summary.sql
@@ -0,0 +1,37 @@
+-- v6: 新增配件汇总表
+-- 用于存储配件在商家组合维度的汇总补货建议
+
+CREATE TABLE IF NOT EXISTS ai_replenishment_part_summary (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
+ part_name VARCHAR(256) COMMENT '配件名称',
+ unit VARCHAR(32) COMMENT '单位',
+ cost_price DECIMAL(14,2) DEFAULT 0.00 COMMENT '成本价',
+
+ -- 商家组合级别汇总数据
+ total_storage_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总库存数量',
+ total_avg_sales_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总月均销量',
+ group_current_ratio DECIMAL(10,4) COMMENT '商家组合级库销比',
+
+ -- 补货建议汇总
+ total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量',
+ total_suggest_amount DECIMAL(14,2) DEFAULT 0.00 COMMENT '总建议金额',
+ shop_count INT DEFAULT 0 COMMENT '涉及门店数',
+ need_replenishment_shop_count INT DEFAULT 0 COMMENT '需要补货的门店数',
+
+ -- LLM分析结果
+ part_decision_reason TEXT COMMENT '配件级补货决策理由',
+ priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1=高, 2=中, 3=低',
+ llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度',
+
+ -- 元数据
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+
+ INDEX idx_task_no (task_no),
+ INDEX idx_part_code (part_code),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议-配件汇总表';
diff --git b/sql/v7_add_low_frequency_count.sql a/sql/v7_add_low_frequency_count.sql
new file mode 100644
index 0000000..5c56518
--- /dev/null
+++ a/sql/v7_add_low_frequency_count.sql
@@ -0,0 +1,6 @@
+-- v7: 添加低频件统计字段
+-- 用于记录出库次数<3 或 平均出库间隔>=30天的配件数量
+
+ALTER TABLE ai_replenishment_report
+ADD COLUMN low_frequency_part_count INT DEFAULT 0 COMMENT '低频件数量'
+AFTER stagnant_part_count;
diff --git b/sql/v8_analysis_report.sql a/sql/v8_analysis_report.sql
new file mode 100644
index 0000000..2ed386a
--- /dev/null
+++ a/sql/v8_analysis_report.sql
@@ -0,0 +1,44 @@
+-- v8_analysis_report.sql
+-- 分析报告表,支持按品牌组合分组的分析数据持久化
+-- 分析内容由 LLM 生成
+
+CREATE TABLE IF NOT EXISTS ai_analysis_report (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
+ group_id BIGINT NOT NULL COMMENT '集团ID',
+ dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
+ brand_grouping_id BIGINT DEFAULT NULL COMMENT '品牌组合ID,NULL表示全部品牌',
+ brand_grouping_name VARCHAR(256) DEFAULT NULL COMMENT '品牌组合名称',
+
+ -- 风险管控统计(数量)
+ total_count INT DEFAULT 0 COMMENT '配件总数',
+ shortage_count INT DEFAULT 0 COMMENT '缺货数量',
+ overstock_count INT DEFAULT 0 COMMENT '超标数量',
+ normal_count INT DEFAULT 0 COMMENT '正常数量',
+ low_frequency_count INT DEFAULT 0 COMMENT '低频数量',
+ stagnant_count INT DEFAULT 0 COMMENT '呆滞数量',
+
+ -- 风险管控统计(金额)
+ total_amount DECIMAL(16,2) DEFAULT 0 COMMENT '总金额',
+ shortage_amount DECIMAL(16,2) DEFAULT 0 COMMENT '缺货金额',
+ overstock_amount DECIMAL(16,2) DEFAULT 0 COMMENT '超标金额',
+ normal_amount DECIMAL(16,2) DEFAULT 0 COMMENT '正常金额',
+ low_frequency_amount DECIMAL(16,2) DEFAULT 0 COMMENT '低频金额',
+ stagnant_amount DECIMAL(16,2) DEFAULT 0 COMMENT '呆滞金额',
+
+ -- LLM 生成的分析内容
+ overview_analysis TEXT COMMENT '核心经营综述(LLM生成)',
+ risk_analysis TEXT COMMENT '风险管控分析(LLM生成)',
+ action_plan TEXT COMMENT '行动计划分析(LLM生成)',
+ report_content JSON COMMENT '结构化报告内容',
+
+ llm_provider VARCHAR(32) COMMENT 'LLM提供商',
+ llm_model VARCHAR(64) COMMENT 'LLM模型名称',
+ generation_tokens INT DEFAULT 0 COMMENT '生成token数',
+ statistics_date VARCHAR(16) COMMENT '统计日期',
+ create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+
+ INDEX idx_task_no (task_no),
+ INDEX idx_task_brand (task_no, brand_grouping_id),
+ INDEX idx_dealer_grouping (dealer_grouping_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI分析报告表';
diff --git b/src/fw_pms_ai/__init__.py a/src/fw_pms_ai/__init__.py
new file mode 100644
index 0000000..0eef170
--- /dev/null
+++ a/src/fw_pms_ai/__init__.py
@@ -0,0 +1,5 @@
+"""
+fw-pms-ai: AI 补货建议系统
+"""
+
+__version__ = "0.1.0"
diff --git b/src/fw_pms_ai/agent/__init__.py a/src/fw_pms_ai/agent/__init__.py
new file mode 100644
index 0000000..70e6a63
--- /dev/null
+++ a/src/fw_pms_ai/agent/__init__.py
@@ -0,0 +1,17 @@
+"""Agent 模块"""
+
+from .state import AgentState
+from .replenishment import ReplenishmentAgent
+from .sql_agent import SQLAgent
+from ..models import SQLExecutionResult, ReplenishmentSuggestion, PartAnalysisResult
+
+__all__ = [
+ "AgentState",
+ "ReplenishmentAgent",
+ "SQLAgent",
+ "SQLExecutionResult",
+ "ReplenishmentSuggestion",
+ "PartAnalysisResult",
+]
+
+
diff --git b/src/fw_pms_ai/agent/nodes.py a/src/fw_pms_ai/agent/nodes.py
new file mode 100644
index 0000000..6f15c84
--- /dev/null
+++ a/src/fw_pms_ai/agent/nodes.py
@@ -0,0 +1,452 @@
+"""
+LangGraph Agent 节点实现
+
+重构版本:直接使用 part_ratio 数据 + SQL Agent
+"""
+
+import logging
+import time
+import json
+from typing import Dict, List
+from decimal import Decimal
+from datetime import datetime
+
+from langchain_core.messages import SystemMessage, HumanMessage
+
+from .state import AgentState
+from .sql_agent import SQLAgent
+from ..models import ReplenishmentSuggestion, PartAnalysisResult
+from ..llm import get_llm_client
+from ..services import DataService
+from ..services.result_writer import ResultWriter
+from ..models import ReplenishmentDetail, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
+
+logger = logging.getLogger(__name__)
+
+
+def _load_prompt(filename: str) -> str:
+ """从prompts目录加载提示词文件"""
+ import os
+ # 从 src/fw_pms_ai/agent/nodes.py 向上4层到达项目根目录
+ prompt_path = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
+ "prompts", filename
+ )
+ try:
+ with open(prompt_path, "r", encoding="utf-8") as f:
+ return f.read()
+ except FileNotFoundError:
+ logger.warning(f"Prompt文件未找到: {prompt_path}")
+ return ""
+
+
+
+def fetch_part_ratio_node(state: AgentState) -> AgentState:
+ """
+ 节点1: 获取 part_ratio 数据
+ 直接通过 dealer_grouping_id 从 part_ratio 表获取配件库销比数据
+ """
+ logger.info(f"[FetchPartRatio] ========== 开始获取数据 ==========")
+ logger.info(
+ f"[FetchPartRatio] group_id={state['group_id']}, "
+ f"dealer_grouping_id={state['dealer_grouping_id']}, "
+ f"date={state['statistics_date']}"
+ )
+
+ start_time = time.time()
+
+ sql_agent = SQLAgent()
+
+ try:
+ # 直接使用 dealer_grouping_id 获取 part_ratio 数据
+ part_ratios = sql_agent.fetch_part_ratios(
+ group_id=state["group_id"],
+ dealer_grouping_id=state["dealer_grouping_id"],
+ statistics_date=state["statistics_date"],
+ )
+
+ execution_time = int((time.time() - start_time) * 1000)
+
+ # 记录执行日志
+ log_entry = {
+ "step_name": "fetch_part_ratio",
+ "step_order": 1,
+ "status": LogStatus.SUCCESS if part_ratios else LogStatus.SKIPPED,
+ "input_data": json.dumps({
+ "dealer_grouping_id": state["dealer_grouping_id"],
+ "statistics_date": state["statistics_date"],
+ }),
+ "output_data": json.dumps({"part_ratios_count": len(part_ratios)}),
+ "execution_time_ms": execution_time,
+ "start_time": datetime.now().isoformat(),
+ }
+
+ logger.info(
+ f"[FetchPartRatio] 数据获取完成: part_ratios={len(part_ratios)}, "
+ f"耗时={execution_time}ms"
+ )
+
+ return {
+ **state,
+ "part_ratios": part_ratios,
+ "sql_execution_logs": [log_entry],
+ "current_node": "fetch_part_ratio",
+ "next_node": "sql_agent",
+ }
+
+ finally:
+ sql_agent.close()
+
+
+
+def sql_agent_node(state: AgentState) -> AgentState:
+ """
+ 节点2: SQL Agent 分析和生成建议
+ 按 part_code 分组,逐个配件分析各门店的补货需求
+ """
+ part_ratios = state.get("part_ratios", [])
+
+
+ logger.info(f"[SQLAgent] 开始分析: part_ratios={len(part_ratios)}")
+
+ start_time = time.time()
+ retry_count = state.get("sql_retry_count", 0)
+
+ if not part_ratios:
+ logger.warning("[SQLAgent] 无配件数据可分析")
+
+ log_entry = {
+ "step_name": "sql_agent",
+ "step_order": 2,
+ "status": LogStatus.SKIPPED,
+ "error_message": "无配件数据",
+ "execution_time_ms": int((time.time() - start_time) * 1000),
+ }
+
+ return {
+ **state,
+ "llm_suggestions": [],
+ "llm_analysis_summary": "无配件数据可分析",
+ "sql_execution_logs": [log_entry],
+ "current_node": "sql_agent",
+ "next_node": "allocate_budget",
+ }
+
+ sql_agent = SQLAgent()
+
+ try:
+ # 计算基准库销比(仅用于记录,不影响LLM建议)
+ total_valid_storage = sum(
+ Decimal(str(p.get("valid_storage_cnt", 0) or 0))
+ for p in part_ratios
+ )
+ total_avg_sales = sum(
+ Decimal(str(p.get("avg_sales_cnt", 0) or 0))
+ for p in part_ratios
+ )
+
+ if total_avg_sales > 0:
+ base_ratio = total_valid_storage / total_avg_sales
+ else:
+ base_ratio = Decimal("0")
+
+ logger.info(
+ f"[SQLAgent] 当前库销比: 总库存={total_valid_storage}, "
+ f"总销量={total_avg_sales}, 库销比={base_ratio}"
+ )
+
+ # 定义批处理回调
+ # 由于 models 中没有 ResultWriter 的引用,这里尝试直接从 services 导入或实例化
+ # 为避免循环导入,我们在函数内导入
+ from ..services import ResultWriter as WriterService
+ writer = WriterService()
+
+ # 1. 任务开始时清理旧数据(确保重试时不会产生重复数据)
+ # logger.info(f"[SQLAgent] 清理旧建议数据: task_no={state['task_no']}")
+ # writer.clear_llm_suggestions(state["task_no"])
+
+ # 2. 移除批处理回调(不再过程写入,改为最后统一写入)
+ save_batch_callback = None
+
+ # 使用分组分析生成补货建议(按 part_code 分组,逐个配件分析各门店需求)
+ suggestions, part_results, llm_stats = sql_agent.analyze_parts_by_group(
+ part_ratios=part_ratios,
+ dealer_grouping_id=state["dealer_grouping_id"],
+ dealer_grouping_name=state["dealer_grouping_name"],
+ statistics_date=state["statistics_date"],
+ target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"),
+ limit=1,
+ callback=save_batch_callback,
+ )
+
+ execution_time = int((time.time() - start_time) * 1000)
+
+ # 记录执行日志
+ log_entry = {
+ "step_name": "sql_agent",
+ "step_order": 2,
+ "status": LogStatus.SUCCESS,
+ "input_data": json.dumps({
+ "part_ratios_count": len(part_ratios),
+ }),
+ "output_data": json.dumps({
+ "suggestions_count": len(suggestions),
+ "part_results_count": len(part_results),
+ "base_ratio": float(base_ratio),
+ }),
+ "llm_tokens": llm_stats.get("prompt_tokens", 0) + llm_stats.get("completion_tokens", 0),
+ "execution_time_ms": execution_time,
+ "retry_count": retry_count,
+ }
+
+ logger.info(
+ f"[SQLAgent] 分析完成: 建议数={len(suggestions)}, "
+ f"配件汇总数={len(part_results)}, tokens={llm_stats}, 耗时={execution_time}ms"
+ )
+
+ return {
+ **state,
+ "base_ratio": base_ratio,
+ "llm_suggestions": suggestions,
+ "part_results": part_results,
+ "llm_prompt_tokens": state.get("llm_prompt_tokens", 0) + llm_stats.get("prompt_tokens", 0),
+ "llm_completion_tokens": state.get("llm_completion_tokens", 0) + llm_stats.get("completion_tokens", 0),
+ "sql_execution_logs": [log_entry],
+ "current_node": "sql_agent",
+ "next_node": "allocate_budget",
+ }
+
+ except Exception as e:
+ logger.error(f"[SQLAgent] 执行失败: {e}")
+
+ log_entry = {
+ "step_name": "sql_agent",
+ "step_order": 2,
+ "status": LogStatus.FAILED,
+ "error_message": str(e),
+ "retry_count": retry_count,
+ "execution_time_ms": int((time.time() - start_time) * 1000),
+ }
+
+ # 检查是否需要重试
+ if retry_count < 3:
+ return {
+ **state,
+ "sql_retry_count": retry_count + 1,
+ "sql_execution_logs": [log_entry],
+ "current_node": "sql_agent",
+ "next_node": "sql_agent", # 重试
+ "error_message": str(e),
+ }
+
+ return {
+ **state,
+ "llm_suggestions": [],
+ "sql_execution_logs": [log_entry],
+ "current_node": "sql_agent",
+ "next_node": "allocate_budget",
+ "error_message": str(e),
+ }
+
+ finally:
+ sql_agent.close()
+
+
+def allocate_budget_node(state: AgentState) -> AgentState:
+ """
+ 节点3: 转换LLM建议为补货明细
+ 注意:不做预算截断,所有建议直接输出
+ """
+ logger.info(f"[AllocateBudget] 开始处理LLM建议")
+
+ start_time = time.time()
+
+ llm_suggestions = state.get("llm_suggestions", [])
+
+ if not llm_suggestions:
+ logger.warning("[AllocateBudget] 无LLM建议可处理")
+
+ log_entry = {
+ "step_name": "allocate_budget",
+ "step_order": 3,
+ "status": LogStatus.SKIPPED,
+ "error_message": "无LLM建议",
+ "execution_time_ms": int((time.time() - start_time) * 1000),
+ }
+
+ return {
+ **state,
+ "details": [],
+ "sql_execution_logs": [log_entry],
+ "current_node": "allocate_budget",
+ "next_node": "end",
+ }
+
+ # 按优先级和库销比排序(优先级升序,库销比升序)
+ sorted_suggestions = sorted(
+ llm_suggestions,
+ key=lambda x: (x.priority, float(x.current_ratio))
+ )
+
+ # 建立 part_code -> brand_grouping_id 映射,确保明细归属正确的品牌组合
+ part_ratios = state.get("part_ratios", [])
+ part_brand_map = {p.get("part_code"): p.get("brand_grouping_id") for p in part_ratios if p.get("part_code")}
+
+ allocated_details = []
+ total_amount = Decimal("0")
+
+ # 转换所有建议为明细(包括不需要补货的配件,以便记录完整分析结果)
+ for suggestion in sorted_suggestions:
+ # 获取该配件对应的 brand_grouping_id
+ bg_id = part_brand_map.get(suggestion.part_code)
+ if bg_id is None:
+ bg_id = state.get("brand_grouping_id")
+
+ detail = ReplenishmentDetail(
+ task_no=state["task_no"],
+ group_id=state["group_id"],
+ dealer_grouping_id=state["dealer_grouping_id"],
+ brand_grouping_id=bg_id,
+ shop_id=suggestion.shop_id,
+ shop_name=suggestion.shop_name,
+ part_code=suggestion.part_code,
+ part_name=suggestion.part_name,
+ unit=suggestion.unit,
+ cost_price=suggestion.cost_price,
+ base_ratio=state.get("base_ratio", Decimal("1.1")),
+ current_ratio=suggestion.current_ratio,
+ valid_storage_cnt=suggestion.current_storage_cnt,
+ avg_sales_cnt=suggestion.avg_sales_cnt,
+ suggest_cnt=suggestion.suggest_cnt,
+ suggest_amount=suggestion.suggest_amount,
+ suggestion_reason=suggestion.suggestion_reason,
+ priority=suggestion.priority,
+ llm_confidence=suggestion.confidence,
+ statistics_date=state["statistics_date"],
+ )
+ # 计算预计库销比
+ post_storage = detail.valid_storage_cnt + detail.suggest_cnt
+ if post_storage <= 0 or detail.avg_sales_cnt <= 0:
+ # 库存为0或销量为0时,库销比设为0
+ detail.post_plan_ratio = Decimal("0")
+ else:
+ detail.post_plan_ratio = post_storage / detail.avg_sales_cnt
+
+ allocated_details.append(detail)
+ total_amount += suggestion.suggest_amount
+
+ execution_time = int((time.time() - start_time) * 1000)
+
+ # 记录执行日志
+ log_entry = {
+ "step_name": "allocate_budget",
+ "step_order": 3,
+ "status": LogStatus.SUCCESS,
+ "input_data": json.dumps({
+ "suggestions_count": len(llm_suggestions),
+ }),
+ "output_data": json.dumps({
+ "details_count": len(allocated_details),
+ "total_amount": float(total_amount),
+ }),
+ "execution_time_ms": execution_time,
+ }
+
+ logger.info(
+ f"[AllocateBudget] 分配完成: 配件数={len(allocated_details)}, "
+ f"金额={total_amount}"
+ )
+
+ # 保存结果到数据库
+ try:
+ writer = ResultWriter()
+
+ # 0. 先清理旧数据(防止重试或重复执行时产生重复记录)
+ writer.delete_details_by_task(state["task_no"])
+ writer.delete_part_summaries_by_task(state["task_no"])
+ logger.info(f"[AllocateBudget] 已清理旧数据: task_no={state['task_no']}")
+
+ # 1. 保存补货明细
+ if allocated_details:
+ writer.save_details(allocated_details)
+ logger.info(f"[AllocateBudget] 已保存 {len(allocated_details)} 条补货明细")
+
+ # 2. 保存配件汇总
+ part_results = state.get("part_results", [])
+ if part_results:
+ part_summaries = []
+ for pr in part_results:
+ summary = ReplenishmentPartSummary(
+ task_no=state["task_no"],
+ group_id=state["group_id"],
+ dealer_grouping_id=state["dealer_grouping_id"],
+ part_code=pr.part_code,
+ part_name=pr.part_name,
+ unit=pr.unit,
+ cost_price=pr.cost_price,
+ total_storage_cnt=pr.total_storage_cnt,
+ total_avg_sales_cnt=pr.total_avg_sales_cnt,
+ group_current_ratio=pr.group_current_ratio,
+ total_suggest_cnt=pr.total_suggest_cnt,
+ total_suggest_amount=pr.total_suggest_amount,
+ shop_count=pr.shop_count,
+ need_replenishment_shop_count=pr.need_replenishment_shop_count,
+ part_decision_reason=pr.part_decision_reason,
+ priority=pr.priority,
+ llm_confidence=pr.confidence,
+ statistics_date=state["statistics_date"],
+ )
+ part_summaries.append(summary)
+
+ writer.save_part_summaries(part_summaries)
+ logger.info(f"[AllocateBudget] 已保存 {len(part_summaries)} 条配件分析汇总")
+
+ writer.close()
+ except Exception as e:
+ logger.error(f"[AllocateBudget] 保存结果失败: {e}")
+ # 记录错误但不中断流程
+ error_log = {
+ "step_name": "allocate_budget",
+ "step_order": 3,
+ "status": LogStatus.FAILED,
+ "error_message": f"保存结果失败: {str(e)}",
+ "execution_time_ms": 0,
+ }
+
+ return {
+ **state,
+ "details": allocated_details,
+ "sql_execution_logs": [log_entry, error_log],
+ "current_node": "allocate_budget",
+ "next_node": "end",
+ "status": "success",
+ "end_time": time.time(),
+ }
+
+ return {
+ **state,
+ "details": allocated_details,
+ "sql_execution_logs": [log_entry],
+ "current_node": "allocate_budget",
+ "next_node": "end",
+ "status": "success",
+ "end_time": time.time(),
+ }
+
+
+def should_retry_sql(state: AgentState) -> str:
+ """条件边: 判断是否需要重试SQL Agent"""
+ next_node = state.get("next_node", "allocate_budget")
+ retry_count = state.get("sql_retry_count", 0)
+
+ if next_node == "sql_agent" and retry_count < 3:
+ logger.info(f"[Routing] SQL Agent需要重试: retry_count={retry_count}")
+ return "retry"
+
+ return "continue"
+
+
+def should_continue(state: AgentState) -> str:
+ """条件边: 判断是否继续"""
+ return state.get("next_node", "end")
+
diff --git b/src/fw_pms_ai/agent/replenishment.py a/src/fw_pms_ai/agent/replenishment.py
new file mode 100644
index 0000000..4b5bb52
--- /dev/null
+++ a/src/fw_pms_ai/agent/replenishment.py
@@ -0,0 +1,321 @@
+"""
+补货建议 Agent
+
+重构版本:使用 part_ratio + SQL Agent + LangGraph
+"""
+
+import logging
+import time
+import uuid
+from typing import Optional, List
+from datetime import date, datetime
+from decimal import Decimal
+
+from langgraph.graph import StateGraph, END
+
+from .state import AgentState
+from .nodes import (
+ fetch_part_ratio_node,
+ sql_agent_node,
+ allocate_budget_node,
+ should_retry_sql,
+)
+from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
+from ..services import ResultWriter
+
+logger = logging.getLogger(__name__)
+
+
+class ReplenishmentAgent:
+ """补货建议 Agent"""
+
+ def __init__(self):
+ self._graph = None
+ self._result_writer = ResultWriter()
+
+ @property
+ def graph(self) -> StateGraph:
+ """获取工作流图"""
+ if self._graph is None:
+ self._graph = self._build_graph()
+ return self._graph
+
+ def _build_graph(self) -> StateGraph:
+ """
+ 构建 LangGraph 工作流
+
+ 工作流结构:
+ fetch_part_ratio → sql_agent → allocate_budget → END
+ """
+ workflow = StateGraph(AgentState)
+
+ # 添加核心节点
+ workflow.add_node("fetch_part_ratio", fetch_part_ratio_node)
+ workflow.add_node("sql_agent", sql_agent_node)
+ workflow.add_node("allocate_budget", allocate_budget_node)
+
+ # 设置入口
+ workflow.set_entry_point("fetch_part_ratio")
+
+ # 添加边
+ workflow.add_edge("fetch_part_ratio", "sql_agent")
+
+ # SQL Agent 条件边(支持重试)
+ workflow.add_conditional_edges(
+ "sql_agent",
+ should_retry_sql,
+ {
+ "retry": "sql_agent",
+ "continue": "allocate_budget",
+ }
+ )
+
+ # allocate_budget → END
+ workflow.add_edge("allocate_budget", END)
+
+ return workflow.compile()
+
+ def run(
+ self,
+ group_id: int,
+ dealer_grouping_id: int,
+ dealer_grouping_name: str,
+ brand_grouping_id: Optional[int] = None,
+ brand_grouping_name: str = "",
+ statistics_date: Optional[str] = None,
+ ) -> AgentState:
+ """
+ 执行补货建议生成
+
+ Args:
+ group_id: 集团ID
+ dealer_grouping_id: 商家组合ID
+ dealer_grouping_name: 商家组合名称
+ brand_grouping_id: 品牌组合ID
+ brand_grouping_name: 品牌组合名称
+ statistics_date: 统计日期
+ """
+ task_no = f"AI-{uuid.uuid4().hex[:12].upper()}"
+ if statistics_date is None:
+ statistics_date = date.today().strftime("%Y-%m-%d")
+
+ logger.info(
+ f"开始执行补货建议: task_no={task_no}, "
+ f"dealer_grouping={dealer_grouping_name}"
+ )
+
+ # 初始化状态
+ initial_state: AgentState = {
+ "task_no": task_no,
+ "group_id": group_id,
+ "brand_grouping_id": brand_grouping_id,
+ "brand_grouping_name": brand_grouping_name,
+ "dealer_grouping_id": dealer_grouping_id,
+ "dealer_grouping_name": dealer_grouping_name,
+ "statistics_date": statistics_date,
+ "part_ratios": [],
+ "sql_queries": [],
+ "sql_results": [],
+ "sql_retry_count": 0,
+ "sql_execution_logs": [],
+ "base_ratio": Decimal("1.1"),
+ "allocated_details": [],
+ "details": [],
+ "llm_suggestions": [],
+ "part_results": [],
+ "report": None,
+ "llm_provider": "",
+ "llm_model": "",
+ "llm_prompt_tokens": 0,
+ "llm_completion_tokens": 0,
+
+ "status": "running",
+ "error_message": "",
+ "start_time": time.time(),
+ "end_time": 0,
+ "current_node": "",
+ "next_node": "fetch_part_ratio",
+ }
+
+ # 创建任务记录
+ task = ReplenishmentTask(
+ task_no=task_no,
+ group_id=group_id,
+ dealer_grouping_id=dealer_grouping_id,
+ dealer_grouping_name=dealer_grouping_name,
+ brand_grouping_id=brand_grouping_id,
+ statistics_date=statistics_date,
+ status=TaskStatus.RUNNING,
+ )
+ self._result_writer.save_task(task)
+
+ try:
+ # 执行工作流
+ final_state = self.graph.invoke(initial_state)
+
+ # 更新任务状态
+ execution_time = int((final_state.get("end_time", time.time()) - final_state["start_time"]) * 1000)
+ actual_amount = sum(d.suggest_amount for d in final_state.get("details", []))
+
+ task.status = TaskStatus.SUCCESS
+ task.actual_amount = actual_amount
+ task.part_count = len(final_state.get("details", []))
+ task.shop_count = len(set(d.shop_id for d in final_state.get("details", [])))
+ task.base_ratio = final_state.get("base_ratio", Decimal("0"))
+ task.llm_provider = final_state.get("llm_provider", "")
+ task.llm_model = final_state.get("llm_model", "")
+ task.llm_prompt_tokens = final_state.get("llm_prompt_tokens", 0)
+ task.llm_completion_tokens = final_state.get("llm_completion_tokens", 0)
+ task.llm_total_tokens = task.llm_prompt_tokens + task.llm_completion_tokens
+ task.llm_analysis_summary = final_state.get("llm_analysis_summary", "")
+ task.execution_time_ms = execution_time
+
+ self._result_writer.update_task(task)
+
+
+
+ # 保存执行日志
+ if final_state.get("sql_execution_logs"):
+ self._save_execution_logs(
+ task_no=task_no,
+ group_id=group_id,
+ brand_grouping_id=brand_grouping_id,
+ brand_grouping_name=brand_grouping_name,
+ dealer_grouping_id=dealer_grouping_id,
+ dealer_grouping_name=dealer_grouping_name,
+ logs=final_state["sql_execution_logs"],
+ )
+
+ # 配件汇总已在 allocate_budget_node 中保存,此处跳过避免重复
+ # if final_state.get("part_results"):
+ # self._save_part_summaries(
+ # task_no=task_no,
+ # group_id=group_id,
+ # dealer_grouping_id=dealer_grouping_id,
+ # statistics_date=statistics_date,
+ # part_results=final_state["part_results"],
+ # )
+
+ logger.info(
+ f"补货建议执行完成: task_no={task_no}, "
+ f"parts={task.part_count}, amount={actual_amount}, "
+ f"time={execution_time}ms"
+ )
+
+ return final_state
+
+ except Exception as e:
+ logger.error(f"补货建议执行失败: task_no={task_no}, error={e}")
+
+ task.status = TaskStatus.FAILED
+ task.error_message = str(e)
+ task.execution_time_ms = int((time.time() - initial_state["start_time"]) * 1000)
+ self._result_writer.update_task(task)
+
+ raise
+
+ finally:
+ self._result_writer.close()
+
+ def _save_execution_logs(
+ self,
+ task_no: str,
+ group_id: int,
+ brand_grouping_id: Optional[int],
+ brand_grouping_name: str,
+ dealer_grouping_id: int,
+ dealer_grouping_name: str,
+ logs: List[dict],
+ ):
+ """保存执行日志"""
+ for log_data in logs:
+ log = TaskExecutionLog(
+ task_no=task_no,
+ group_id=group_id,
+ brand_grouping_id=brand_grouping_id,
+ brand_grouping_name=brand_grouping_name,
+ dealer_grouping_id=dealer_grouping_id,
+ dealer_grouping_name=dealer_grouping_name,
+ step_name=log_data.get("step_name", ""),
+ step_order=log_data.get("step_order", 0),
+ status=log_data.get("status", LogStatus.SUCCESS),
+ input_data=log_data.get("input_data", ""),
+ output_data=log_data.get("output_data", ""),
+ error_message=log_data.get("error_message", ""),
+ retry_count=log_data.get("retry_count", 0),
+ sql_query=log_data.get("sql_query", ""),
+ llm_prompt=log_data.get("llm_prompt", ""),
+ llm_response=log_data.get("llm_response", ""),
+ llm_tokens=log_data.get("llm_tokens", 0),
+ execution_time_ms=log_data.get("execution_time_ms", 0),
+ )
+ self._result_writer.save_execution_log(log)
+
+ def _save_part_summaries(
+ self,
+ task_no: str,
+ group_id: int,
+ dealer_grouping_id: int,
+ statistics_date: str,
+ part_results: list,
+ ):
+ """保存配件汇总"""
+ from .sql_agent import PartAnalysisResult
+
+ summaries = []
+ for pr in part_results:
+ if not isinstance(pr, PartAnalysisResult):
+ continue
+ summary = ReplenishmentPartSummary(
+ task_no=task_no,
+ group_id=group_id,
+ dealer_grouping_id=dealer_grouping_id,
+ part_code=pr.part_code,
+ part_name=pr.part_name,
+ unit=pr.unit,
+ cost_price=pr.cost_price,
+ total_storage_cnt=pr.total_storage_cnt,
+ total_avg_sales_cnt=pr.total_avg_sales_cnt,
+ group_current_ratio=pr.group_current_ratio,
+ total_suggest_cnt=pr.total_suggest_cnt,
+ total_suggest_amount=pr.total_suggest_amount,
+ shop_count=pr.shop_count,
+ need_replenishment_shop_count=pr.need_replenishment_shop_count,
+ part_decision_reason=pr.part_decision_reason,
+ priority=pr.priority,
+ llm_confidence=pr.confidence,
+ statistics_date=statistics_date,
+ )
+ summaries.append(summary)
+
+ if summaries:
+ self._result_writer.save_part_summaries(summaries)
+ logger.info(f"保存配件汇总: count={len(summaries)}")
+
+ def run_for_all_groupings(self, group_id: int):
+ """
+ 为所有商家组合执行补货建议
+ """
+ from ..services import DataService
+
+ data_service = DataService()
+ try:
+ groupings = data_service.get_dealer_groupings(group_id)
+ logger.info(f"获取商家组合: group_id={group_id}, count={len(groupings)}")
+
+ for idx, grouping in enumerate(groupings):
+ logger.info(f"[{idx+1}/{len(groupings)}] 开始处理商家组合: {grouping['name']} (id={grouping['id']})")
+ try:
+ self.run(
+ group_id=group_id,
+ dealer_grouping_id=grouping["id"],
+ dealer_grouping_name=grouping["name"],
+ )
+ logger.info(f"[{grouping['name']}] 执行完成")
+
+ except Exception as e:
+ logger.error(f"商家组合执行失败: {grouping['name']}, error={e}", exc_info=True)
+ continue
+
+ finally:
+ data_service.close()
diff --git b/src/fw_pms_ai/agent/sql_agent/__init__.py a/src/fw_pms_ai/agent/sql_agent/__init__.py
new file mode 100644
index 0000000..f6ee0ff
--- /dev/null
+++ a/src/fw_pms_ai/agent/sql_agent/__init__.py
@@ -0,0 +1,17 @@
+"""
+SQL Agent 子包
+
+提供 SQL 执行和配件分析功能
+"""
+
+from .agent import SQLAgent
+from .executor import SQLExecutor
+from .analyzer import PartAnalyzer
+from .prompts import load_prompt
+
+__all__ = [
+ "SQLAgent",
+ "SQLExecutor",
+ "PartAnalyzer",
+ "load_prompt",
+]
diff --git b/src/fw_pms_ai/agent/sql_agent/agent.py a/src/fw_pms_ai/agent/sql_agent/agent.py
new file mode 100644
index 0000000..0a79343
--- /dev/null
+++ a/src/fw_pms_ai/agent/sql_agent/agent.py
@@ -0,0 +1,173 @@
+"""
+SQL Agent 主类模块
+
+组合 Executor 和 Analyzer 提供完整的 SQL Agent 功能
+"""
+
+import logging
+from typing import Any, Dict, List, Optional, Tuple
+from decimal import Decimal
+
+from .executor import SQLExecutor
+from .analyzer import PartAnalyzer
+from .prompts import load_prompt
+from ...models import SQLExecutionResult, ReplenishmentSuggestion, PartAnalysisResult
+
+logger = logging.getLogger(__name__)
+
+
+class SQLAgent:
+ """SQL Agent - 组合 SQL 执行和配件分析功能"""
+
+ def __init__(self, db_connection=None):
+ self._executor = SQLExecutor(db_connection)
+ self._analyzer = PartAnalyzer()
+
+ def close(self):
+ """关闭连接"""
+ self._executor.close()
+
+ # === 委托给 SQLExecutor 的方法 ===
+
+ def generate_sql(
+ self,
+ question: str,
+ context: Optional[Dict] = None,
+ previous_error: Optional[str] = None,
+ ) -> Tuple[str, str]:
+ """使用LLM生成SQL"""
+ return self._executor.generate_sql(question, context, previous_error)
+
+ def execute_sql(self, sql: str) -> Tuple[bool, Any, Optional[str]]:
+ """执行SQL查询"""
+ return self._executor.execute_sql(sql)
+
+ def query_with_retry(
+ self,
+ question: str,
+ context: Optional[Dict] = None,
+ ) -> SQLExecutionResult:
+ """带重试的查询"""
+ return self._executor.query_with_retry(question, context)
+
+ # === 委托给 PartAnalyzer 的方法 ===
+
+ def group_parts_by_code(self, part_ratios: List[Dict]) -> Dict[str, List[Dict]]:
+ """按配件编码分组"""
+ return self._analyzer.group_parts_by_code(part_ratios)
+
+ def generate_suggestions(
+ self,
+ part_data: List[Dict],
+ dealer_grouping_id: int,
+ dealer_grouping_name: str,
+ statistics_date: str,
+ ) -> Tuple[List[ReplenishmentSuggestion], Dict]:
+ """生成补货建议"""
+ return self._analyzer.generate_suggestions(
+ part_data, dealer_grouping_id, dealer_grouping_name, statistics_date
+ )
+
+ def analyze_parts_by_group(
+ self,
+ part_ratios: List[Dict],
+ dealer_grouping_id: int,
+ dealer_grouping_name: str,
+ statistics_date: str,
+ target_ratio: Decimal = Decimal("1.3"),
+ limit: Optional[int] = None,
+ callback: Optional[Any] = None,
+ ) -> Tuple[List[ReplenishmentSuggestion], List[PartAnalysisResult], Dict]:
+ """按配件分组分析补货建议"""
+ return self._analyzer.analyze_parts_by_group(
+ part_ratios,
+ dealer_grouping_id,
+ dealer_grouping_name,
+ statistics_date,
+ target_ratio,
+ limit,
+ callback,
+ )
+
+ # === 数据查询方法 ===
+
+ def fetch_part_ratios(
+ self,
+ group_id: int,
+ dealer_grouping_id: int,
+ statistics_date: str,
+ ) -> List[Dict]:
+ """
+ 查询part_ratio数据
+
+ Args:
+ group_id: 集团ID
+ dealer_grouping_id: 商家组合ID
+ statistics_date: 统计日期
+ Returns:
+ 配件库销比数据列表
+ """
+ conn = self._executor._get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ # 1. 查询商家组合关联的品牌组合配置
+ brand_grouping_ids = []
+ try:
+ cursor.execute(
+ "SELECT part_purchase_brand_assemble_id FROM artificial_region_dealer WHERE id = %s",
+ (dealer_grouping_id,)
+ )
+ rows = cursor.fetchall()
+ for row in rows:
+ bid = row.get("part_purchase_brand_assemble_id")
+ if bid:
+ brand_grouping_ids.append(bid)
+ if brand_grouping_ids:
+ logger.info(f"商家组合关联品牌组合: dealer_grouping_id={dealer_grouping_id} -> brand_grouping_ids={brand_grouping_ids}")
+ except Exception as e:
+ logger.warning(f"查询商家组合配置失败: {e}")
+
+ sql = """
+ SELECT
+ id, group_id, brand_id, brand_grouping_id,
+ dealer_grouping_id,
+ supplier_id, supplier_name, area_id, area_name,
+ shop_id, shop_name, part_id, part_code, part_name,
+ unit, cost_price,
+ in_stock_unlocked_cnt, has_plan_cnt, on_the_way_cnt,
+ out_stock_cnt, buy_cnt, storage_locked_cnt,
+ out_stock_ongoing_cnt, stock_age, out_times, out_duration,
+ transfer_cnt, gen_transfer_cnt,
+ part_biz_type, statistics_date,
+ (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) as valid_storage_cnt,
+ ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt
+ FROM part_ratio
+ WHERE group_id = %s
+ AND dealer_grouping_id = %s
+ AND statistics_date = %s
+ AND part_biz_type = 1
+ """
+ params = [group_id, dealer_grouping_id, statistics_date]
+
+ # 如果有配置的品牌组合,用 IN 过滤
+ if brand_grouping_ids:
+ placeholders = ", ".join(["%s"] * len(brand_grouping_ids))
+ sql += f" AND brand_grouping_id IN ({placeholders})"
+ params.extend(brand_grouping_ids)
+
+ # 优先处理有销量的配件
+ sql += """ ORDER BY
+ CASE WHEN ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) > 0 THEN 0 ELSE 1 END,
+ (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,
+ ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) DESC
+ """
+
+ cursor.execute(sql, params)
+ rows = cursor.fetchall()
+
+ logger.info(f"获取part_ratio数据: dealer_grouping_id={dealer_grouping_id}, count={len(rows)}")
+ return rows
+
+ finally:
+ cursor.close()
diff --git b/src/fw_pms_ai/agent/sql_agent/analyzer.py a/src/fw_pms_ai/agent/sql_agent/analyzer.py
new file mode 100644
index 0000000..c50164d
--- /dev/null
+++ a/src/fw_pms_ai/agent/sql_agent/analyzer.py
@@ -0,0 +1,545 @@
+"""
+配件分析器模块
+
+负责配件分组分析、LLM 调用和结果解析
+"""
+
+import logging
+import time
+import json
+import concurrent.futures
+from typing import Any, Dict, List, Optional, Tuple
+from decimal import Decimal
+
+from langchain_core.messages import SystemMessage, HumanMessage
+
+from .prompts import (
+ load_prompt,
+ SUGGESTION_PROMPT,
+ SUGGESTION_SYSTEM_PROMPT,
+ PART_SHOP_ANALYSIS_PROMPT,
+ PART_SHOP_ANALYSIS_SYSTEM_PROMPT,
+)
+from ...llm import get_llm_client
+from ...models import ReplenishmentSuggestion, PartAnalysisResult
+
+logger = logging.getLogger(__name__)
+
+
+class PartAnalyzer:
+ """配件分析器 - 负责 LLM 分析和结果解析"""
+
+ def __init__(self):
+ self._llm = get_llm_client()
+
+ def group_parts_by_code(self, part_ratios: List[Dict]) -> Dict[str, List[Dict]]:
+ """
+ 按配件编码分组
+
+ Args:
+ part_ratios: 配件库销比数据列表
+
+ Returns:
+ {part_code: [各门店数据列表]}
+ """
+ grouped = {}
+ for pr in part_ratios:
+ part_code = pr.get("part_code", "")
+ if not part_code:
+ continue
+ if part_code not in grouped:
+ grouped[part_code] = []
+ grouped[part_code].append(pr)
+
+ logger.info(f"配件分组完成: 总配件数={len(grouped)}, 总记录数={len(part_ratios)}")
+ return grouped
+
+ def generate_suggestions(
+ self,
+ part_data: List[Dict],
+ dealer_grouping_id: int,
+ dealer_grouping_name: str,
+ statistics_date: str,
+ ) -> Tuple[List[ReplenishmentSuggestion], Dict]:
+ """
+ 生成补货建议
+
+ Args:
+ part_data: 配件数据
+ dealer_grouping_id: 商家组合ID
+ dealer_grouping_name: 商家组合名称
+ statistics_date: 统计日期
+
+ Returns:
+ (补货建议列表, LLM统计信息)
+ """
+ if not part_data:
+ return [], {"prompt_tokens": 0, "completion_tokens": 0}
+
+ # 将所有数据传给LLM分析
+ part_data_str = json.dumps(part_data, ensure_ascii=False, indent=2, default=str)
+
+ prompt = SUGGESTION_PROMPT.format(
+ dealer_grouping_id=dealer_grouping_id,
+ dealer_grouping_name=dealer_grouping_name,
+ statistics_date=statistics_date,
+ part_data=part_data_str,
+ )
+
+ messages = [
+ SystemMessage(content=SUGGESTION_SYSTEM_PROMPT),
+ HumanMessage(content=prompt),
+ ]
+
+ response = self._llm.invoke(messages)
+ content = response.content.strip()
+
+ suggestions = []
+ try:
+ # 提取JSON
+ if "```json" in content:
+ content = content.split("```json")[1].split("```")[0].strip()
+ elif "```" in content:
+ content = content.split("```")[1].split("```")[0].strip()
+
+ raw_suggestions = json.loads(content)
+
+ for item in raw_suggestions:
+ suggestions.append(ReplenishmentSuggestion(
+ shop_id=item.get("shop_id", 0),
+ shop_name=item.get("shop_name", ""),
+ part_code=item.get("part_code", ""),
+ part_name=item.get("part_name", ""),
+ unit=item.get("unit", ""),
+ cost_price=Decimal(str(item.get("cost_price", 0))),
+ current_storage_cnt=Decimal(str(item.get("current_storage_cnt", 0))),
+ avg_sales_cnt=Decimal(str(item.get("avg_sales_cnt", 0))),
+ current_ratio=Decimal(str(item.get("current_ratio", 0))),
+ suggest_cnt=int(item.get("suggest_cnt", 0)),
+ suggest_amount=Decimal(str(item.get("suggest_amount", 0))),
+ suggestion_reason=item.get("suggestion_reason", ""),
+ priority=int(item.get("priority", 2)),
+ confidence=float(item.get("confidence", 0.8)),
+ ))
+
+ except json.JSONDecodeError as e:
+ logger.error(f"解析LLM建议失败: {e}")
+
+ llm_stats = {
+ "prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
+ "completion_tokens": response.usage.completion_tokens if response.usage else 0,
+ }
+
+ logger.info(f"生成补货建议: {len(suggestions)}条")
+ return suggestions, llm_stats
+
+ def analyze_parts_by_group(
+ self,
+ part_ratios: List[Dict],
+ dealer_grouping_id: int,
+ dealer_grouping_name: str,
+ statistics_date: str,
+ target_ratio: Decimal = Decimal("1.3"),
+ limit: Optional[int] = None,
+ callback: Optional[Any] = None,
+ ) -> Tuple[List[ReplenishmentSuggestion], List[PartAnalysisResult], Dict]:
+ """
+ 按配件分组分析补货建议
+
+ Args:
+ part_ratios: 配件库销比数据列表
+ dealer_grouping_id: 商家组合ID
+ dealer_grouping_name: 商家组合名称
+ statistics_date: 统计日期
+ target_ratio: 目标库销比(基准库销比)
+ limit: 测试限制数量
+ callback: 批处理回调函数(suggestions)
+
+ Returns:
+ (补货建议列表, 配件分析结果列表, LLM统计信息)
+ """
+ if not part_ratios:
+ return [], [], {"prompt_tokens": 0, "completion_tokens": 0}
+
+ # 按 part_code 分组
+ grouped_parts = self.group_parts_by_code(part_ratios)
+
+ # 应用限制
+ all_part_codes = list(grouped_parts.keys())
+ if limit and limit > 0:
+ logger.warning(f"启用测试限制: 仅处理前 {limit} 个配件 (总数: {len(all_part_codes)})")
+ all_part_codes = all_part_codes[:limit]
+
+ all_suggestions = []
+ all_part_results: List[PartAnalysisResult] = []
+ total_prompt_tokens = 0
+ total_completion_tokens = 0
+
+ system_prompt = PART_SHOP_ANALYSIS_SYSTEM_PROMPT
+ user_prompt_template = PART_SHOP_ANALYSIS_PROMPT
+
+ # 将目标库销比格式化到 Prompt 中
+ target_ratio_str = f"{float(target_ratio):.2f}"
+ system_prompt = system_prompt.replace("{target_ratio}", target_ratio_str)
+
+ def process_single_part(part_code: str) -> Tuple[PartAnalysisResult, List[ReplenishmentSuggestion], int, int]:
+ """处理单个配件"""
+ shop_data_list = grouped_parts[part_code]
+ if not shop_data_list:
+ return None, [], 0, 0
+
+ # 获取配件基本信息
+ first_item = shop_data_list[0]
+ part_name = first_item.get("part_name", "")
+ cost_price = first_item.get("cost_price", 0)
+ unit = first_item.get("unit", "")
+
+ # 构建门店数据
+ shop_data_str = json.dumps(shop_data_list, ensure_ascii=False, indent=2, default=str)
+
+ prompt = user_prompt_template.format(
+ part_code=part_code,
+ part_name=part_name,
+ cost_price=cost_price,
+ unit=unit,
+ dealer_grouping_name=dealer_grouping_name,
+ statistics_date=statistics_date,
+ shop_data=shop_data_str,
+ target_ratio=target_ratio_str,
+ )
+
+ messages = [
+ SystemMessage(content=system_prompt),
+ HumanMessage(content=prompt),
+ ]
+
+ p_tokens = 0
+ c_tokens = 0
+
+ try:
+ response = self._llm.invoke(messages)
+ content = response.content.strip()
+
+ if response.usage:
+ p_tokens = response.usage.prompt_tokens
+ c_tokens = response.usage.completion_tokens
+
+ # 解析 LLM 响应
+ part_result, suggestions = self._parse_part_analysis_response(
+ content, part_code, part_name, unit, cost_price, shop_data_list, target_ratio
+ )
+
+ # 请求间延迟,避免触发速率限制
+ time.sleep(0.5)
+
+ return part_result, suggestions, p_tokens, c_tokens
+
+ except Exception as e:
+ logger.error(f"分析配件 {part_code} 失败: {e}")
+ # 失败后等待更长时间再继续
+ time.sleep(2.0)
+ return None, [], 0, 0
+
+ # 并发执行
+ batch_size = 10
+ current_batch = []
+ finished_count = 0
+ total_count = len(all_part_codes)
+ # 最大并发数150,但不超过配件数量
+ max_workers = min(150, total_count) if total_count > 0 else 1
+
+ logger.info(f"开始并行分析: workers={max_workers}, parts={total_count}")
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
+ # 提交所有任务
+ future_to_part = {
+ executor.submit(process_single_part, code): code
+ for code in all_part_codes
+ }
+
+ for future in concurrent.futures.as_completed(future_to_part):
+ part_code = future_to_part[future]
+ finished_count += 1
+
+ try:
+ part_result, suggestions, p_t, c_t = future.result()
+
+ if part_result:
+ all_part_results.append(part_result)
+ if suggestions:
+ all_suggestions.extend(suggestions)
+ current_batch.extend(suggestions)
+
+ total_prompt_tokens += p_t
+ total_completion_tokens += c_t
+
+ # 批量回调处理
+ if callback and len(current_batch) >= batch_size:
+ try:
+ callback(current_batch)
+ logger.info(f"批次落库: {len(current_batch)} 条")
+ current_batch = []
+ except Exception as e:
+ logger.error(f"回调执行失败: {e}")
+
+ except Exception as e:
+ logger.error(f"任务执行异常 {part_code}: {e}")
+
+ if finished_count % 10 == 0:
+ logger.info(f"进度: {finished_count}/{total_count} ({(finished_count/total_count*100):.1f}%)")
+
+ # 处理剩余批次
+ if callback and current_batch:
+ try:
+ callback(current_batch)
+ logger.info(f"最后批次落库: {len(current_batch)} 条")
+ except Exception as e:
+ logger.error(f"最后回调执行失败: {e}")
+
+ llm_stats = {
+ "prompt_tokens": total_prompt_tokens,
+ "completion_tokens": total_completion_tokens,
+ }
+
+ logger.info(
+ f"分组分析完成: 配件数={len(grouped_parts)}, "
+ f"配件汇总数={len(all_part_results)}, "
+ f"建议数={len(all_suggestions)}, tokens={total_prompt_tokens + total_completion_tokens}"
+ )
+ return all_suggestions, all_part_results, llm_stats
+
+ def _calculate_priority_by_ratio(
+ self,
+ current_ratio: Decimal,
+ avg_sales: Decimal,
+ target_ratio: Decimal,
+ ) -> int:
+ """
+ 根据库销比计算优先级
+
+ 规则:
+ - 库销比 < 0.5 且月均销量 >= 1: 高优先级 (1)
+ - 库销比 0.5-1.0 且月均销量 >= 1: 中优先级 (2)
+ - 库销比 1.0-target_ratio 且月均销量 >= 1: 低优先级 (3)
+ - 其他情况: 无需补货 (0)
+
+ Args:
+ current_ratio: 当前库销比
+ avg_sales: 月均销量
+ target_ratio: 目标库销比
+
+ Returns:
+ 优先级 (0=无需补货, 1=高, 2=中, 3=低)
+ """
+ if avg_sales < 1:
+ return 0
+
+ if current_ratio < Decimal("0.5"):
+ return 1
+ elif current_ratio < Decimal("1.0"):
+ return 2
+ elif current_ratio < target_ratio:
+ return 3
+ else:
+ return 0
+
+ def _parse_part_analysis_response(
+ self,
+ content: str,
+ part_code: str,
+ part_name: str,
+ unit: str,
+ cost_price: float,
+ shop_data_list: List[Dict],
+ target_ratio: Decimal = Decimal("1.3"),
+ ) -> Tuple[PartAnalysisResult, List[ReplenishmentSuggestion]]:
+ """
+ 解析单配件分析响应
+
+ Args:
+ content: LLM 响应内容
+ part_code: 配件编码
+ part_name: 配件名称
+ unit: 单位
+ cost_price: 成本价
+ shop_data_list: 门店数据列表
+
+ Returns:
+ (配件分析结果, 补货建议列表)
+ """
+ suggestions = []
+
+ # 计算默认配件汇总数据
+ total_storage = sum(Decimal(str(s.get("valid_storage_cnt", 0))) for s in shop_data_list)
+ total_avg_sales = sum(Decimal(str(s.get("avg_sales_cnt", 0))) for s in shop_data_list)
+ group_ratio = total_storage / total_avg_sales if total_avg_sales > 0 else Decimal("0")
+
+ part_result = PartAnalysisResult(
+ part_code=part_code,
+ part_name=part_name,
+ unit=unit,
+ cost_price=Decimal(str(cost_price)),
+ total_storage_cnt=total_storage,
+ total_avg_sales_cnt=total_avg_sales,
+ group_current_ratio=group_ratio,
+ need_replenishment=False,
+ total_suggest_cnt=0,
+ total_suggest_amount=Decimal("0"),
+ shop_count=len(shop_data_list),
+ need_replenishment_shop_count=0,
+ part_decision_reason="",
+ priority=2,
+ confidence=0.8,
+ suggestions=[],
+ )
+
+ try:
+ # 提取 JSON
+ if "```json" in content:
+ content = content.split("```json")[1].split("```")[0].strip()
+ elif "```" in content:
+ content = content.split("```")[1].split("```")[0].strip()
+
+ result = json.loads(content)
+
+ # 获取配件级汇总信息
+ confidence = float(result.get("confidence", 0.8))
+ part_decision_reason = result.get("part_decision_reason", "")
+ need_replenishment = result.get("need_replenishment", False)
+ priority = int(result.get("priority", 2))
+
+ # 更新配件结果
+ part_result.need_replenishment = need_replenishment
+ part_result.total_suggest_cnt = int(result.get("total_suggest_cnt", 0))
+ part_result.total_suggest_amount = Decimal(str(result.get("total_suggest_amount", 0)))
+ part_result.shop_count = int(result.get("shop_count", len(shop_data_list)))
+ part_result.part_decision_reason = part_decision_reason
+ part_result.priority = priority
+ part_result.confidence = confidence
+
+ # 如果LLM返回了商家组合级数据,使用LLM的数据
+ if "total_storage_cnt" in result:
+ part_result.total_storage_cnt = Decimal(str(result["total_storage_cnt"]))
+ if "total_avg_sales_cnt" in result:
+ part_result.total_avg_sales_cnt = Decimal(str(result["total_avg_sales_cnt"]))
+ if "group_current_ratio" in result:
+ part_result.group_current_ratio = Decimal(str(result["group_current_ratio"]))
+
+ # 构建建议字典以便快速查找
+ shop_suggestion_map = {}
+ shop_suggestions_data = result.get("shop_suggestions", [])
+ if shop_suggestions_data:
+ for shop in shop_suggestions_data:
+ s_id = int(shop.get("shop_id", 0))
+ shop_suggestion_map[s_id] = shop
+
+ # 统计需要补货的门店数
+ need_replenishment_shop_count = len([s for s in shop_suggestions_data if int(s.get("suggest_cnt", 0)) > 0])
+ part_result.need_replenishment_shop_count = need_replenishment_shop_count
+
+ # 递归所有输入门店,确保每个门店都有记录
+ for shop_data in shop_data_list:
+ shop_id = int(shop_data.get("shop_id", 0))
+ shop_name = shop_data.get("shop_name", "")
+
+ # 检查LLM是否有针对该门店的建议
+ if shop_id in shop_suggestion_map:
+ s_item = shop_suggestion_map[shop_id]
+ suggest_cnt = int(s_item.get("suggest_cnt", 0))
+ suggest_amount = Decimal(str(s_item.get("suggest_amount", 0)))
+ reason = s_item.get("reason", part_decision_reason)
+ shop_priority = int(s_item.get("priority", priority))
+ else:
+ # LLM未提及该门店,根据门店数据生成个性化默认理由
+ suggest_cnt = 0
+ suggest_amount = Decimal("0")
+
+ # 计算该门店的库存和销售数据
+ _storage = Decimal(str(shop_data.get("valid_storage_cnt", 0)))
+ _avg_sales = Decimal(str(shop_data.get("avg_sales_cnt", 0)))
+ _out_times = shop_data.get("out_times", 0) or 0
+ _out_duration = shop_data.get("out_duration", 0) or 0
+ _ratio = _storage / _avg_sales if _avg_sales > 0 else Decimal("0")
+
+ # 根据库销比规则计算 priority
+ shop_priority = self._calculate_priority_by_ratio(_ratio, _avg_sales, target_ratio)
+
+ if _storage > 0 and _avg_sales <= 0:
+ reason = f"「呆滞件」当前库存{_storage}件,但90天内无销售记录,库存滞销风险高,暂不补货。"
+ shop_priority = 0
+ elif _storage <= 0 and _avg_sales < 1:
+ reason = f"「低频件-需求不足」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,需求过低,暂不纳入补货计划。"
+ shop_priority = 0
+ elif _out_times < 3:
+ reason = f"「低频件-出库次数不足」90天内仅出库{_out_times}次(阈值≥3次),周转频率过低,暂不纳入补货计划。"
+ shop_priority = 0
+ elif _out_duration >= 30:
+ reason = f"「低频件-出库间隔过长」平均出库间隔{_out_duration}天(阈值<30天),周转周期过长,暂不纳入补货计划。"
+ shop_priority = 0
+ elif _avg_sales > 0 and _ratio >= target_ratio:
+ _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0
+ reason = f"「库存充足」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,无需补货。"
+ shop_priority = 0
+ elif shop_priority == 1:
+ _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0
+ reason = f"「急需补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},仅可支撑约{_days}天销售,存在缺货风险。"
+ elif shop_priority == 2:
+ _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0
+ reason = f"「建议补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,库存偏低建议补货。"
+ elif shop_priority == 3:
+ _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0
+ reason = f"「可选补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,可根据资金情况酌情补货。"
+ else:
+ reason = f"「无需补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,AI分析判定暂不补货。"
+
+ curr_storage = Decimal(str(shop_data.get("valid_storage_cnt", 0)))
+ avg_sales = Decimal(str(shop_data.get("avg_sales_cnt", 0)))
+ if avg_sales > 0:
+ current_ratio = curr_storage / avg_sales
+ else:
+ current_ratio = Decimal("0")
+
+ suggestion = ReplenishmentSuggestion(
+ shop_id=shop_id,
+ shop_name=shop_name,
+ part_code=part_code,
+ part_name=part_name,
+ unit=unit,
+ cost_price=Decimal(str(cost_price)),
+ current_storage_cnt=curr_storage,
+ avg_sales_cnt=avg_sales,
+ current_ratio=current_ratio,
+ suggest_cnt=suggest_cnt,
+ suggest_amount=suggest_amount,
+ suggestion_reason=reason,
+ priority=shop_priority,
+ confidence=confidence,
+ )
+ suggestions.append(suggestion)
+
+ except json.JSONDecodeError as e:
+ logger.error(f"解析配件 {part_code} 分析结果失败: {e}")
+ part_result.part_decision_reason = f"分析失败: {str(e)}"
+ for shop_data in shop_data_list:
+ suggestions.append(ReplenishmentSuggestion(
+ shop_id=int(shop_data.get("shop_id", 0)),
+ shop_name=shop_data.get("shop_name", ""),
+ part_code=part_code,
+ part_name=part_name,
+ unit=unit,
+ cost_price=Decimal(str(cost_price)),
+ current_storage_cnt=Decimal(str(shop_data.get("valid_storage_cnt", 0))),
+ avg_sales_cnt=Decimal(str(shop_data.get("avg_sales_cnt", 0))),
+ current_ratio=Decimal("0"),
+ suggest_cnt=0,
+ suggest_amount=Decimal("0"),
+ suggestion_reason=f"分析失败: {str(e)}",
+ priority=3,
+ confidence=0.0,
+ ))
+
+ except Exception as e:
+ logger.error(f"处理配件 {part_code} 分析结果异常: {e}")
+
+ part_result.suggestions = suggestions
+ return part_result, suggestions
diff --git b/src/fw_pms_ai/agent/sql_agent/executor.py a/src/fw_pms_ai/agent/sql_agent/executor.py
new file mode 100644
index 0000000..92e4810
--- /dev/null
+++ a/src/fw_pms_ai/agent/sql_agent/executor.py
@@ -0,0 +1,283 @@
+"""
+SQL 执行器模块
+
+提供 SQL 执行、重试和错误分类功能
+"""
+
+import logging
+import time
+import json
+from typing import Any, Dict, List, Optional, Tuple
+
+from ..sql_agent.prompts import load_prompt, SQL_AGENT_SYSTEM_PROMPT
+from ...llm import get_llm_client
+from ...config import get_settings
+from ...models import SQLExecutionResult
+
+from langchain_core.messages import SystemMessage, HumanMessage
+
+logger = logging.getLogger(__name__)
+
+
+class SQLExecutor:
+ """SQL 执行器 - 负责 SQL 生成、执行和重试"""
+
+ def __init__(self, db_connection=None):
+ self._settings = get_settings()
+ self._conn = db_connection
+ self._llm = get_llm_client()
+ self._max_retries = 3
+ self._base_delay = 1.0
+
+ def _get_connection(self):
+ """获取数据库连接"""
+ if self._conn is None:
+ from ...services.db import get_connection
+ self._conn = get_connection()
+ return self._conn
+
+ def close(self):
+ """关闭连接"""
+ if self._conn and hasattr(self._conn, 'close'):
+ self._conn.close()
+ self._conn = None
+
+ def generate_sql(
+ self,
+ question: str,
+ context: Optional[Dict] = None,
+ previous_error: Optional[str] = None,
+ ) -> Tuple[str, str]:
+ """
+ 使用LLM生成SQL
+
+ Args:
+ question: 自然语言查询需求
+ context: 上下文信息
+ previous_error: 上一次执行的错误(用于重试)
+
+ Returns:
+ (SQL语句, 解释说明)
+ """
+ user_prompt = question
+
+ if context:
+ user_prompt += f"\n\n上下文信息:\n{json.dumps(context, ensure_ascii=False, indent=2)}"
+
+ if previous_error:
+ user_prompt += f"\n\n上一次执行错误:\n{previous_error}\n请修正SQL语句。"
+
+ messages = [
+ SystemMessage(content=SQL_AGENT_SYSTEM_PROMPT),
+ HumanMessage(content=user_prompt),
+ ]
+
+ response = self._llm.invoke(messages)
+ content = response.content.strip()
+
+ try:
+ # 提取JSON
+ if "```json" in content:
+ content = content.split("```json")[1].split("```")[0].strip()
+ elif "```" in content:
+ content = content.split("```")[1].split("```")[0].strip()
+
+ result = json.loads(content)
+ return result.get("sql", ""), result.get("explanation", "")
+ except json.JSONDecodeError:
+ logger.warning(f"无法解析LLM响应为JSON: {content[:200]}")
+ # 尝试直接提取SQL
+ if "SELECT" in content.upper():
+ lines = content.split("\n")
+ for line in lines:
+ if "SELECT" in line.upper():
+ return line.strip(), "直接提取的SQL"
+ return "", "解析失败"
+
+ def execute_sql(self, sql: str) -> Tuple[bool, Any, Optional[str]]:
+ """
+ 执行SQL查询
+
+ Returns:
+ (成功标志, 数据/None, 错误信息/None)
+ """
+ if not sql or not sql.strip():
+ return False, None, "SQL语句为空"
+
+ # 安全检查:只允许SELECT语句
+ sql_upper = sql.upper().strip()
+ if not sql_upper.startswith("SELECT"):
+ return False, None, "只允许执行SELECT查询"
+
+ conn = self._get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ start_time = time.time()
+ cursor.execute(sql)
+ rows = cursor.fetchall()
+ execution_time = int((time.time() - start_time) * 1000)
+
+ logger.info(f"SQL执行成功: 返回{len(rows)}行, 耗时{execution_time}ms")
+ return True, rows, None
+
+ except Exception as e:
+ error_msg = str(e)
+ logger.error(f"SQL执行失败: {error_msg}")
+ return False, None, error_msg
+
+ finally:
+ cursor.close()
+
+ def query_with_retry(
+ self,
+ question: str,
+ context: Optional[Dict] = None,
+ ) -> SQLExecutionResult:
+ """
+ 带重试的查询
+
+ 错误类型区分:
+ - SyntaxError: 立即重试,让LLM修正SQL
+ - OperationalError: 指数退避重试(连接超时、死锁等)
+
+ Args:
+ question: 查询问题
+ context: 上下文
+
+ Returns:
+ SQLExecutionResult
+ """
+ start_time = time.time()
+ last_error = None
+ last_sql = ""
+
+ for attempt in range(self._max_retries):
+ # 生成SQL
+ sql, explanation = self.generate_sql(question, context, last_error)
+ last_sql = sql
+
+ if not sql:
+ last_error = "LLM未能生成有效的SQL语句"
+ logger.warning(f"重试 {attempt + 1}/{self._max_retries}: {last_error}")
+
+ if attempt < self._max_retries - 1:
+ delay = self._base_delay * (2 ** attempt)
+ time.sleep(delay)
+ continue
+
+ logger.info(f"尝试 {attempt + 1}: 执行SQL: {sql[:100]}...")
+
+ # 执行SQL
+ success, data, error, error_type = self._execute_sql_with_type(sql)
+
+ if success:
+ execution_time = int((time.time() - start_time) * 1000)
+ return SQLExecutionResult(
+ success=True,
+ sql=sql,
+ data=data,
+ retry_count=attempt,
+ execution_time_ms=execution_time,
+ )
+
+ last_error = error
+ logger.warning(f"重试 {attempt + 1}/{self._max_retries}: [{error_type}] {error}")
+
+ if attempt < self._max_retries - 1:
+ if error_type == "syntax":
+ # 语法错误:立即重试,不等待
+ logger.info("SQL语法错误,立即重试让LLM修正")
+ else:
+ # 连接/死锁等操作错误:指数退避
+ delay = self._base_delay * (2 ** attempt)
+ logger.info(f"操作错误,等待 {delay}秒 后重试...")
+ time.sleep(delay)
+
+ execution_time = int((time.time() - start_time) * 1000)
+ return SQLExecutionResult(
+ success=False,
+ sql=last_sql,
+ error=last_error,
+ retry_count=self._max_retries,
+ execution_time_ms=execution_time,
+ )
+
+ def _execute_sql_with_type(self, sql: str) -> Tuple[bool, Any, Optional[str], str]:
+ """
+ 执行SQL并返回错误类型
+
+ Returns:
+ (成功标志, 数据/None, 错误信息/None, 错误类型)
+ 错误类型: "syntax" | "operational" | "unknown"
+ """
+ if not sql or not sql.strip():
+ return False, None, "SQL语句为空", "unknown"
+
+ # 安全检查:只允许SELECT语句
+ sql_upper = sql.upper().strip()
+ if not sql_upper.startswith("SELECT"):
+ return False, None, "只允许执行SELECT查询", "syntax"
+
+ conn = self._get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ start_time = time.time()
+ cursor.execute(sql)
+ rows = cursor.fetchall()
+ execution_time = int((time.time() - start_time) * 1000)
+
+ logger.info(f"SQL执行成功: 返回{len(rows)}行, 耗时{execution_time}ms")
+ return True, rows, None, ""
+
+ except Exception as e:
+ error_msg = str(e)
+ error_type = self._classify_error(e)
+ logger.error(f"SQL执行失败 [{error_type}]: {error_msg}")
+ return False, None, error_msg, error_type
+
+ finally:
+ cursor.close()
+
+ def _classify_error(self, error: Exception) -> str:
+ """
+ 分类SQL错误类型
+
+ Returns:
+ "syntax" - 语法错误,需要LLM修正
+ "operational" - 操作错误,需要指数退避
+ "unknown" - 未知错误
+ """
+ error_msg = str(error).lower()
+ error_class = type(error).__name__
+
+ # MySQL语法错误特征
+ syntax_keywords = [
+ "syntax error", "you have an error in your sql syntax",
+ "unknown column", "unknown table", "doesn't exist",
+ "ambiguous column", "invalid", "near",
+ ]
+
+ # 操作错误特征(需要退避)
+ operational_keywords = [
+ "timeout", "timed out", "connection", "deadlock",
+ "lock wait", "too many connections", "gone away",
+ "lost connection", "can't connect",
+ ]
+
+ for keyword in syntax_keywords:
+ if keyword in error_msg:
+ return "syntax"
+
+ for keyword in operational_keywords:
+ if keyword in error_msg:
+ return "operational"
+
+ # 根据异常类型判断
+ if "ProgrammingError" in error_class or "InterfaceError" in error_class:
+ return "syntax"
+ if "OperationalError" in error_class:
+ return "operational"
+
+ return "unknown"
diff --git b/src/fw_pms_ai/agent/sql_agent/prompts.py a/src/fw_pms_ai/agent/sql_agent/prompts.py
new file mode 100644
index 0000000..28a05c3
--- /dev/null
+++ a/src/fw_pms_ai/agent/sql_agent/prompts.py
@@ -0,0 +1,31 @@
+"""
+提示词加载模块
+"""
+
+import os
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def load_prompt(filename: str) -> str:
+ """从prompts目录加载提示词文件"""
+ # 从 src/fw_pms_ai/agent/sql_agent/prompts.py 向上5层到达项目根目录
+ prompt_path = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))),
+ "prompts", filename
+ )
+ try:
+ with open(prompt_path, "r", encoding="utf-8") as f:
+ return f.read()
+ except FileNotFoundError:
+ logger.warning(f"Prompt文件未找到: {prompt_path}")
+ return ""
+
+
+# 预加载常用提示词
+SQL_AGENT_SYSTEM_PROMPT = load_prompt("sql_agent.md")
+SUGGESTION_PROMPT = load_prompt("suggestion.md")
+SUGGESTION_SYSTEM_PROMPT = load_prompt("suggestion_system.md")
+PART_SHOP_ANALYSIS_PROMPT = load_prompt("part_shop_analysis.md")
+PART_SHOP_ANALYSIS_SYSTEM_PROMPT = load_prompt("part_shop_analysis_system.md")
diff --git b/src/fw_pms_ai/agent/state.py a/src/fw_pms_ai/agent/state.py
new file mode 100644
index 0000000..dfdff73
--- /dev/null
+++ a/src/fw_pms_ai/agent/state.py
@@ -0,0 +1,92 @@
+"""
+LangGraph Agent 状态定义
+
+重构版本:直接使用 part_ratio 数据,支持 SQL Agent
+使用 Annotated 类型和 reducer 函数解决并发状态更新问题
+"""
+
+from typing import TypedDict, Optional, List, Any, Annotated
+from decimal import Decimal
+import operator
+
+
+def merge_lists(left: List[Any], right: List[Any]) -> List[Any]:
+ """合并两个列表的 reducer 函数"""
+ if left is None:
+ left = []
+ if right is None:
+ right = []
+ return left + right
+
+
+def merge_dicts(left: List[dict], right: List[dict]) -> List[dict]:
+ """合并字典列表的 reducer 函数"""
+ if left is None:
+ left = []
+ if right is None:
+ right = []
+ return left + right
+
+
+def keep_last(left: Any, right: Any) -> Any:
+ """保留最后一个值的 reducer 函数"""
+ return right if right is not None else left
+
+
+def sum_values(left: int, right: int) -> int:
+ """累加数值的 reducer 函数"""
+ return (left or 0) + (right or 0)
+
+
+class AgentState(TypedDict, total=False):
+ """补货建议 Agent 状态
+
+ 使用 Annotated 类型定义 reducer 函数,处理并行节点的状态合并
+ """
+
+ # 任务标识(使用 keep_last,因为这些值在并行执行时相同)
+ task_no: Annotated[str, keep_last]
+ group_id: Annotated[int, keep_last]
+ brand_grouping_id: Annotated[Optional[int], keep_last]
+ brand_grouping_name: Annotated[str, keep_last]
+ dealer_grouping_id: Annotated[int, keep_last]
+ dealer_grouping_name: Annotated[str, keep_last]
+ statistics_date: Annotated[str, keep_last]
+
+ # part_ratio 原始数据
+ part_ratios: Annotated[List[dict], merge_dicts]
+
+ # SQL Agent 相关
+ sql_queries: Annotated[List[str], merge_lists]
+ sql_results: Annotated[List[dict], merge_dicts]
+ sql_retry_count: Annotated[int, keep_last]
+ sql_execution_logs: Annotated[List[dict], merge_dicts]
+
+ # 计算结果
+ base_ratio: Annotated[Decimal, keep_last]
+ allocated_details: Annotated[List[dict], merge_dicts]
+ details: Annotated[List[Any], merge_lists]
+
+ # LLM 建议明细
+ llm_suggestions: Annotated[List[Any], merge_lists]
+
+ # 配件汇总结果
+ part_results: Annotated[List[Any], merge_lists]
+
+ # LLM 统计(使用累加,合并多个并行节点的 token 使用量)
+ llm_provider: Annotated[str, keep_last]
+ llm_model: Annotated[str, keep_last]
+ llm_prompt_tokens: Annotated[int, sum_values]
+ llm_completion_tokens: Annotated[int, sum_values]
+
+ # 执行状态
+ status: Annotated[str, keep_last]
+ error_message: Annotated[str, keep_last]
+ start_time: Annotated[float, keep_last]
+ end_time: Annotated[float, keep_last]
+
+ # 流程控制
+ current_node: Annotated[str, keep_last]
+ next_node: Annotated[str, keep_last]
+
+
diff --git b/src/fw_pms_ai/api/__init__.py a/src/fw_pms_ai/api/__init__.py
new file mode 100644
index 0000000..3e68f6b
--- /dev/null
+++ a/src/fw_pms_ai/api/__init__.py
@@ -0,0 +1 @@
+# API 模块
diff --git b/src/fw_pms_ai/api/app.py a/src/fw_pms_ai/api/app.py
new file mode 100644
index 0000000..7125c6b
--- /dev/null
+++ a/src/fw_pms_ai/api/app.py
@@ -0,0 +1,66 @@
+"""
+FastAPI 主应用
+提供 AI 补货建议系统的 REST API
+"""
+
+import logging
+from pathlib import Path
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+
+from .routes import tasks
+
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """应用生命周期管理"""
+ logger.info("API 服务启动")
+ yield
+ logger.info("API 服务关闭")
+
+
+app = FastAPI(
+ title="AI 补货建议系统 API",
+ description="提供补货任务、明细的查询接口",
+ version="1.0.0",
+ lifespan=lifespan,
+)
+
+# CORS 配置
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# 挂载路由
+app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
+
+# 静态文件服务
+ui_path = Path(__file__).parent.parent.parent.parent / "ui"
+if ui_path.exists():
+ app.mount("/css", StaticFiles(directory=ui_path / "css"), name="css")
+ app.mount("/js", StaticFiles(directory=ui_path / "js"), name="js")
+
+
+@app.get("/", include_in_schema=False)
+async def serve_index():
+ """服务主页面"""
+ index_file = ui_path / "index.html"
+ if index_file.exists():
+ return FileResponse(index_file)
+ return {"message": "AI 补货建议系统 API", "docs": "/docs"}
+
+
+@app.get("/health")
+async def health_check():
+ """健康检查"""
+ return {"status": "ok"}
diff --git b/src/fw_pms_ai/api/routes/__init__.py a/src/fw_pms_ai/api/routes/__init__.py
new file mode 100644
index 0000000..557b975
--- /dev/null
+++ a/src/fw_pms_ai/api/routes/__init__.py
@@ -0,0 +1 @@
+# API 路由模块
diff --git b/src/fw_pms_ai/api/routes/tasks.py a/src/fw_pms_ai/api/routes/tasks.py
new file mode 100644
index 0000000..389e416
--- /dev/null
+++ a/src/fw_pms_ai/api/routes/tasks.py
@@ -0,0 +1,611 @@
+"""
+任务相关 API 路由
+"""
+
+import logging
+from typing import Optional, List
+from datetime import datetime
+from decimal import Decimal
+
+from fastapi import APIRouter, Query, HTTPException
+from pydantic import BaseModel
+
+from ...services.db import get_connection
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+class TaskResponse(BaseModel):
+ """任务响应模型"""
+ id: int
+ task_no: str
+ group_id: int
+ dealer_grouping_id: int
+ dealer_grouping_name: Optional[str] = None
+ brand_grouping_id: Optional[int] = None
+ plan_amount: float = 0
+ actual_amount: float = 0
+ part_count: int = 0
+ base_ratio: Optional[float] = None
+ status: int = 0
+ status_text: str = ""
+ error_message: Optional[str] = None
+ llm_provider: Optional[str] = None
+ llm_model: Optional[str] = None
+ llm_total_tokens: int = 0
+ statistics_date: Optional[str] = None
+ start_time: Optional[str] = None
+ end_time: Optional[str] = None
+ duration_seconds: Optional[int] = None
+ create_time: Optional[str] = None
+
+
+class TaskListResponse(BaseModel):
+ """任务列表响应"""
+ total: int
+ page: int
+ page_size: int
+ items: List[TaskResponse]
+
+
+class DetailResponse(BaseModel):
+ """配件建议明细响应"""
+ id: int
+ task_no: str
+ shop_id: int
+ shop_name: Optional[str] = None
+ part_code: str
+ part_name: Optional[str] = None
+ unit: Optional[str] = None
+ cost_price: float = 0
+ current_ratio: Optional[float] = None
+ base_ratio: Optional[float] = None
+ post_plan_ratio: Optional[float] = None
+ valid_storage_cnt: float = 0
+ avg_sales_cnt: float = 0
+ suggest_cnt: int = 0
+ suggest_amount: float = 0
+ suggestion_reason: Optional[str] = None
+ priority: int = 2
+ llm_confidence: Optional[float] = None
+ statistics_date: Optional[str] = None
+
+
+class DetailListResponse(BaseModel):
+ """配件建议明细列表响应"""
+ total: int
+ page: int
+ page_size: int
+ items: List[DetailResponse]
+
+
+def format_datetime(dt) -> Optional[str]:
+ """格式化日期时间"""
+ if dt is None:
+ return None
+ if isinstance(dt, datetime):
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
+ return str(dt)
+
+
+def get_status_text(status: int) -> str:
+ """获取状态文本"""
+ status_map = {0: "运行中", 1: "成功", 2: "失败"}
+ return status_map.get(status, "未知")
+
+
+@router.get("/tasks", response_model=TaskListResponse)
+async def list_tasks(
+ page: int = Query(1, ge=1, description="页码"),
+ page_size: int = Query(20, ge=1, le=100, description="每页数量"),
+ status: Optional[int] = Query(None, description="状态筛选: 0-运行中 1-成功 2-失败"),
+ dealer_grouping_id: Optional[int] = Query(None, description="商家组合ID"),
+ statistics_date: Optional[str] = Query(None, description="统计日期"),
+):
+ """获取任务列表"""
+ conn = get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ # 构建查询条件
+ where_clauses = []
+ params = []
+
+ if status is not None:
+ where_clauses.append("status = %s")
+ params.append(status)
+
+ if dealer_grouping_id is not None:
+ where_clauses.append("dealer_grouping_id = %s")
+ params.append(dealer_grouping_id)
+
+ if statistics_date:
+ where_clauses.append("statistics_date = %s")
+ params.append(statistics_date)
+
+ where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
+
+ # 查询总数
+ count_sql = f"SELECT COUNT(*) as total FROM ai_replenishment_task WHERE {where_sql}"
+ cursor.execute(count_sql, params)
+ total = cursor.fetchone()["total"]
+
+ # 查询分页数据
+ offset = (page - 1) * page_size
+ data_sql = f"""
+ SELECT t.*,
+ COALESCE(
+ NULLIF(t.llm_total_tokens, 0),
+ (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no)
+ ) as calculated_tokens
+ FROM ai_replenishment_task t
+ WHERE {where_sql}
+ ORDER BY t.create_time DESC
+ LIMIT %s OFFSET %s
+ """
+ cursor.execute(data_sql, params + [page_size, offset])
+ rows = cursor.fetchall()
+
+ items = []
+ for row in rows:
+ # 计算执行时长
+ duration = None
+ if row.get("start_time") and row.get("end_time"):
+ duration = int((row["end_time"] - row["start_time"]).total_seconds())
+
+ items.append(TaskResponse(
+ id=row["id"],
+ task_no=row["task_no"],
+ group_id=row["group_id"],
+ dealer_grouping_id=row["dealer_grouping_id"],
+ dealer_grouping_name=row.get("dealer_grouping_name"),
+ brand_grouping_id=row.get("brand_grouping_id"),
+ plan_amount=float(row.get("plan_amount") or 0),
+ actual_amount=float(row.get("actual_amount") or 0),
+ part_count=row.get("part_count") or 0,
+ base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None,
+ status=row.get("status") or 0,
+ status_text=get_status_text(row.get("status") or 0),
+ error_message=row.get("error_message"),
+ llm_provider=row.get("llm_provider"),
+ llm_model=row.get("llm_model"),
+ llm_total_tokens=int(row.get("calculated_tokens") or 0),
+ statistics_date=row.get("statistics_date"),
+ start_time=format_datetime(row.get("start_time")),
+ end_time=format_datetime(row.get("end_time")),
+ duration_seconds=duration,
+ create_time=format_datetime(row.get("create_time")),
+ ))
+
+ return TaskListResponse(
+ total=total,
+ page=page,
+ page_size=page_size,
+ items=items,
+ )
+
+ finally:
+ cursor.close()
+ conn.close()
+
+
+@router.get("/tasks/{task_no}", response_model=TaskResponse)
+async def get_task(task_no: str):
+ """获取任务详情"""
+ conn = get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ cursor.execute(
+ """
+ SELECT t.*,
+ COALESCE(
+ NULLIF(t.llm_total_tokens, 0),
+ (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no)
+ ) as calculated_tokens
+ FROM ai_replenishment_task t
+ WHERE t.task_no = %s
+ """,
+ (task_no,)
+ )
+ row = cursor.fetchone()
+
+ if not row:
+ raise HTTPException(status_code=404, detail="任务不存在")
+
+ duration = None
+ if row.get("start_time") and row.get("end_time"):
+ duration = int((row["end_time"] - row["start_time"]).total_seconds())
+
+ return TaskResponse(
+ id=row["id"],
+ task_no=row["task_no"],
+ group_id=row["group_id"],
+ dealer_grouping_id=row["dealer_grouping_id"],
+ dealer_grouping_name=row.get("dealer_grouping_name"),
+ brand_grouping_id=row.get("brand_grouping_id"),
+ plan_amount=float(row.get("plan_amount") or 0),
+ actual_amount=float(row.get("actual_amount") or 0),
+ part_count=row.get("part_count") or 0,
+ base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None,
+ status=row.get("status") or 0,
+ status_text=get_status_text(row.get("status") or 0),
+ error_message=row.get("error_message"),
+ llm_provider=row.get("llm_provider"),
+ llm_model=row.get("llm_model"),
+ llm_total_tokens=int(row.get("calculated_tokens") or 0),
+ statistics_date=row.get("statistics_date"),
+ start_time=format_datetime(row.get("start_time")),
+ end_time=format_datetime(row.get("end_time")),
+ duration_seconds=duration,
+ create_time=format_datetime(row.get("create_time")),
+ )
+
+ finally:
+ cursor.close()
+ conn.close()
+
+
+@router.get("/tasks/{task_no}/details", response_model=DetailListResponse)
+async def get_task_details(
+ task_no: str,
+ page: int = Query(1, ge=1),
+ page_size: int = Query(50, ge=1, le=200),
+ sort_by: str = Query("suggest_amount", description="排序字段"),
+ sort_order: str = Query("desc", description="排序方向: asc/desc"),
+ part_code: Optional[str] = Query(None, description="配件编码搜索"),
+):
+ """获取任务的配件建议明细"""
+ conn = get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ # 验证排序字段
+ allowed_sort_fields = [
+ "suggest_amount", "suggest_cnt", "cost_price",
+ "avg_sales_cnt", "current_ratio", "part_code"
+ ]
+ if sort_by not in allowed_sort_fields:
+ sort_by = "suggest_amount"
+
+ sort_direction = "DESC" if sort_order.lower() == "desc" else "ASC"
+
+ # 构建查询条件
+ where_sql = "task_no = %s"
+ params = [task_no]
+
+ if part_code:
+ where_sql += " AND part_code LIKE %s"
+ params.append(f"%{part_code}%")
+
+ # 查询总数
+ cursor.execute(
+ f"SELECT COUNT(*) as total FROM ai_replenishment_detail WHERE {where_sql}",
+ params
+ )
+ total = cursor.fetchone()["total"]
+
+ # 查询分页数据
+ offset = (page - 1) * page_size
+ cursor.execute(
+ f"""
+ SELECT * FROM ai_replenishment_detail
+ WHERE {where_sql}
+ ORDER BY {sort_by} {sort_direction}
+ LIMIT %s OFFSET %s
+ """,
+ params + [page_size, offset]
+ )
+ rows = cursor.fetchall()
+
+ items = []
+ for row in rows:
+ items.append(DetailResponse(
+ id=row["id"],
+ task_no=row["task_no"],
+ shop_id=row["shop_id"],
+ shop_name=row.get("shop_name"),
+ part_code=row["part_code"],
+ part_name=row.get("part_name"),
+ unit=row.get("unit"),
+ cost_price=float(row.get("cost_price") or 0),
+ current_ratio=float(row["current_ratio"]) if row.get("current_ratio") else None,
+ base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None,
+ post_plan_ratio=float(row["post_plan_ratio"]) if row.get("post_plan_ratio") else None,
+ valid_storage_cnt=float(row.get("valid_storage_cnt") or 0),
+ avg_sales_cnt=float(row.get("avg_sales_cnt") or 0),
+ suggest_cnt=row.get("suggest_cnt") or 0,
+ suggest_amount=float(row.get("suggest_amount") or 0),
+ suggestion_reason=row.get("suggestion_reason"),
+ priority=row.get("priority") or 2,
+ llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None,
+ statistics_date=row.get("statistics_date"),
+ ))
+
+ return DetailListResponse(
+ total=total,
+ page=page,
+ page_size=page_size,
+ items=items,
+ )
+
+ finally:
+ cursor.close()
+ conn.close()
+
+
+class ExecutionLogResponse(BaseModel):
+ """执行日志响应"""
+ id: int
+ task_no: str
+ step_name: str
+ step_order: int
+ status: int
+ status_text: str = ""
+ input_data: Optional[str] = None
+ output_data: Optional[str] = None
+ error_message: Optional[str] = None
+ retry_count: int = 0
+ llm_tokens: int = 0
+ execution_time_ms: int = 0
+ start_time: Optional[str] = None
+ end_time: Optional[str] = None
+ create_time: Optional[str] = None
+
+
+class ExecutionLogListResponse(BaseModel):
+ """执行日志列表响应"""
+ total: int
+ items: List[ExecutionLogResponse]
+
+
+def get_log_status_text(status: int) -> str:
+ """获取日志状态文本"""
+ status_map = {0: "运行中", 1: "成功", 2: "失败", 3: "跳过"}
+ return status_map.get(status, "未知")
+
+
+def get_step_name_display(step_name: str) -> str:
+ """获取步骤名称显示"""
+ step_map = {
+ "fetch_part_ratio": "获取配件数据",
+ "sql_agent": "AI分析建议",
+ "allocate_budget": "分配预算",
+ "generate_report": "生成报告",
+ }
+ return step_map.get(step_name, step_name)
+
+
+@router.get("/tasks/{task_no}/logs", response_model=ExecutionLogListResponse)
+async def get_task_logs(task_no: str):
+ """获取任务执行日志"""
+ conn = get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ cursor.execute(
+ """
+ SELECT * FROM ai_task_execution_log
+ WHERE task_no = %s
+ ORDER BY step_order ASC
+ """,
+ (task_no,)
+ )
+ rows = cursor.fetchall()
+
+ items = []
+ for row in rows:
+ items.append(ExecutionLogResponse(
+ id=row["id"],
+ task_no=row["task_no"],
+ step_name=row["step_name"],
+ step_order=row.get("step_order") or 0,
+ status=row.get("status") or 0,
+ status_text=get_log_status_text(row.get("status") or 0),
+ input_data=row.get("input_data"),
+ output_data=row.get("output_data"),
+ error_message=row.get("error_message"),
+ retry_count=row.get("retry_count") or 0,
+ llm_tokens=row.get("llm_tokens") or 0,
+ execution_time_ms=row.get("execution_time_ms") or 0,
+ start_time=format_datetime(row.get("start_time")),
+ end_time=format_datetime(row.get("end_time")),
+ create_time=format_datetime(row.get("create_time")),
+ ))
+
+ return ExecutionLogListResponse(
+ total=len(items),
+ items=items,
+ )
+
+ finally:
+ cursor.close()
+ conn.close()
+
+
+class PartSummaryResponse(BaseModel):
+ """配件汇总响应"""
+ id: int
+ task_no: str
+ part_code: str
+ part_name: Optional[str] = None
+ unit: Optional[str] = None
+ cost_price: float = 0
+ total_storage_cnt: float = 0
+ total_avg_sales_cnt: float = 0
+ group_current_ratio: Optional[float] = None
+ total_suggest_cnt: int = 0
+ total_suggest_amount: float = 0
+ shop_count: int = 0
+ need_replenishment_shop_count: int = 0
+ part_decision_reason: Optional[str] = None
+ priority: int = 2
+ llm_confidence: Optional[float] = None
+ statistics_date: Optional[str] = None
+ group_post_plan_ratio: Optional[float] = None
+
+
+class PartSummaryListResponse(BaseModel):
+ """配件汇总列表响应"""
+ total: int
+ page: int
+ page_size: int
+ items: List[PartSummaryResponse]
+
+
+@router.get("/tasks/{task_no}/part-summaries", response_model=PartSummaryListResponse)
+async def get_task_part_summaries(
+ task_no: str,
+ page: int = Query(1, ge=1),
+ page_size: int = Query(50, ge=1, le=200),
+ sort_by: str = Query("total_suggest_amount", description="排序字段"),
+ sort_order: str = Query("desc", description="排序方向: asc/desc"),
+ part_code: Optional[str] = Query(None, description="配件编码筛选"),
+ priority: Optional[int] = Query(None, description="优先级筛选"),
+):
+ """获取任务的配件汇总列表"""
+ conn = get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ # 验证排序字段
+ allowed_sort_fields = [
+ "total_suggest_amount", "total_suggest_cnt", "cost_price",
+ "total_avg_sales_cnt", "group_current_ratio", "part_code",
+ "priority", "need_replenishment_shop_count",
+ "total_storage_cnt", "shop_count", "group_post_plan_ratio"
+ ]
+ if sort_by not in allowed_sort_fields:
+ sort_by = "total_suggest_amount"
+
+ sort_direction = "DESC" if sort_order.lower() == "desc" else "ASC"
+
+ # 构建查询条件
+ where_clauses = ["task_no = %s"]
+ params = [task_no]
+
+ if part_code:
+ where_clauses.append("part_code LIKE %s")
+ params.append(f"%{part_code}%")
+
+ if priority is not None:
+ where_clauses.append("priority = %s")
+ params.append(priority)
+
+ where_sql = " AND ".join(where_clauses)
+
+ # 查询总数
+ cursor.execute(
+ f"SELECT COUNT(*) as total FROM ai_replenishment_part_summary WHERE {where_sql}",
+ params
+ )
+ total = cursor.fetchone()["total"]
+
+ # 查询分页数据
+ offset = (page - 1) * page_size
+
+ # 动态计算计划后库销比: (库存 + 建议) / 月均销
+ # 注意: total_avg_sales_cnt 可能为 0, 需要处理除以零的情况
+ query_sql = f"""
+ SELECT *,
+ (
+ (COALESCE(total_storage_cnt, 0) + COALESCE(total_suggest_cnt, 0)) /
+ NULLIF(total_avg_sales_cnt, 0)
+ ) as group_post_plan_ratio
+ FROM ai_replenishment_part_summary
+ WHERE {where_sql}
+ ORDER BY {sort_by} {sort_direction}
+ LIMIT %s OFFSET %s
+ """
+
+ cursor.execute(query_sql, params + [page_size, offset])
+ rows = cursor.fetchall()
+
+ items = []
+ for row in rows:
+ items.append(PartSummaryResponse(
+ id=row["id"],
+ task_no=row["task_no"],
+ part_code=row["part_code"],
+ part_name=row.get("part_name"),
+ unit=row.get("unit"),
+ cost_price=float(row.get("cost_price") or 0),
+ total_storage_cnt=float(row.get("total_storage_cnt") or 0),
+ total_avg_sales_cnt=float(row.get("total_avg_sales_cnt") or 0),
+ group_current_ratio=float(row["group_current_ratio"]) if row.get("group_current_ratio") else None,
+ group_post_plan_ratio=float(row["group_post_plan_ratio"]) if row.get("group_post_plan_ratio") is not None else None,
+ total_suggest_cnt=row.get("total_suggest_cnt") or 0,
+ total_suggest_amount=float(row.get("total_suggest_amount") or 0),
+ shop_count=row.get("shop_count") or 0,
+ need_replenishment_shop_count=row.get("need_replenishment_shop_count") or 0,
+ part_decision_reason=row.get("part_decision_reason"),
+ priority=row.get("priority") or 2,
+ llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None,
+ statistics_date=row.get("statistics_date"),
+ ))
+
+ return PartSummaryListResponse(
+ total=total,
+ page=page,
+ page_size=page_size,
+ items=items,
+ )
+
+ finally:
+ cursor.close()
+ conn.close()
+
+
+@router.get("/tasks/{task_no}/parts/{part_code}/shops")
+async def get_part_shop_details(
+ task_no: str,
+ part_code: str,
+):
+ """获取指定配件的门店明细"""
+ conn = get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ cursor.execute(
+ """
+ SELECT * FROM ai_replenishment_detail
+ WHERE task_no = %s AND part_code = %s
+ ORDER BY suggest_amount DESC
+ """,
+ (task_no, part_code)
+ )
+ rows = cursor.fetchall()
+
+ items = []
+ for row in rows:
+ items.append(DetailResponse(
+ id=row["id"],
+ task_no=row["task_no"],
+ shop_id=row["shop_id"],
+ shop_name=row.get("shop_name"),
+ part_code=row["part_code"],
+ part_name=row.get("part_name"),
+ unit=row.get("unit"),
+ cost_price=float(row.get("cost_price") or 0),
+ current_ratio=float(row["current_ratio"]) if row.get("current_ratio") else None,
+ base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None,
+ post_plan_ratio=float(row["post_plan_ratio"]) if row.get("post_plan_ratio") else None,
+ valid_storage_cnt=float(row.get("valid_storage_cnt") or 0),
+ avg_sales_cnt=float(row.get("avg_sales_cnt") or 0),
+ suggest_cnt=row.get("suggest_cnt") or 0,
+ suggest_amount=float(row.get("suggest_amount") or 0),
+ suggestion_reason=row.get("suggestion_reason"),
+ priority=row.get("priority") or 2,
+ llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None,
+ statistics_date=row.get("statistics_date"),
+ ))
+
+ return {
+ "total": len(items),
+ "items": items,
+ }
+
+ finally:
+ cursor.close()
+ conn.close()
diff --git b/src/fw_pms_ai/config/__init__.py a/src/fw_pms_ai/config/__init__.py
new file mode 100644
index 0000000..890ea8b
--- /dev/null
+++ a/src/fw_pms_ai/config/__init__.py
@@ -0,0 +1,5 @@
+"""配置模块"""
+
+from .settings import Settings, get_settings
+
+__all__ = ["Settings", "get_settings"]
diff --git b/src/fw_pms_ai/config/settings.py a/src/fw_pms_ai/config/settings.py
new file mode 100644
index 0000000..8b40431
--- /dev/null
+++ a/src/fw_pms_ai/config/settings.py
@@ -0,0 +1,77 @@
+"""
+配置管理模块
+使用 pydantic-settings 从环境变量加载配置
+"""
+
+from functools import lru_cache
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ """应用配置"""
+
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ case_sensitive=False,
+ )
+
+ # GLM-4.7 配置
+ glm_api_key: str = ""
+ glm_model: str = "glm-4"
+
+ # OpenAI 兼容模式配置(火山引擎等)
+ openai_compat_api_key: str = ""
+ openai_compat_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
+ openai_compat_model: str = "doubao-seed-1-8-251228"
+
+ # Anthropic 兼容模式配置(智谱AI)
+ anthropic_api_key: str = ""
+ anthropic_base_url: str = "https://open.bigmodel.cn/api/anthropic"
+ anthropic_model: str = "glm-4.7"
+
+ # 豆包配置
+ doubao_api_key: str = ""
+ doubao_model: str = "doubao-pro"
+
+ # 数据库配置
+ mysql_host: str = "localhost"
+ mysql_port: int = 3306
+ mysql_user: str = "root"
+ mysql_password: str = ""
+ mysql_database: str = "fw_pms"
+
+ # 定时任务配置
+ scheduler_cron_hour: int = 2
+ scheduler_cron_minute: int = 0
+
+ # 日志配置
+ log_level: str = "INFO"
+
+ @property
+ def mysql_connection_string(self) -> str:
+ """MySQL 连接字符串"""
+ return (
+ f"mysql+mysqlconnector://{self.mysql_user}:{self.mysql_password}"
+ f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}"
+ )
+
+ @property
+ def primary_llm_provider(self) -> str:
+ """主要 LLM 供应商"""
+ if self.openai_compat_api_key:
+ return "openai_compat"
+ elif self.anthropic_api_key:
+ return "anthropic_compat"
+ elif self.glm_api_key:
+ return "glm"
+ elif self.doubao_api_key:
+ return "doubao"
+ else:
+ raise ValueError("未配置任何 LLM API Key")
+
+
+@lru_cache
+def get_settings() -> Settings:
+ """获取配置单例"""
+ return Settings()
diff --git b/src/fw_pms_ai/llm/__init__.py a/src/fw_pms_ai/llm/__init__.py
new file mode 100644
index 0000000..afa5604
--- /dev/null
+++ a/src/fw_pms_ai/llm/__init__.py
@@ -0,0 +1,38 @@
+"""LLM 模块"""
+
+from .base import BaseLLMClient, LLMResponse, LLMUsage
+from .glm import GLMClient
+from .doubao import DoubaoClient
+from .anthropic_compat import AnthropicCompatClient
+from .openai_compat import OpenAICompatClient
+from ..config import get_settings
+
+
+def get_llm_client() -> BaseLLMClient:
+ """获取 LLM 客户端"""
+ settings = get_settings()
+ provider = settings.primary_llm_provider
+
+ if provider == "openai_compat":
+ return OpenAICompatClient()
+ elif provider == "anthropic_compat":
+ return AnthropicCompatClient()
+ elif provider == "glm":
+ return GLMClient()
+ elif provider == "doubao":
+ return DoubaoClient()
+ else:
+ raise ValueError(f"不支持的 LLM 供应商: {provider}")
+
+
+__all__ = [
+ "BaseLLMClient",
+ "LLMResponse",
+ "LLMUsage",
+ "GLMClient",
+ "DoubaoClient",
+ "AnthropicCompatClient",
+ "OpenAICompatClient",
+ "get_llm_client",
+]
+
diff --git b/src/fw_pms_ai/llm/anthropic_compat.py a/src/fw_pms_ai/llm/anthropic_compat.py
new file mode 100644
index 0000000..efbfa43
--- /dev/null
+++ a/src/fw_pms_ai/llm/anthropic_compat.py
@@ -0,0 +1,183 @@
+"""
+Anthropic 兼容模式客户端
+支持智谱AI的Anthropic兼容接口
+"""
+
+import logging
+from langchain_core.language_models import BaseChatModel
+from langchain_core.messages import BaseMessage, AIMessage
+import anthropic
+
+from .base import BaseLLMClient, LLMResponse, LLMUsage
+from ..config import get_settings
+
+logger = logging.getLogger(__name__)
+
+
+class ChatAnthropicCompat(BaseChatModel):
+ """Anthropic兼容模式 LangChain 包装器"""
+
+ client: anthropic.Anthropic = None
+ model: str = "glm-4.7"
+ temperature: float = 0.7
+ max_tokens: int = 4096
+
+ def __init__(self, api_key: str, base_url: str, model: str = "glm-4.7", **kwargs):
+ super().__init__(**kwargs)
+ self.client = anthropic.Anthropic(
+ api_key=api_key,
+ base_url=base_url,
+ )
+ self.model = model
+
+ @property
+ def _llm_type(self) -> str:
+ return "anthropic_compat"
+
+ def _generate(self, messages, stop=None, run_manager=None, **kwargs):
+ from langchain_core.outputs import ChatGeneration, ChatResult
+
+ formatted_messages = []
+ system_content = None
+
+ for msg in messages:
+ if hasattr(msg, 'type'):
+ if msg.type == "system":
+ system_content = msg.content
+ continue
+ role = "user" if msg.type == "human" else "assistant"
+ else:
+ role = "user"
+ formatted_messages.append({"role": role, "content": msg.content})
+
+ create_kwargs = {
+ "model": self.model,
+ "messages": formatted_messages,
+ "max_tokens": self.max_tokens,
+ }
+ if system_content:
+ create_kwargs["system"] = system_content
+
+ response = self.client.messages.create(**create_kwargs)
+
+ content = response.content[0].text
+ generation = ChatGeneration(message=AIMessage(content=content))
+
+ return ChatResult(
+ generations=[generation],
+ llm_output={
+ "token_usage": {
+ "prompt_tokens": response.usage.input_tokens,
+ "completion_tokens": response.usage.output_tokens,
+ "total_tokens": response.usage.input_tokens + response.usage.output_tokens,
+ }
+ }
+ )
+
+
+class AnthropicCompatClient(BaseLLMClient):
+ """Anthropic兼容模式客户端"""
+
+ def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
+ settings = get_settings()
+ self._api_key = api_key or settings.anthropic_api_key
+ self._base_url = base_url or settings.anthropic_base_url
+ self._model = model or settings.anthropic_model
+ self._client = anthropic.Anthropic(
+ api_key=self._api_key,
+ base_url=self._base_url,
+ )
+ self._chat_model = None
+
+ @property
+ def provider(self) -> str:
+ return "anthropic_compat"
+
+ @property
+ def model_name(self) -> str:
+ return self._model
+
+ def get_chat_model(self) -> BaseChatModel:
+ """获取 LangChain 聊天模型"""
+ if self._chat_model is None:
+ self._chat_model = ChatAnthropicCompat(
+ api_key=self._api_key,
+ base_url=self._base_url,
+ model=self._model,
+ )
+ return self._chat_model
+
+ def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
+ """调用 Anthropic 兼容接口(带自定义重试和速率限制处理)"""
+ import time
+ import random
+
+ max_retries = 5
+ base_delay = 2.0
+ max_delay = 30.0
+ post_request_delay = 1.0
+
+ formatted_messages = []
+ system_content = None
+
+ for msg in messages:
+ if hasattr(msg, 'type'):
+ if msg.type == "system":
+ system_content = msg.content
+ continue
+ role = "user" if msg.type == "human" else "assistant"
+ else:
+ role = "user"
+ formatted_messages.append({"role": role, "content": msg.content})
+
+ create_kwargs = {
+ "model": self._model,
+ "messages": formatted_messages,
+ "max_tokens": 4096,
+ }
+ if system_content:
+ create_kwargs["system"] = system_content
+
+ last_exception = None
+
+ for attempt in range(max_retries):
+ try:
+ response = self._client.messages.create(**create_kwargs)
+
+ content = response.content[0].text
+ usage = self.create_usage(
+ prompt_tokens=response.usage.input_tokens,
+ completion_tokens=response.usage.output_tokens,
+ )
+
+ logger.info(
+ f"Anthropic兼容接口调用完成: model={self._model}, "
+ f"tokens={usage.total_tokens}"
+ )
+
+ time.sleep(post_request_delay)
+
+ return LLMResponse(content=content, usage=usage, raw_response=response)
+
+ except Exception as e:
+ last_exception = e
+ error_str = str(e)
+ is_rate_limit = "429" in error_str or "rate" in error_str.lower() or "1302" in error_str
+
+ if attempt < max_retries - 1:
+ if is_rate_limit:
+ delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
+ logger.warning(
+ f"API速率限制 (尝试 {attempt + 1}/{max_retries}), "
+ f"等待 {delay:.1f}秒 后重试..."
+ )
+ else:
+ delay = base_delay * (attempt + 1)
+ logger.warning(
+ f"API调用失败 (尝试 {attempt + 1}/{max_retries}): {e}, "
+ f"等待 {delay:.1f}秒 后重试..."
+ )
+ time.sleep(delay)
+ else:
+ logger.error(f"Anthropic兼容接口调用失败(已重试{max_retries}次): {e}")
+ raise
diff --git b/src/fw_pms_ai/llm/base.py a/src/fw_pms_ai/llm/base.py
new file mode 100644
index 0000000..6cffa4d
--- /dev/null
+++ a/src/fw_pms_ai/llm/base.py
@@ -0,0 +1,74 @@
+"""
+LLM 基础抽象类
+定义统一的 LLM 接口
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any
+from dataclasses import dataclass, field
+from langchain_core.language_models import BaseChatModel
+from langchain_core.messages import BaseMessage
+
+
+@dataclass
+class LLMUsage:
+ """LLM 使用统计"""
+ provider: str = ""
+ model: str = ""
+ prompt_tokens: int = 0
+ completion_tokens: int = 0
+ total_tokens: int = 0
+
+ def add(self, other: "LLMUsage") -> "LLMUsage":
+ """累加使用量"""
+ return LLMUsage(
+ provider=self.provider or other.provider,
+ model=self.model or other.model,
+ prompt_tokens=self.prompt_tokens + other.prompt_tokens,
+ completion_tokens=self.completion_tokens + other.completion_tokens,
+ total_tokens=self.total_tokens + other.total_tokens,
+ )
+
+
+@dataclass
+class LLMResponse:
+ """LLM 响应"""
+ content: str
+ usage: LLMUsage = field(default_factory=LLMUsage)
+ raw_response: Any = None
+
+
+class BaseLLMClient(ABC):
+ """LLM 客户端基类"""
+
+ @property
+ @abstractmethod
+ def provider(self) -> str:
+ """供应商名称"""
+ pass
+
+ @property
+ @abstractmethod
+ def model_name(self) -> str:
+ """模型名称"""
+ pass
+
+ @abstractmethod
+ def get_chat_model(self) -> BaseChatModel:
+ """获取 LangChain 聊天模型"""
+ pass
+
+ @abstractmethod
+ def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
+ """调用 LLM"""
+ pass
+
+ def create_usage(self, prompt_tokens: int = 0, completion_tokens: int = 0) -> LLMUsage:
+ """创建使用统计"""
+ return LLMUsage(
+ provider=self.provider,
+ model=self.model_name,
+ prompt_tokens=prompt_tokens,
+ completion_tokens=completion_tokens,
+ total_tokens=prompt_tokens + completion_tokens,
+ )
diff --git b/src/fw_pms_ai/llm/doubao.py a/src/fw_pms_ai/llm/doubao.py
new file mode 100644
index 0000000..a1c72d5
--- /dev/null
+++ a/src/fw_pms_ai/llm/doubao.py
@@ -0,0 +1,82 @@
+"""
+豆包 (字节跳动) 集成 - 备选 LLM
+"""
+
+import logging
+import httpx
+from langchain_core.language_models import BaseChatModel
+from langchain_core.messages import BaseMessage, AIMessage
+
+from .base import BaseLLMClient, LLMResponse
+from ..config import get_settings
+
+logger = logging.getLogger(__name__)
+
+
+class DoubaoClient(BaseLLMClient):
+ """豆包客户端 (备选)"""
+
+ def __init__(self, api_key: str = None, model: str = None):
+ settings = get_settings()
+ self._api_key = api_key or settings.doubao_api_key
+ self._model = model or settings.doubao_model
+ self._base_url = "https://ark.cn-beijing.volces.com/api/v3"
+
+ @property
+ def provider(self) -> str:
+ return "doubao"
+
+ @property
+ def model_name(self) -> str:
+ return self._model
+
+ def get_chat_model(self) -> BaseChatModel:
+ """获取 LangChain 聊天模型"""
+ raise NotImplementedError("豆包 LangChain 集成待实现")
+
+ def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
+ """调用豆包"""
+ try:
+ formatted_messages = []
+ for msg in messages:
+ if hasattr(msg, 'type'):
+ role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system"
+ else:
+ role = "user"
+ formatted_messages.append({"role": role, "content": msg.content})
+
+ with httpx.Client() as client:
+ response = client.post(
+ f"{self._base_url}/chat/completions",
+ headers={
+ "Authorization": f"Bearer {self._api_key}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "model": self._model,
+ "messages": formatted_messages,
+ "temperature": 0.7,
+ "max_tokens": 4096,
+ },
+ timeout=60.0,
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ content = data["choices"][0]["message"]["content"]
+ usage_data = data.get("usage", {})
+ usage = self.create_usage(
+ prompt_tokens=usage_data.get("prompt_tokens", 0),
+ completion_tokens=usage_data.get("completion_tokens", 0),
+ )
+
+ logger.info(
+ f"豆包调用完成: model={self._model}, "
+ f"tokens={usage.total_tokens}"
+ )
+
+ return LLMResponse(content=content, usage=usage, raw_response=data)
+
+ except Exception as e:
+ logger.error(f"豆包调用失败: {e}")
+ raise
diff --git b/src/fw_pms_ai/llm/glm.py a/src/fw_pms_ai/llm/glm.py
new file mode 100644
index 0000000..618b58a
--- /dev/null
+++ a/src/fw_pms_ai/llm/glm.py
@@ -0,0 +1,123 @@
+"""
+GLM-4.7 (智谱AI) 集成
+"""
+
+import logging
+from langchain_core.language_models import BaseChatModel
+from langchain_core.messages import BaseMessage, AIMessage
+from zhipuai import ZhipuAI
+
+from .base import BaseLLMClient, LLMResponse, LLMUsage
+from ..config import get_settings
+
+logger = logging.getLogger(__name__)
+
+
+class ChatZhipuAI(BaseChatModel):
+ """智谱AI 聊天模型 LangChain 包装器"""
+
+ client: ZhipuAI = None
+ model: str = "glm-4"
+ temperature: float = 0.7
+ max_tokens: int = 4096
+
+ def __init__(self, api_key: str, model: str = "glm-4", **kwargs):
+ super().__init__(**kwargs)
+ self.client = ZhipuAI(api_key=api_key)
+ self.model = model
+
+ @property
+ def _llm_type(self) -> str:
+ return "zhipuai"
+
+ def _generate(self, messages, stop=None, run_manager=None, **kwargs):
+ from langchain_core.outputs import ChatGeneration, ChatResult
+
+ formatted_messages = []
+ for msg in messages:
+ if hasattr(msg, 'type'):
+ role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system"
+ else:
+ role = "user"
+ formatted_messages.append({"role": role, "content": msg.content})
+
+ response = self.client.chat.completions.create(
+ model=self.model,
+ messages=formatted_messages,
+ temperature=self.temperature,
+ max_tokens=self.max_tokens,
+ )
+
+ content = response.choices[0].message.content
+ generation = ChatGeneration(message=AIMessage(content=content))
+
+ return ChatResult(
+ generations=[generation],
+ llm_output={
+ "token_usage": {
+ "prompt_tokens": response.usage.prompt_tokens,
+ "completion_tokens": response.usage.completion_tokens,
+ "total_tokens": response.usage.total_tokens,
+ }
+ }
+ )
+
+
+class GLMClient(BaseLLMClient):
+ """GLM-4.7 客户端"""
+
+ def __init__(self, api_key: str = None, model: str = None):
+ settings = get_settings()
+ self._api_key = api_key or settings.glm_api_key
+ self._model = model or settings.glm_model
+ self._client = ZhipuAI(api_key=self._api_key)
+ self._chat_model = None
+
+ @property
+ def provider(self) -> str:
+ return "glm"
+
+ @property
+ def model_name(self) -> str:
+ return self._model
+
+ def get_chat_model(self) -> BaseChatModel:
+ """获取 LangChain 聊天模型"""
+ if self._chat_model is None:
+ self._chat_model = ChatZhipuAI(api_key=self._api_key, model=self._model)
+ return self._chat_model
+
+ def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
+ """调用 GLM"""
+ try:
+ formatted_messages = []
+ for msg in messages:
+ if hasattr(msg, 'type'):
+ role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system"
+ else:
+ role = "user"
+ formatted_messages.append({"role": role, "content": msg.content})
+
+ response = self._client.chat.completions.create(
+ model=self._model,
+ messages=formatted_messages,
+ temperature=0.7,
+ max_tokens=4096,
+ )
+
+ content = response.choices[0].message.content
+ usage = self.create_usage(
+ prompt_tokens=response.usage.prompt_tokens,
+ completion_tokens=response.usage.completion_tokens,
+ )
+
+ logger.info(
+ f"GLM 调用完成: model={self._model}, "
+ f"tokens={usage.total_tokens}"
+ )
+
+ return LLMResponse(content=content, usage=usage, raw_response=response)
+
+ except Exception as e:
+ logger.error(f"GLM 调用失败: {e}")
+ raise
diff --git b/src/fw_pms_ai/llm/openai_compat.py a/src/fw_pms_ai/llm/openai_compat.py
new file mode 100644
index 0000000..8a59beb
--- /dev/null
+++ a/src/fw_pms_ai/llm/openai_compat.py
@@ -0,0 +1,122 @@
+"""
+OpenAI 兼容模式客户端
+支持火山引擎等 OpenAI 兼容接口
+"""
+
+import logging
+from langchain_core.language_models import BaseChatModel
+from langchain_core.messages import BaseMessage, AIMessage
+from langchain_openai import ChatOpenAI
+from openai import OpenAI
+
+from .base import BaseLLMClient, LLMResponse, LLMUsage
+from ..config import get_settings
+
+logger = logging.getLogger(__name__)
+
+
+class OpenAICompatClient(BaseLLMClient):
+ """OpenAI 兼容模式客户端(火山引擎等)"""
+
+ def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
+ settings = get_settings()
+ self._api_key = api_key or settings.openai_compat_api_key
+ self._base_url = base_url or settings.openai_compat_base_url
+ self._model = model or settings.openai_compat_model
+ self._client = OpenAI(
+ api_key=self._api_key,
+ base_url=self._base_url,
+ )
+ self._chat_model = None
+
+ @property
+ def provider(self) -> str:
+ return "openai_compat"
+
+ @property
+ def model_name(self) -> str:
+ return self._model
+
+ def get_chat_model(self) -> BaseChatModel:
+ """获取 LangChain 聊天模型"""
+ if self._chat_model is None:
+ self._chat_model = ChatOpenAI(
+ api_key=self._api_key,
+ base_url=self._base_url,
+ model=self._model,
+ temperature=0.7,
+ max_tokens=4096,
+ )
+ return self._chat_model
+
+ def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
+ """调用 OpenAI 兼容接口(带自定义重试和速率限制处理)"""
+ import time
+ import random
+
+ max_retries = 5
+ base_delay = 2.0
+ max_delay = 30.0
+ post_request_delay = 1.0
+
+ formatted_messages = []
+
+ for msg in messages:
+ if hasattr(msg, 'type'):
+ if msg.type == "system":
+ role = "system"
+ elif msg.type == "human":
+ role = "user"
+ else:
+ role = "assistant"
+ else:
+ role = "user"
+ formatted_messages.append({"role": role, "content": msg.content})
+
+ last_exception = None
+
+ for attempt in range(max_retries):
+ try:
+ response = self._client.chat.completions.create(
+ model=self._model,
+ messages=formatted_messages,
+ max_tokens=4096,
+ )
+
+ content = response.choices[0].message.content
+ usage = self.create_usage(
+ prompt_tokens=response.usage.prompt_tokens,
+ completion_tokens=response.usage.completion_tokens,
+ )
+
+ logger.info(
+ f"OpenAI兼容接口调用完成: model={self._model}, "
+ f"tokens={usage.total_tokens}"
+ )
+
+ time.sleep(post_request_delay)
+
+ return LLMResponse(content=content, usage=usage, raw_response=response)
+
+ except Exception as e:
+ last_exception = e
+ error_str = str(e)
+ is_rate_limit = "429" in error_str or "rate" in error_str.lower()
+
+ if attempt < max_retries - 1:
+ if is_rate_limit:
+ delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
+ logger.warning(
+ f"API速率限制 (尝试 {attempt + 1}/{max_retries}), "
+ f"等待 {delay:.1f}秒 后重试..."
+ )
+ else:
+ delay = base_delay * (attempt + 1)
+ logger.warning(
+ f"API调用失败 (尝试 {attempt + 1}/{max_retries}): {e}, "
+ f"等待 {delay:.1f}秒 后重试..."
+ )
+ time.sleep(delay)
+ else:
+ logger.error(f"OpenAI兼容接口调用失败(已重试{max_retries}次): {e}")
+ raise
diff --git b/src/fw_pms_ai/main.py a/src/fw_pms_ai/main.py
new file mode 100644
index 0000000..c752187
--- /dev/null
+++ a/src/fw_pms_ai/main.py
@@ -0,0 +1,30 @@
+"""
+fw-pms-ai 主入口
+"""
+
+import logging
+import sys
+from pathlib import Path
+
+try:
+ from .scheduler.tasks import main as scheduler_main
+except ImportError:
+ # 直接运行时,添加项目根目录到 sys.path
+ project_root = Path(__file__).parent.parent.parent
+ if str(project_root) not in sys.path:
+ sys.path.insert(0, str(project_root))
+ from fw_pms_ai.scheduler.tasks import main as scheduler_main
+
+
+def main():
+ """应用入口"""
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ )
+
+ scheduler_main()
+
+
+if __name__ == "__main__":
+ main()
diff --git b/src/fw_pms_ai/models/__init__.py a/src/fw_pms_ai/models/__init__.py
new file mode 100644
index 0000000..ab795c3
--- /dev/null
+++ a/src/fw_pms_ai/models/__init__.py
@@ -0,0 +1,24 @@
+"""数据模型模块"""
+
+from .part_ratio import PartRatio
+from .task import ReplenishmentTask, ReplenishmentDetail, TaskStatus
+from .execution_log import TaskExecutionLog, LogStatus
+from .part_summary import ReplenishmentPartSummary
+from .sql_result import SQLExecutionResult
+from .suggestion import ReplenishmentSuggestion, PartAnalysisResult
+
+__all__ = [
+ "PartRatio",
+ "ReplenishmentTask",
+ "ReplenishmentDetail",
+ "TaskStatus",
+ "TaskExecutionLog",
+ "LogStatus",
+ "ReplenishmentPartSummary",
+ "SQLExecutionResult",
+ "ReplenishmentSuggestion",
+ "PartAnalysisResult",
+]
+
+
+
diff --git b/src/fw_pms_ai/models/execution_log.py a/src/fw_pms_ai/models/execution_log.py
new file mode 100644
index 0000000..8023d1d
--- /dev/null
+++ a/src/fw_pms_ai/models/execution_log.py
@@ -0,0 +1,48 @@
+"""
+任务执行日志模型和LLM建议明细模型
+"""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from decimal import Decimal
+from typing import Optional
+from enum import IntEnum
+
+
+class LogStatus(IntEnum):
+ """日志状态"""
+ RUNNING = 0
+ SUCCESS = 1
+ FAILED = 2
+ SKIPPED = 3
+
+
+@dataclass
+class TaskExecutionLog:
+ """任务执行日志"""
+ task_no: str
+ group_id: int
+ dealer_grouping_id: int
+ step_name: str
+
+ id: Optional[int] = None
+ brand_grouping_id: Optional[int] = None
+ brand_grouping_name: str = ""
+ dealer_grouping_name: str = ""
+ step_order: int = 0
+ status: LogStatus = LogStatus.RUNNING
+ input_data: str = ""
+ output_data: str = ""
+ error_message: str = ""
+ retry_count: int = 0
+ sql_query: str = ""
+ llm_prompt: str = ""
+ llm_response: str = ""
+ llm_tokens: int = 0
+ execution_time_ms: int = 0
+ start_time: Optional[datetime] = None
+ end_time: Optional[datetime] = None
+ create_time: Optional[datetime] = None
+
+
+
diff --git b/src/fw_pms_ai/models/part_ratio.py a/src/fw_pms_ai/models/part_ratio.py
new file mode 100644
index 0000000..3df7039
--- /dev/null
+++ a/src/fw_pms_ai/models/part_ratio.py
@@ -0,0 +1,111 @@
+"""
+数据模型 - 库销比
+"""
+
+from dataclasses import dataclass
+from decimal import Decimal
+from datetime import datetime
+from typing import Optional
+
+
+@dataclass
+class PartRatio:
+ """配件库销比数据"""
+
+ id: int
+ group_id: int
+ brand_id: Optional[int] = None
+ brand_grouping_id: Optional[int] = None
+ supplier_id: Optional[int] = None
+ supplier_name: Optional[str] = None
+ area_id: Optional[int] = None
+ area_name: Optional[str] = None
+ shop_id: int = 0
+ shop_name: Optional[str] = None
+ part_id: Optional[int] = None
+ part_code: str = ""
+ part_name: Optional[str] = None
+ part_biz_type: Optional[str] = None
+ unit_price: Decimal = Decimal("0")
+ cost_price: Decimal = Decimal("0")
+ storage_total_cnt: Decimal = Decimal("0")
+ in_stock_unlocked_cnt: Decimal = Decimal("0")
+ has_plan_cnt: Decimal = Decimal("0")
+ on_the_way_cnt: Decimal = Decimal("0")
+ out_stock_cnt: Decimal = Decimal("0")
+ storage_locked_cnt: Decimal = Decimal("0")
+ out_stock_ongoing_cnt: Decimal = Decimal("0")
+ buy_cnt: int = 0
+ transfer_cnt: int = 0
+ gen_transfer_cnt: int = 0
+ part_tag: Optional[int] = None
+ stock_age: Optional[int] = None
+ unit: Optional[str] = None
+ out_times: Optional[int] = None
+ statistics_date: str = ""
+
+ @property
+ def valid_storage_cnt(self) -> Decimal:
+ """有效库存数量 = 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途"""
+ return (self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt +
+ Decimal(str(self.transfer_cnt)) + Decimal(str(self.gen_transfer_cnt)))
+
+ @property
+ def valid_storage_amount(self) -> Decimal:
+ """有效库存金额"""
+ return self.valid_storage_cnt * self.cost_price
+
+ @property
+ def avg_sales_cnt(self) -> Decimal:
+ """平均销量 (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3"""
+ total = (self.out_stock_cnt or Decimal("0")) + self.storage_locked_cnt + self.out_stock_ongoing_cnt + Decimal(str(self.buy_cnt))
+ return total / Decimal("3")
+
+ @property
+ def avg_sales_amount(self) -> Decimal:
+ """平均销量金额"""
+ return self.avg_sales_cnt * self.cost_price
+
+ @property
+ def current_ratio(self) -> Decimal:
+ """当前库销比"""
+ if self.avg_sales_cnt == 0:
+ return Decimal("999")
+ return self.valid_storage_cnt / self.avg_sales_cnt
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "PartRatio":
+ """从字典创建"""
+ return cls(
+ id=data.get("id", 0),
+ group_id=data.get("group_id", 0),
+ brand_id=data.get("brand_id"),
+ brand_grouping_id=data.get("brand_grouping_id"),
+ supplier_id=data.get("supplier_id"),
+ supplier_name=data.get("supplier_name"),
+ area_id=data.get("area_id"),
+ area_name=data.get("area_name"),
+ shop_id=data.get("shop_id", 0),
+ shop_name=data.get("shop_name"),
+ part_id=data.get("part_id"),
+ part_code=data.get("part_code", ""),
+ part_name=data.get("part_name"),
+ part_biz_type=data.get("part_biz_type"),
+ unit_price=Decimal(str(data.get("unit_price", 0))),
+ cost_price=Decimal(str(data.get("cost_price", 0))),
+ storage_total_cnt=Decimal(str(data.get("storage_total_cnt", 0))),
+ in_stock_unlocked_cnt=Decimal(str(data.get("in_stock_unlocked_cnt", 0))),
+ has_plan_cnt=Decimal(str(data.get("has_plan_cnt", 0))),
+ on_the_way_cnt=Decimal(str(data.get("on_the_way_cnt", 0))),
+ out_stock_cnt=Decimal(str(data.get("out_stock_cnt", 0))),
+ storage_locked_cnt=Decimal(str(data.get("storage_locked_cnt", 0))),
+ out_stock_ongoing_cnt=Decimal(str(data.get("out_stock_ongoing_cnt", 0))),
+ buy_cnt=int(data.get("buy_cnt", 0)),
+ transfer_cnt=int(data.get("transfer_cnt", 0)),
+ gen_transfer_cnt=int(data.get("gen_transfer_cnt", 0)),
+ part_tag=data.get("part_tag"),
+ stock_age=data.get("stock_age"),
+ unit=data.get("unit"),
+ out_times=data.get("out_times"),
+ statistics_date=data.get("statistics_date", ""),
+ )
diff --git b/src/fw_pms_ai/models/part_summary.py a/src/fw_pms_ai/models/part_summary.py
new file mode 100644
index 0000000..8749144
--- /dev/null
+++ a/src/fw_pms_ai/models/part_summary.py
@@ -0,0 +1,66 @@
+"""
+数据模型 - 配件汇总
+"""
+
+from dataclasses import dataclass
+from decimal import Decimal
+from datetime import datetime
+from typing import Optional
+
+
+@dataclass
+class ReplenishmentPartSummary:
+ """AI补货建议-配件汇总"""
+
+ task_no: str
+ group_id: int
+ dealer_grouping_id: int
+ part_code: str
+
+ id: Optional[int] = None
+ part_name: Optional[str] = None
+ unit: Optional[str] = None
+ cost_price: Decimal = Decimal("0")
+
+ # 商家组合级别汇总数据
+ total_storage_cnt: Decimal = Decimal("0")
+ total_avg_sales_cnt: Decimal = Decimal("0")
+ group_current_ratio: Optional[Decimal] = None
+
+ # 补货建议汇总
+ total_suggest_cnt: int = 0
+ total_suggest_amount: Decimal = Decimal("0")
+ shop_count: int = 0
+ need_replenishment_shop_count: int = 0
+
+ # LLM分析结果
+ part_decision_reason: str = ""
+ priority: int = 2
+ llm_confidence: float = 0.8
+
+ # 元数据
+ statistics_date: str = ""
+ create_time: Optional[datetime] = None
+
+ def to_dict(self) -> dict:
+ """转换为字典"""
+ return {
+ "task_no": self.task_no,
+ "group_id": self.group_id,
+ "dealer_grouping_id": self.dealer_grouping_id,
+ "part_code": self.part_code,
+ "part_name": self.part_name,
+ "unit": self.unit,
+ "cost_price": float(self.cost_price),
+ "total_storage_cnt": float(self.total_storage_cnt),
+ "total_avg_sales_cnt": float(self.total_avg_sales_cnt),
+ "group_current_ratio": float(self.group_current_ratio) if self.group_current_ratio else None,
+ "total_suggest_cnt": self.total_suggest_cnt,
+ "total_suggest_amount": float(self.total_suggest_amount),
+ "shop_count": self.shop_count,
+ "need_replenishment_shop_count": self.need_replenishment_shop_count,
+ "part_decision_reason": self.part_decision_reason,
+ "priority": self.priority,
+ "llm_confidence": self.llm_confidence,
+ "statistics_date": self.statistics_date,
+ }
diff --git b/src/fw_pms_ai/models/sql_result.py a/src/fw_pms_ai/models/sql_result.py
new file mode 100644
index 0000000..4ccba93
--- /dev/null
+++ a/src/fw_pms_ai/models/sql_result.py
@@ -0,0 +1,17 @@
+"""
+SQL 执行结果模型
+"""
+
+from dataclasses import dataclass
+from typing import Dict, List, Optional
+
+
+@dataclass
+class SQLExecutionResult:
+ """SQL执行结果"""
+ success: bool
+ sql: str
+ data: Optional[List[Dict]] = None
+ error: Optional[str] = None
+ retry_count: int = 0
+ execution_time_ms: int = 0
diff --git b/src/fw_pms_ai/models/suggestion.py a/src/fw_pms_ai/models/suggestion.py
new file mode 100644
index 0000000..8dc92e1
--- /dev/null
+++ a/src/fw_pms_ai/models/suggestion.py
@@ -0,0 +1,47 @@
+"""
+补货建议和配件分析结果模型
+"""
+
+from dataclasses import dataclass, field
+from decimal import Decimal
+from typing import List
+
+
+@dataclass
+class ReplenishmentSuggestion:
+ """补货建议"""
+ shop_id: int
+ shop_name: str
+ part_code: str
+ part_name: str
+ unit: str
+ cost_price: Decimal
+ current_storage_cnt: Decimal
+ avg_sales_cnt: Decimal
+ current_ratio: Decimal
+ suggest_cnt: int
+ suggest_amount: Decimal
+ suggestion_reason: str
+ priority: int = 2
+ confidence: float = 0.8
+
+
+@dataclass
+class PartAnalysisResult:
+ """配件分析结果 - 包含配件级汇总信息"""
+ part_code: str
+ part_name: str
+ unit: str
+ cost_price: Decimal
+ total_storage_cnt: Decimal
+ total_avg_sales_cnt: Decimal
+ group_current_ratio: Decimal
+ need_replenishment: bool
+ total_suggest_cnt: int
+ total_suggest_amount: Decimal
+ shop_count: int
+ need_replenishment_shop_count: int
+ part_decision_reason: str
+ priority: int = 2
+ confidence: float = 0.8
+ suggestions: List["ReplenishmentSuggestion"] = field(default_factory=list)
diff --git b/src/fw_pms_ai/models/task.py a/src/fw_pms_ai/models/task.py
new file mode 100644
index 0000000..3c3ac1d
--- /dev/null
+++ a/src/fw_pms_ai/models/task.py
@@ -0,0 +1,118 @@
+"""
+数据模型 - 补货任务
+"""
+
+from dataclasses import dataclass, field
+from decimal import Decimal
+from datetime import datetime
+from typing import Optional
+from enum import IntEnum
+
+
+class TaskStatus(IntEnum):
+ """任务状态"""
+ RUNNING = 0
+ SUCCESS = 1
+ FAILED = 2
+
+
+@dataclass
+class ReplenishmentTask:
+ """AI补货任务"""
+
+ task_no: str
+ group_id: int
+ dealer_grouping_id: int
+
+ id: Optional[int] = None
+ dealer_grouping_name: Optional[str] = None
+ brand_grouping_id: Optional[int] = None
+ plan_amount: Decimal = Decimal("0")
+ actual_amount: Decimal = Decimal("0")
+ part_count: int = 0
+ base_ratio: Optional[Decimal] = None
+ status: TaskStatus = TaskStatus.RUNNING
+ error_message: str = ""
+ llm_provider: str = ""
+ llm_model: str = ""
+ llm_total_tokens: int = 0
+ statistics_date: str = ""
+ start_time: Optional[datetime] = None
+ end_time: Optional[datetime] = None
+ create_time: Optional[datetime] = None
+
+ def to_dict(self) -> dict:
+ """转换为字典"""
+ return {
+ "task_no": self.task_no,
+ "group_id": self.group_id,
+ "dealer_grouping_id": self.dealer_grouping_id,
+ "dealer_grouping_name": self.dealer_grouping_name,
+ "brand_grouping_id": self.brand_grouping_id,
+ "plan_amount": float(self.plan_amount),
+ "actual_amount": float(self.actual_amount),
+ "part_count": self.part_count,
+ "base_ratio": float(self.base_ratio) if self.base_ratio else None,
+ "status": int(self.status),
+ "error_message": self.error_message,
+ "llm_provider": self.llm_provider,
+ "llm_model": self.llm_model,
+ "llm_total_tokens": self.llm_total_tokens,
+ "statistics_date": self.statistics_date,
+ }
+
+
+@dataclass
+class ReplenishmentDetail:
+ """AI补货建议明细"""
+
+ task_no: str
+ group_id: int
+ dealer_grouping_id: int
+ shop_id: int
+ part_code: str
+
+ id: Optional[int] = None
+ brand_grouping_id: Optional[int] = None
+ shop_name: Optional[str] = None
+ part_name: Optional[str] = None
+ unit: Optional[str] = None
+ cost_price: Decimal = Decimal("0")
+ current_ratio: Optional[Decimal] = None
+ base_ratio: Optional[Decimal] = None
+ post_plan_ratio: Optional[Decimal] = None
+ valid_storage_cnt: Decimal = Decimal("0")
+ avg_sales_cnt: Decimal = Decimal("0")
+ suggest_cnt: int = 0
+ suggest_amount: Decimal = Decimal("0")
+ suggestion_reason: str = ""
+ priority: int = 2
+ llm_confidence: float = 0.8
+ statistics_date: str = ""
+ create_time: Optional[datetime] = None
+
+ def to_dict(self) -> dict:
+ """转换为字典"""
+ return {
+ "task_no": self.task_no,
+ "group_id": self.group_id,
+ "dealer_grouping_id": self.dealer_grouping_id,
+ "brand_grouping_id": self.brand_grouping_id,
+ "shop_id": self.shop_id,
+ "shop_name": self.shop_name,
+ "part_code": self.part_code,
+ "part_name": self.part_name,
+ "unit": self.unit,
+ "cost_price": float(self.cost_price),
+ "current_ratio": float(self.current_ratio) if self.current_ratio else None,
+ "base_ratio": float(self.base_ratio) if self.base_ratio else None,
+ "post_plan_ratio": float(self.post_plan_ratio) if self.post_plan_ratio else None,
+ "valid_storage_cnt": float(self.valid_storage_cnt),
+ "avg_sales_cnt": float(self.avg_sales_cnt),
+ "suggest_cnt": self.suggest_cnt,
+ "suggest_amount": float(self.suggest_amount),
+ "suggestion_reason": self.suggestion_reason,
+ "priority": self.priority,
+ "llm_confidence": self.llm_confidence,
+ "statistics_date": self.statistics_date,
+ }
diff --git b/src/fw_pms_ai/scheduler/__init__.py a/src/fw_pms_ai/scheduler/__init__.py
new file mode 100644
index 0000000..a5dcde1
--- /dev/null
+++ a/src/fw_pms_ai/scheduler/__init__.py
@@ -0,0 +1,8 @@
+"""定时任务模块"""
+
+from .tasks import run_replenishment_task, start_scheduler
+
+__all__ = [
+ "run_replenishment_task",
+ "start_scheduler",
+]
diff --git b/src/fw_pms_ai/scheduler/tasks.py a/src/fw_pms_ai/scheduler/tasks.py
new file mode 100644
index 0000000..f90b332
--- /dev/null
+++ a/src/fw_pms_ai/scheduler/tasks.py
@@ -0,0 +1,139 @@
+"""
+定时任务
+使用 APScheduler 实现每日凌晨执行
+"""
+
+import logging
+import argparse
+from datetime import date
+
+from apscheduler.schedulers.blocking import BlockingScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from ..config import get_settings
+from ..agent import ReplenishmentAgent
+
+logger = logging.getLogger(__name__)
+
+
+def run_replenishment_task():
+ """执行补货建议任务"""
+ logger.info("="*50)
+ logger.info("开始执行 AI 补货建议定时任务")
+ logger.info("="*50)
+
+ try:
+ agent = ReplenishmentAgent()
+
+ # 默认配置 - 可从数据库读取
+ group_id = 2
+
+ agent.run_for_all_groupings(
+ group_id=group_id,
+ )
+
+ logger.info("="*50)
+ logger.info("AI 补货建议定时任务执行完成")
+ logger.info("="*50)
+
+ except Exception as e:
+ logger.error(f"定时任务执行失败: {e}", exc_info=True)
+ raise
+
+
+def start_scheduler():
+ """启动定时调度"""
+ settings = get_settings()
+
+ scheduler = BlockingScheduler()
+
+ # 添加定时任务
+ trigger = CronTrigger(
+ hour=settings.scheduler_cron_hour,
+ minute=settings.scheduler_cron_minute,
+ )
+
+ scheduler.add_job(
+ run_replenishment_task,
+ trigger=trigger,
+ id="replenishment_task",
+ name="AI 补货建议任务",
+ replace_existing=True,
+ )
+
+ logger.info(
+ f"定时任务已配置: 每日 {settings.scheduler_cron_hour:02d}:{settings.scheduler_cron_minute:02d} 执行"
+ )
+
+ try:
+ logger.info("调度器启动...")
+ scheduler.start()
+ except (KeyboardInterrupt, SystemExit):
+ logger.info("调度器停止")
+ scheduler.shutdown()
+
+
+def main():
+ """CLI 入口"""
+ parser = argparse.ArgumentParser(description="AI 补货建议定时任务")
+ parser.add_argument(
+ "--run-once",
+ action="store_true",
+ help="立即执行一次(不启动调度器)",
+ )
+ parser.add_argument(
+ "--group-id",
+ type=int,
+ default=2,
+ help="集团ID (默认: 2)",
+ )
+ parser.add_argument(
+ "--dealer-grouping-id",
+ type=int,
+ help="指定商家组合ID (可选)",
+ )
+
+ args = parser.parse_args()
+
+ # 配置日志
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ )
+
+ if args.run_once:
+ logger.info("单次执行模式")
+
+ if args.dealer_grouping_id:
+ # 指定商家组合
+ from ..services import DataService
+
+ agent = ReplenishmentAgent()
+ data_service = DataService()
+
+ try:
+ groupings = data_service.get_dealer_groupings(args.group_id)
+ grouping = next((g for g in groupings if g["id"] == args.dealer_grouping_id), None)
+
+ if not grouping:
+ logger.error(f"未找到商家组合: {args.dealer_grouping_id}")
+ return
+
+ # 直接使用预计划数据,不需要 shop_ids
+ agent.run(
+ group_id=args.group_id,
+ dealer_grouping_id=grouping["id"],
+ dealer_grouping_name=grouping["name"],
+ )
+
+ finally:
+ data_service.close()
+ else:
+ # 所有商家组合
+ run_replenishment_task()
+ else:
+ start_scheduler()
+
+
+if __name__ == "__main__":
+ main()
diff --git b/src/fw_pms_ai/services/__init__.py a/src/fw_pms_ai/services/__init__.py
new file mode 100644
index 0000000..2146ca3
--- /dev/null
+++ a/src/fw_pms_ai/services/__init__.py
@@ -0,0 +1,16 @@
+"""服务模块"""
+
+from .data_service import DataService
+from .result_writer import ResultWriter
+from .repository import TaskRepository, DetailRepository, LogRepository, SummaryRepository
+
+__all__ = [
+ "DataService",
+ "ResultWriter",
+ "TaskRepository",
+ "DetailRepository",
+ "LogRepository",
+ "SummaryRepository",
+]
+
+
diff --git b/src/fw_pms_ai/services/data_service.py a/src/fw_pms_ai/services/data_service.py
new file mode 100644
index 0000000..cd28a62
--- /dev/null
+++ a/src/fw_pms_ai/services/data_service.py
@@ -0,0 +1,91 @@
+"""
+数据获取服务
+从 MySQL 数据库查询库销比等数据
+"""
+
+import logging
+from typing import List, Optional
+from datetime import date
+from decimal import Decimal
+
+from .db import get_connection
+from ..models import PartRatio
+
+logger = logging.getLogger(__name__)
+
+
+class DataService:
+ """数据服务"""
+
+ def __init__(self):
+ self._conn = None
+
+ def _get_connection(self):
+ """获取数据库连接"""
+ if self._conn is None or not self._conn.is_connected():
+ self._conn = get_connection()
+ return self._conn
+
+ def close(self):
+ """关闭连接"""
+ if self._conn and self._conn.is_connected():
+ self._conn.close()
+ self._conn = None
+
+
+
+ def get_dealer_groupings(self, group_id: int) -> List[dict]:
+ """
+ 获取商家组合列表
+
+ Returns:
+ [{"id": 1, "name": "商家组合A", "dealer_ids": [1, 2, 3]}]
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ sql = """
+ SELECT id, region_name as name, auth_dealers
+ FROM artificial_region_dealer
+ WHERE group_id = %s
+ """
+ cursor.execute(sql, [group_id])
+ rows = cursor.fetchall()
+
+ result = []
+ for row in rows:
+ dealer_ids = []
+ if row.get("auth_dealers"):
+ try:
+ import json
+ dealers = json.loads(row["auth_dealers"])
+ if isinstance(dealers, list):
+ if dealers and isinstance(dealers[0], dict):
+ dealer_ids = [d.get("dealerId") for d in dealers if d.get("dealerId")]
+ else:
+ dealer_ids = [int(d) for d in dealers if d]
+ except:
+ pass
+ result.append({
+ "id": row["id"],
+ "name": row["name"],
+ "dealer_ids": dealer_ids,
+ })
+
+ logger.info(f"查询商家组合: group_id={group_id}, count={len(result)}")
+ return result
+
+ finally:
+ cursor.close()
+
+
+
+
+
+
+
+
+
+
+
diff --git b/src/fw_pms_ai/services/db.py a/src/fw_pms_ai/services/db.py
new file mode 100644
index 0000000..91c4bd7
--- /dev/null
+++ a/src/fw_pms_ai/services/db.py
@@ -0,0 +1,49 @@
+"""
+数据库连接管理模块
+统一管理 MySQL 数据库连接
+"""
+
+import mysql.connector
+from mysql.connector import MySQLConnection
+
+from ..config import get_settings
+
+
+def get_connection() -> MySQLConnection:
+ """
+ 获取数据库连接
+
+ Returns:
+ MySQLConnection: MySQL 数据库连接
+ """
+ settings = get_settings()
+ return mysql.connector.connect(
+ host=settings.mysql_host,
+ port=settings.mysql_port,
+ user=settings.mysql_user,
+ password=settings.mysql_password,
+ database=settings.mysql_database,
+ )
+
+
+class DatabaseConnection:
+ """
+ 数据库连接上下文管理器
+
+ 使用示例:
+ with DatabaseConnection() as conn:
+ cursor = conn.cursor(dictionary=True)
+ cursor.execute("SELECT * FROM table")
+ rows = cursor.fetchall()
+ """
+
+ def __init__(self):
+ self._conn: MySQLConnection = None
+
+ def __enter__(self) -> MySQLConnection:
+ self._conn = get_connection()
+ return self._conn
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if self._conn and self._conn.is_connected():
+ self._conn.close()
diff --git b/src/fw_pms_ai/services/repository/__init__.py a/src/fw_pms_ai/services/repository/__init__.py
new file mode 100644
index 0000000..f5457e1
--- /dev/null
+++ a/src/fw_pms_ai/services/repository/__init__.py
@@ -0,0 +1,16 @@
+"""
+Repository 数据访问层子包
+
+提供各表的 CRUD 操作
+"""
+
+from .task_repo import TaskRepository
+from .detail_repo import DetailRepository
+from .log_repo import LogRepository, SummaryRepository
+
+__all__ = [
+ "TaskRepository",
+ "DetailRepository",
+ "LogRepository",
+ "SummaryRepository",
+]
diff --git b/src/fw_pms_ai/services/repository/detail_repo.py a/src/fw_pms_ai/services/repository/detail_repo.py
new file mode 100644
index 0000000..a1259a3
--- /dev/null
+++ a/src/fw_pms_ai/services/repository/detail_repo.py
@@ -0,0 +1,119 @@
+"""
+补货明细数据访问层
+
+提供 ai_replenishment_detail 表的 CRUD 操作
+"""
+
+import logging
+from typing import List
+
+from ..db import get_connection
+from ...models import ReplenishmentDetail
+
+logger = logging.getLogger(__name__)
+
+
+class DetailRepository:
+ """补货明细数据访问"""
+
+ def __init__(self, connection=None):
+ self._conn = connection
+
+ def _get_connection(self):
+ """获取数据库连接"""
+ if self._conn is None or not self._conn.is_connected():
+ self._conn = get_connection()
+ return self._conn
+
+ def close(self):
+ """关闭连接"""
+ if self._conn and self._conn.is_connected():
+ self._conn.close()
+ self._conn = None
+
+ def save_batch(self, details: List[ReplenishmentDetail]) -> int:
+ """
+ 批量保存补货明细
+
+ Returns:
+ 插入的行数
+ """
+ if not details:
+ return 0
+
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ INSERT INTO ai_replenishment_detail (
+ task_no, group_id, dealer_grouping_id, brand_grouping_id,
+ shop_id, shop_name, part_code, part_name, unit, cost_price,
+ current_ratio, base_ratio, post_plan_ratio,
+ valid_storage_cnt, avg_sales_cnt, suggest_cnt, suggest_amount,
+ suggestion_reason, priority, llm_confidence, statistics_date, create_time
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
+ )
+ """
+
+ values = [
+ (
+ d.task_no, d.group_id, d.dealer_grouping_id, d.brand_grouping_id,
+ d.shop_id, d.shop_name, d.part_code, d.part_name, d.unit,
+ float(d.cost_price),
+ float(d.current_ratio) if d.current_ratio else None,
+ float(d.base_ratio) if d.base_ratio else None,
+ float(d.post_plan_ratio) if d.post_plan_ratio else None,
+ float(d.valid_storage_cnt), float(d.avg_sales_cnt),
+ d.suggest_cnt, float(d.suggest_amount),
+ d.suggestion_reason, d.priority, d.llm_confidence, d.statistics_date,
+ )
+ for d in details
+ ]
+
+ cursor.executemany(sql, values)
+ conn.commit()
+
+ logger.info(f"保存补货明细: {cursor.rowcount}条")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
+ def delete_by_task_no(self, task_no: str) -> int:
+ """
+ 删除指定任务的补货明细
+
+ Returns:
+ 删除的行数
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s"
+ cursor.execute(sql, (task_no,))
+ conn.commit()
+
+ logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
+ def find_by_task_no(self, task_no: str) -> List[ReplenishmentDetail]:
+ """根据 task_no 查询补货明细"""
+ conn = self._get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ sql = "SELECT * FROM ai_replenishment_detail WHERE task_no = %s"
+ cursor.execute(sql, (task_no,))
+ rows = cursor.fetchall()
+
+ return [ReplenishmentDetail(**row) for row in rows]
+
+ finally:
+ cursor.close()
diff --git b/src/fw_pms_ai/services/repository/log_repo.py a/src/fw_pms_ai/services/repository/log_repo.py
new file mode 100644
index 0000000..f0eefbb
--- /dev/null
+++ a/src/fw_pms_ai/services/repository/log_repo.py
@@ -0,0 +1,164 @@
+"""
+日志和汇总数据访问层
+
+提供 ai_task_execution_log 和 ai_replenishment_part_summary 表的 CRUD 操作
+"""
+
+import logging
+from typing import List
+
+from ..db import get_connection
+from ...models import TaskExecutionLog, ReplenishmentPartSummary
+
+logger = logging.getLogger(__name__)
+
+
+class LogRepository:
+ """执行日志数据访问"""
+
+ def __init__(self, connection=None):
+ self._conn = connection
+
+ def _get_connection(self):
+ """获取数据库连接"""
+ if self._conn is None or not self._conn.is_connected():
+ self._conn = get_connection()
+ return self._conn
+
+ def close(self):
+ """关闭连接"""
+ if self._conn and self._conn.is_connected():
+ self._conn.close()
+ self._conn = None
+
+ def create(self, log: TaskExecutionLog) -> int:
+ """
+ 保存执行日志
+
+ Returns:
+ 插入的日志ID
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ INSERT INTO ai_task_execution_log (
+ task_no, group_id, dealer_grouping_id, brand_grouping_id,
+ brand_grouping_name, dealer_grouping_name,
+ step_name, step_order, status, input_data, output_data,
+ error_message, retry_count, sql_query, llm_prompt, llm_response,
+ llm_tokens, execution_time_ms, start_time, end_time, create_time
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
+ )
+ """
+
+ values = (
+ log.task_no, log.group_id, log.dealer_grouping_id,
+ log.brand_grouping_id, log.brand_grouping_name,
+ log.dealer_grouping_name,
+ log.step_name, log.step_order, int(log.status),
+ log.input_data, log.output_data, log.error_message,
+ log.retry_count, log.sql_query, log.llm_prompt, log.llm_response,
+ log.llm_tokens, log.execution_time_ms,
+ log.start_time, log.end_time,
+ )
+
+ cursor.execute(sql, values)
+ conn.commit()
+
+ return cursor.lastrowid
+
+ finally:
+ cursor.close()
+
+
+class SummaryRepository:
+ """配件汇总数据访问"""
+
+ def __init__(self, connection=None):
+ self._conn = connection
+
+ def _get_connection(self):
+ """获取数据库连接"""
+ if self._conn is None or not self._conn.is_connected():
+ self._conn = get_connection()
+ return self._conn
+
+ def close(self):
+ """关闭连接"""
+ if self._conn and self._conn.is_connected():
+ self._conn.close()
+ self._conn = None
+
+ def save_batch(self, summaries: List[ReplenishmentPartSummary]) -> int:
+ """
+ 批量保存配件汇总
+
+ Returns:
+ 插入的行数
+ """
+ if not summaries:
+ return 0
+
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ INSERT INTO ai_replenishment_part_summary (
+ task_no, group_id, dealer_grouping_id, part_code, part_name,
+ unit, cost_price, total_storage_cnt, total_avg_sales_cnt,
+ group_current_ratio, total_suggest_cnt, total_suggest_amount,
+ shop_count, need_replenishment_shop_count, part_decision_reason,
+ priority, llm_confidence, statistics_date, create_time
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s, %s, %s, %s, NOW()
+ )
+ """
+
+ values = [
+ (
+ s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name,
+ s.unit, float(s.cost_price), float(s.total_storage_cnt),
+ float(s.total_avg_sales_cnt),
+ float(s.group_current_ratio) if s.group_current_ratio else None,
+ s.total_suggest_cnt, float(s.total_suggest_amount),
+ s.shop_count, s.need_replenishment_shop_count, s.part_decision_reason,
+ s.priority, s.llm_confidence, s.statistics_date,
+ )
+ for s in summaries
+ ]
+
+ cursor.executemany(sql, values)
+ conn.commit()
+
+ logger.info(f"保存配件汇总: {cursor.rowcount}条")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
+ def delete_by_task_no(self, task_no: str) -> int:
+ """
+ 删除指定任务的配件汇总
+
+ Returns:
+ 删除的行数
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s"
+ cursor.execute(sql, (task_no,))
+ conn.commit()
+
+ logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
diff --git b/src/fw_pms_ai/services/repository/task_repo.py a/src/fw_pms_ai/services/repository/task_repo.py
new file mode 100644
index 0000000..07be4c7
--- /dev/null
+++ a/src/fw_pms_ai/services/repository/task_repo.py
@@ -0,0 +1,149 @@
+"""
+任务数据访问层
+
+提供 ai_replenishment_task 表的 CRUD 操作
+"""
+
+import logging
+from datetime import datetime
+from typing import Optional
+
+from ..db import get_connection
+from ...models import ReplenishmentTask
+
+logger = logging.getLogger(__name__)
+
+
+class TaskRepository:
+ """任务数据访问"""
+
+ def __init__(self, connection=None):
+ self._conn = connection
+
+ def _get_connection(self):
+ """获取数据库连接"""
+ if self._conn is None or not self._conn.is_connected():
+ self._conn = get_connection()
+ return self._conn
+
+ def close(self):
+ """关闭连接"""
+ if self._conn and self._conn.is_connected():
+ self._conn.close()
+ self._conn = None
+
+ def create(self, task: ReplenishmentTask) -> int:
+ """
+ 创建任务记录
+
+ Returns:
+ 插入的任务ID
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ INSERT INTO ai_replenishment_task (
+ task_no, group_id, dealer_grouping_id, dealer_grouping_name,
+ brand_grouping_id, plan_amount, actual_amount, part_count,
+ base_ratio, status, error_message, llm_provider, llm_model,
+ llm_total_tokens, statistics_date, start_time, end_time, create_time
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
+ )
+ """
+
+ values = (
+ task.task_no,
+ task.group_id,
+ task.dealer_grouping_id,
+ task.dealer_grouping_name,
+ task.brand_grouping_id,
+ float(task.plan_amount),
+ float(task.actual_amount),
+ task.part_count,
+ float(task.base_ratio) if task.base_ratio else None,
+ int(task.status),
+ task.error_message,
+ task.llm_provider,
+ task.llm_model,
+ task.llm_total_tokens,
+ task.statistics_date,
+ datetime.now() if task.start_time is None else task.start_time,
+ task.end_time,
+ )
+
+ cursor.execute(sql, values)
+ conn.commit()
+
+ task_id = cursor.lastrowid
+ logger.info(f"创建任务记录: task_no={task.task_no}, id={task_id}")
+ return task_id
+
+ finally:
+ cursor.close()
+
+ def update(self, task: ReplenishmentTask) -> int:
+ """
+ 更新任务记录
+
+ Returns:
+ 更新的行数
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ UPDATE ai_replenishment_task
+ SET actual_amount = %s,
+ part_count = %s,
+ base_ratio = %s,
+ status = %s,
+ error_message = %s,
+ llm_provider = %s,
+ llm_model = %s,
+ llm_total_tokens = %s,
+ end_time = %s
+ WHERE task_no = %s
+ """
+
+ values = (
+ float(task.actual_amount),
+ task.part_count,
+ float(task.base_ratio) if task.base_ratio else None,
+ int(task.status),
+ task.error_message,
+ task.llm_provider,
+ task.llm_model,
+ task.llm_total_tokens,
+ datetime.now() if task.end_time is None else task.end_time,
+ task.task_no,
+ )
+
+ cursor.execute(sql, values)
+ conn.commit()
+
+ logger.info(f"更新任务记录: task_no={task.task_no}, rows={cursor.rowcount}")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
+ def find_by_task_no(self, task_no: str) -> Optional[ReplenishmentTask]:
+ """根据 task_no 查询任务"""
+ conn = self._get_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ try:
+ sql = "SELECT * FROM ai_replenishment_task WHERE task_no = %s"
+ cursor.execute(sql, (task_no,))
+ row = cursor.fetchone()
+
+ if row:
+ return ReplenishmentTask(**row)
+ return None
+
+ finally:
+ cursor.close()
diff --git b/src/fw_pms_ai/services/result_writer.py a/src/fw_pms_ai/services/result_writer.py
new file mode 100644
index 0000000..99b5a39
--- /dev/null
+++ a/src/fw_pms_ai/services/result_writer.py
@@ -0,0 +1,324 @@
+"""
+结果写入服务
+负责将补货建议结果写入数据库
+"""
+
+import logging
+import json
+from typing import List, Optional
+from datetime import datetime
+
+from .db import get_connection
+from ..models import (
+ ReplenishmentTask,
+ ReplenishmentDetail,
+ TaskExecutionLog,
+ ReplenishmentPartSummary,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class ResultWriter:
+ """结果写入服务"""
+
+ def __init__(self):
+ self._conn = None
+
+ def _get_connection(self):
+ """获取数据库连接"""
+ if self._conn is None or not self._conn.is_connected():
+ self._conn = get_connection()
+ return self._conn
+
+ def close(self):
+ """关闭连接"""
+ if self._conn and self._conn.is_connected():
+ self._conn.close()
+ self._conn = None
+
+ def save_task(self, task: ReplenishmentTask) -> int:
+ """
+ 保存任务记录
+
+ Returns:
+ 插入的任务ID
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ INSERT INTO ai_replenishment_task (
+ task_no, group_id, dealer_grouping_id, dealer_grouping_name,
+ brand_grouping_id, plan_amount, actual_amount, part_count,
+ base_ratio, status, error_message, llm_provider, llm_model,
+ llm_total_tokens, statistics_date, start_time, end_time, create_time
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
+ )
+ """
+
+ values = (
+ task.task_no,
+ task.group_id,
+ task.dealer_grouping_id,
+ task.dealer_grouping_name,
+ task.brand_grouping_id,
+ float(task.plan_amount),
+ float(task.actual_amount),
+ task.part_count,
+ float(task.base_ratio) if task.base_ratio else None,
+ int(task.status),
+ task.error_message,
+ task.llm_provider,
+ task.llm_model,
+ task.llm_total_tokens,
+ task.statistics_date,
+ datetime.now() if task.start_time is None else task.start_time,
+ task.end_time,
+ )
+
+ cursor.execute(sql, values)
+ conn.commit()
+
+ task_id = cursor.lastrowid
+ logger.info(f"保存任务记录: task_no={task.task_no}, id={task_id}")
+ return task_id
+
+ finally:
+ cursor.close()
+
+ def update_task(self, task: ReplenishmentTask) -> int:
+ """
+ 更新任务记录
+
+ Returns:
+ 更新的行数
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ UPDATE ai_replenishment_task
+ SET actual_amount = %s,
+ part_count = %s,
+ base_ratio = %s,
+ status = %s,
+ error_message = %s,
+ llm_provider = %s,
+ llm_model = %s,
+ llm_total_tokens = %s,
+ end_time = %s
+ WHERE task_no = %s
+ """
+
+ values = (
+ float(task.actual_amount),
+ task.part_count,
+ float(task.base_ratio) if task.base_ratio else None,
+ int(task.status),
+ task.error_message,
+ task.llm_provider,
+ task.llm_model,
+ task.llm_total_tokens,
+ datetime.now() if task.end_time is None else task.end_time,
+ task.task_no,
+ )
+
+ cursor.execute(sql, values)
+ conn.commit()
+
+ logger.info(f"更新任务记录: task_no={task.task_no}, rows={cursor.rowcount}")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
+ def save_details(self, details: List[ReplenishmentDetail]) -> int:
+ """
+ 保存补货明细
+
+ Returns:
+ 插入的行数
+ """
+ if not details:
+ return 0
+
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ INSERT INTO ai_replenishment_detail (
+ task_no, group_id, dealer_grouping_id, brand_grouping_id,
+ shop_id, shop_name, part_code, part_name, unit, cost_price,
+ current_ratio, base_ratio, post_plan_ratio,
+ valid_storage_cnt, avg_sales_cnt, suggest_cnt, suggest_amount,
+ suggestion_reason, priority, llm_confidence, statistics_date, create_time
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
+ )
+ """
+
+ values = [
+ (
+ d.task_no, d.group_id, d.dealer_grouping_id, d.brand_grouping_id,
+ d.shop_id, d.shop_name, d.part_code, d.part_name, d.unit,
+ float(d.cost_price),
+ float(d.current_ratio) if d.current_ratio else None,
+ float(d.base_ratio) if d.base_ratio else None,
+ float(d.post_plan_ratio) if d.post_plan_ratio else None,
+ float(d.valid_storage_cnt), float(d.avg_sales_cnt),
+ d.suggest_cnt, float(d.suggest_amount),
+ d.suggestion_reason, d.priority, d.llm_confidence, d.statistics_date,
+ )
+ for d in details
+ ]
+
+ cursor.executemany(sql, values)
+ conn.commit()
+
+ logger.info(f"保存补货明细: {cursor.rowcount}条")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
+ def save_execution_log(self, log: TaskExecutionLog) -> int:
+ """
+ 保存执行日志
+
+ Returns:
+ 插入的日志ID
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ INSERT INTO ai_task_execution_log (
+ task_no, group_id, dealer_grouping_id, brand_grouping_id,
+ brand_grouping_name, dealer_grouping_name,
+ step_name, step_order, status, input_data, output_data,
+ error_message, retry_count, sql_query, llm_prompt, llm_response,
+ llm_tokens, execution_time_ms, start_time, end_time, create_time
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
+ )
+ """
+
+ values = (
+ log.task_no, log.group_id, log.dealer_grouping_id,
+ log.brand_grouping_id, log.brand_grouping_name,
+ log.dealer_grouping_name,
+ log.step_name, log.step_order, int(log.status),
+ log.input_data, log.output_data, log.error_message,
+ log.retry_count, log.sql_query, log.llm_prompt, log.llm_response,
+ log.llm_tokens, log.execution_time_ms,
+ log.start_time, log.end_time,
+ )
+
+ cursor.execute(sql, values)
+ conn.commit()
+
+ log_id = cursor.lastrowid
+ return log_id
+
+ finally:
+ cursor.close()
+
+ def save_part_summaries(self, summaries: List[ReplenishmentPartSummary]) -> int:
+ """
+ 保存配件汇总信息
+
+ Returns:
+ 插入的行数
+ """
+ if not summaries:
+ return 0
+
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = """
+ INSERT INTO ai_replenishment_part_summary (
+ task_no, group_id, dealer_grouping_id, part_code, part_name,
+ unit, cost_price, total_storage_cnt, total_avg_sales_cnt,
+ group_current_ratio, total_suggest_cnt, total_suggest_amount,
+ shop_count, need_replenishment_shop_count, part_decision_reason,
+ priority, llm_confidence, statistics_date, create_time
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s, %s, %s, %s, NOW()
+ )
+ """
+
+ values = [
+ (
+ s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name,
+ s.unit, float(s.cost_price), float(s.total_storage_cnt),
+ float(s.total_avg_sales_cnt),
+ float(s.group_current_ratio) if s.group_current_ratio else None,
+ s.total_suggest_cnt, float(s.total_suggest_amount),
+ s.shop_count, s.need_replenishment_shop_count, s.part_decision_reason,
+ s.priority, s.llm_confidence, s.statistics_date,
+ )
+ for s in summaries
+ ]
+
+ cursor.executemany(sql, values)
+ conn.commit()
+
+ logger.info(f"保存配件汇总: {cursor.rowcount}条")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
+ def delete_part_summaries_by_task(self, task_no: str) -> int:
+ """
+ 删除指定任务的配件汇总
+
+ Returns:
+ 删除的行数
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s"
+ cursor.execute(sql, (task_no,))
+ conn.commit()
+
+ logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
+ def delete_details_by_task(self, task_no: str) -> int:
+ """
+ 删除指定任务的补货明细
+
+ Returns:
+ 删除的行数
+ """
+ conn = self._get_connection()
+ cursor = conn.cursor()
+
+ try:
+ sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s"
+ cursor.execute(sql, (task_no,))
+ conn.commit()
+
+ logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}")
+ return cursor.rowcount
+
+ finally:
+ cursor.close()
+
diff --git b/ui/css/style.css a/ui/css/style.css
new file mode 100644
index 0000000..d6dda43
--- /dev/null
+++ a/ui/css/style.css
@@ -0,0 +1,2055 @@
+/**
+ * AI 补货建议系统 - 核心样式
+ * 设计理念: 深色主题 + 玻璃拟态 + 现代专业风格
+ */
+
+/* =====================
+ CSS Variables
+ ===================== */
+:root {
+ /* 主色调 */
+ --color-primary: #6366f1;
+ --color-primary-light: #818cf8;
+ --color-primary-dark: #4f46e5;
+
+ /* 功能色 */
+ --color-success: #10b981;
+ --color-success-light: #34d399;
+ --color-warning: #f59e0b;
+ --color-warning-light: #fbbf24;
+ --color-danger: #ef4444;
+ --color-danger-light: #f87171;
+ --color-info: #3b82f6;
+ --color-info-light: #60a5fa;
+
+ /* 深色主题背景 */
+ --bg-base: #0f172a;
+ --bg-surface: #1e293b;
+ --bg-elevated: #334155;
+ --bg-hover: #475569;
+
+ /* 文字颜色 */
+ --text-primary: #f8fafc;
+ --text-secondary: #94a3b8;
+ --text-muted: #64748b;
+ --text-disabled: #475569;
+
+ /* 边框 */
+ --border-color: rgba(148, 163, 184, 0.1);
+ --border-color-light: rgba(148, 163, 184, 0.2);
+
+ /* 阴影 */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3);
+ --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
+
+ /* 玻璃效果 */
+ --glass-bg: rgba(30, 41, 59, 0.8);
+ --glass-border: rgba(148, 163, 184, 0.15);
+ --glass-blur: 12px;
+
+ /* 间距 */
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 16px;
+ --spacing-lg: 24px;
+ --spacing-xl: 32px;
+ --spacing-2xl: 48px;
+
+ /* 圆角 */
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 16px;
+ --radius-xl: 24px;
+ --radius-full: 9999px;
+
+ /* 字体 */
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+
+ /* 过渡 */
+ --transition-fast: 150ms ease;
+ --transition-base: 250ms ease;
+ --transition-slow: 400ms ease;
+
+ /* 布局 */
+ --sidebar-width: 260px;
+ --sidebar-width-collapsed: 64px;
+ --topbar-height: 64px;
+}
+
+/* =====================
+ Reset & Base
+ ===================== */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ font-size: 14px;
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: var(--font-sans);
+ background-color: var(--bg-base);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+ display: flex;
+ overflow-x: hidden;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button {
+ font-family: inherit;
+ cursor: pointer;
+ border: none;
+ background: none;
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+/* =====================
+ Scrollbar
+ ===================== */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-surface);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--bg-elevated);
+ border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--bg-hover);
+}
+
+/* =====================
+ Sidebar
+ ===================== */
+.sidebar {
+ width: var(--sidebar-width);
+ height: 100vh;
+ background: var(--glass-bg);
+ backdrop-filter: blur(var(--glass-blur));
+ border-right: 1px solid var(--glass-border);
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ left: 0;
+ top: 0;
+ z-index: 100;
+ transition: transform var(--transition-base);
+}
+
+.sidebar.visible {
+ transform: translateX(0);
+}
+
+.sidebar-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.5);
+ backdrop-filter: blur(2px);
+ z-index: 90;
+ opacity: 0;
+ visibility: hidden;
+ transition: all var(--transition-base);
+}
+
+.sidebar-overlay.active {
+ opacity: 1;
+ visibility: visible;
+}
+
+.sidebar-header {
+ padding: var(--spacing-lg);
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.sidebar-collapse-btn {
+ color: var(--text-secondary);
+ transition: transform var(--transition-base);
+}
+
+.sidebar-collapse-btn:hover {
+ color: var(--text-primary);
+ background: var(--bg-elevated);
+}
+
+/* Collapsed State */
+.sidebar.collapsed {
+ width: var(--sidebar-width-collapsed);
+}
+
+.sidebar.collapsed .logo-text,
+.sidebar.collapsed .version-info,
+.sidebar.collapsed .nav-item span {
+ display: none;
+}
+
+.sidebar.collapsed .sidebar-header {
+ padding: var(--spacing-lg) var(--spacing-sm);
+ justify-content: center;
+}
+
+.sidebar.collapsed .logo {
+ display: none;
+}
+
+.sidebar.collapsed .sidebar-collapse-btn {
+ transform: rotate(180deg);
+}
+
+.sidebar.collapsed .sidebar-nav {
+ padding: var(--spacing-md) var(--spacing-xs);
+ align-items: center;
+}
+
+.sidebar.collapsed .nav-item {
+ justify-content: center;
+ padding: var(--spacing-md);
+ width: 40px;
+ height: 40px;
+}
+
+.sidebar.collapsed .nav-item svg {
+ margin: 0;
+}
+
+.sidebar.collapsed + .sidebar-overlay + .main-content {
+ margin-left: var(--sidebar-width-collapsed);
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+
+.logo-icon {
+ width: 32px;
+ height: 32px;
+ color: var(--color-primary);
+}
+
+.logo-text {
+ font-size: 1.25rem;
+ font-weight: 600;
+ background: linear-gradient(135deg, var(--color-primary-light), var(--color-primary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.sidebar-nav {
+ flex: 1;
+ padding: var(--spacing-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ padding: var(--spacing-md) var(--spacing-lg);
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+ transition: all var(--transition-fast);
+}
+
+.nav-item:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+}
+
+.nav-item.active {
+ background: linear-gradient(135deg, var(--color-primary-dark), var(--color-primary));
+ color: white;
+ box-shadow: var(--shadow-md);
+}
+
+.nav-item svg {
+ width: 20px;
+ height: 20px;
+}
+
+.sidebar-footer {
+ padding: var(--spacing-md) var(--spacing-lg);
+ border-top: 1px solid var(--border-color);
+}
+
+.version-info {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-align: center;
+}
+
+/* =====================
+ Main Content
+ ===================== */
+.main-content {
+ flex: 1;
+ margin-left: var(--sidebar-width);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ transition: margin-left var(--transition-base);
+}
+
+.top-bar {
+ height: var(--topbar-height);
+ background: var(--glass-bg);
+ backdrop-filter: blur(var(--glass-blur));
+ border-bottom: 1px solid var(--glass-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 var(--spacing-xl);
+ top: 0;
+ z-index: 50;
+ gap: var(--spacing-sm);
+}
+
+.menu-toggle {
+ display: none;
+ margin-right: var(--spacing-sm);
+}
+
+@media (max-width: 1024px) {
+ .menu-toggle {
+ display: inline-flex;
+ }
+}
+
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ color: var(--text-secondary);
+}
+
+.breadcrumb-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.breadcrumb-item:not(:last-child)::after {
+ content: '/';
+ color: var(--text-muted);
+ margin-left: var(--spacing-sm);
+}
+
+.breadcrumb-item a {
+ color: var(--color-primary-light);
+ transition: color var(--transition-fast);
+}
+
+.breadcrumb-item a:hover {
+ color: var(--color-primary);
+}
+
+.top-bar-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.page-container {
+ flex: 1;
+ padding: var(--spacing-xl);
+ overflow-y: auto;
+}
+
+/* =====================
+ Buttons
+ ===================== */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm) var(--spacing-lg);
+ font-size: 0.875rem;
+ font-weight: 500;
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
+ color: white;
+ box-shadow: var(--shadow-sm);
+}
+
+.btn-primary:hover {
+ box-shadow: var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color-light);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-hover);
+}
+
+.btn-ghost {
+ color: var(--text-secondary);
+}
+
+.btn-ghost:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+}
+
+.btn-icon {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+}
+
+.btn-icon:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+}
+
+.btn-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.btn-sm {
+ padding: var(--spacing-xs) var(--spacing-md);
+ font-size: 0.75rem;
+}
+
+/* =====================
+ Cards
+ ===================== */
+.card {
+ background: var(--glass-bg);
+ backdrop-filter: blur(var(--glass-blur));
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ padding: var(--spacing-lg);
+ transition: all var(--transition-base);
+}
+
+.card:hover {
+ border-color: var(--border-color-light);
+ box-shadow: var(--shadow-lg);
+}
+
+.card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--spacing-lg);
+}
+
+.card-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.card-title svg {
+ width: 20px;
+ height: 20px;
+ color: var(--color-primary);
+}
+
+/* =====================
+ Stat Cards
+ ===================== */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: var(--spacing-lg);
+ margin-bottom: var(--spacing-xl);
+}
+
+.stat-card {
+ background: var(--glass-bg);
+ backdrop-filter: blur(var(--glass-blur));
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ padding: var(--spacing-lg);
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-lg);
+ transition: all var(--transition-base);
+}
+
+.stat-card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+.stat-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: var(--radius-md);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.stat-icon svg {
+ width: 24px;
+ height: 24px;
+}
+
+.stat-icon.primary {
+ background: rgba(99, 102, 241, 0.15);
+ color: var(--color-primary);
+}
+
+.stat-icon.success {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--color-success);
+}
+
+.stat-icon.warning {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning);
+}
+
+.stat-icon.danger {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--color-danger);
+}
+
+.stat-icon.info {
+ background: rgba(59, 130, 246, 0.15);
+ color: var(--color-info);
+}
+
+.stat-content {
+ flex: 1;
+}
+
+.stat-label {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ margin-bottom: var(--spacing-xs);
+}
+
+.stat-value {
+ font-size: 1.75rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.stat-change {
+ font-size: 0.75rem;
+ margin-top: var(--spacing-xs);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.stat-change.positive {
+ color: var(--color-success);
+}
+
+.stat-change.negative {
+ color: var(--color-danger);
+}
+
+/* =====================
+ Tables
+ ===================== */
+.table-container {
+ background: var(--glass-bg);
+ backdrop-filter: blur(var(--glass-blur));
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.table-container > .table-wrapper {
+ border-radius: var(--radius-lg);
+ overflow-x: auto;
+}
+
+.table-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-lg);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.table-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+.table-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.table-wrapper {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ width: 100%;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+thead {
+ background: var(--bg-surface);
+}
+
+th {
+ padding: var(--spacing-md) var(--spacing-lg);
+ text-align: left;
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+ border-bottom: 1px solid var(--border-color);
+ white-space: nowrap;
+}
+
+td {
+ padding: var(--spacing-md) var(--spacing-lg);
+ border-bottom: 1px solid var(--border-color);
+ vertical-align: middle;
+}
+
+.table-cell-secondary {
+ min-width: 120px;
+ max-width: 200px;
+ word-break: break-word;
+}
+
+tbody tr {
+ transition: background var(--transition-fast);
+}
+
+tbody tr:hover {
+ background: var(--bg-elevated);
+}
+
+tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.table-cell-link {
+ color: var(--color-primary-light);
+ font-weight: 500;
+ cursor: pointer;
+ transition: color var(--transition-fast);
+}
+
+.table-cell-link:hover {
+ color: var(--color-primary);
+ text-decoration: underline;
+}
+
+.table-cell-mono {
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+}
+
+.table-cell-amount {
+ font-weight: 600;
+ color: var(--color-success);
+}
+
+.table-cell-secondary {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+/* =====================
+ Badges / Status
+ ===================== */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-size: 0.75rem;
+ font-weight: 500;
+ border-radius: var(--radius-full);
+ white-space: nowrap;
+}
+
+.badge-success {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--color-success-light);
+}
+
+.badge-warning {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning-light);
+}
+
+.badge-danger {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--color-danger-light);
+}
+
+.badge-info {
+ background: rgba(59, 130, 246, 0.15);
+ color: var(--color-info-light);
+}
+
+.badge-neutral {
+ background: var(--bg-elevated);
+ color: var(--text-secondary);
+}
+
+.badge-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+/* =====================
+ Pagination
+ ===================== */
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-lg);
+ border-top: 1px solid var(--border-color);
+}
+
+.pagination-info {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.pagination-controls {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.pagination-btn {
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+ transition: all var(--transition-fast);
+}
+
+.pagination-btn:hover:not(:disabled) {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+}
+
+.pagination-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pagination-btn.active {
+ background: var(--color-primary);
+ color: white;
+}
+
+.pagination-btn svg {
+ width: 18px;
+ height: 18px;
+}
+
+/* =====================
+ Loading & Overlay
+ ===================== */
+.loading-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.8);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ opacity: 0;
+ visibility: hidden;
+ transition: all var(--transition-base);
+}
+
+.loading-overlay.active {
+ opacity: 1;
+ visibility: visible;
+}
+
+.loading-spinner {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-md);
+ color: var(--text-primary);
+}
+
+.loading-spinner svg {
+ width: 48px;
+ height: 48px;
+ color: var(--color-primary);
+}
+
+.spin {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+/* =====================
+ Modal
+ ===================== */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.8);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: var(--spacing-xl);
+ opacity: 0;
+ visibility: hidden;
+ transition: all var(--transition-base);
+}
+
+.modal-overlay.active {
+ opacity: 1;
+ visibility: visible;
+}
+
+.modal {
+ background: var(--bg-surface);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-xl);
+ width: 100%;
+ max-width: 800px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-xl);
+ transform: scale(0.95);
+ transition: transform var(--transition-base);
+}
+
+.modal-overlay.active .modal {
+ transform: scale(1);
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-lg) var(--spacing-xl);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.modal-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+}
+
+.modal-body {
+ flex: 1;
+ padding: var(--spacing-xl);
+ overflow-y: auto;
+}
+
+/* =====================
+ Toast Notifications
+ ===================== */
+.toast-container {
+ position: fixed;
+ top: var(--spacing-lg);
+ right: var(--spacing-lg);
+ z-index: 1100;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.toast {
+ background: var(--bg-surface);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-md);
+ padding: var(--spacing-md) var(--spacing-lg);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ box-shadow: var(--shadow-lg);
+ animation: slideIn var(--transition-base) ease;
+ min-width: 300px;
+}
+
+.toast.success {
+ border-left: 4px solid var(--color-success);
+}
+
+.toast.error {
+ border-left: 4px solid var(--color-danger);
+}
+
+.toast.warning {
+ border-left: 4px solid var(--color-warning);
+}
+
+.toast.info {
+ border-left: 4px solid var(--color-info);
+}
+
+.toast-icon {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+}
+
+.toast.success .toast-icon { color: var(--color-success); }
+.toast.error .toast-icon { color: var(--color-danger); }
+.toast.warning .toast-icon { color: var(--color-warning); }
+.toast.info .toast-icon { color: var(--color-info); }
+
+.toast-message {
+ flex: 1;
+ font-size: 0.875rem;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* =====================
+ Detail View
+ ===================== */
+.detail-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: var(--spacing-xl);
+}
+
+.detail-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+
+.detail-title .badge {
+ font-size: 0.875rem;
+}
+
+.detail-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-lg);
+ margin-top: var(--spacing-sm);
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.detail-meta-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.detail-meta-item svg {
+ width: 16px;
+ height: 16px;
+}
+
+.detail-actions {
+ display: flex;
+ gap: var(--spacing-sm);
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: var(--spacing-lg);
+ margin-bottom: var(--spacing-xl);
+}
+
+.detail-section {
+ margin-bottom: var(--spacing-xl);
+}
+
+.detail-section-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin-bottom: var(--spacing-lg);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ color: var(--text-primary);
+}
+
+.detail-section-title svg {
+ width: 20px;
+ height: 20px;
+ color: var(--color-primary);
+}
+
+/* =====================
+ Info List
+ ===================== */
+.info-list {
+ display: grid;
+ gap: var(--spacing-md);
+}
+
+.info-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-sm) 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.info-item:last-child {
+ border-bottom: none;
+}
+
+.info-label {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.info-value {
+ font-weight: 500;
+ text-align: right;
+}
+
+/* =====================
+ Markdown Content
+ ===================== */
+.markdown-content {
+ line-height: 1.8;
+ color: var(--text-primary);
+}
+
+.markdown-content h1,
+.markdown-content h2,
+.markdown-content h3,
+.markdown-content h4 {
+ margin-top: var(--spacing-xl);
+ margin-bottom: var(--spacing-md);
+ font-weight: 600;
+}
+
+.markdown-content h1 { font-size: 1.75rem; }
+.markdown-content h2 { font-size: 1.5rem; }
+.markdown-content h3 { font-size: 1.25rem; }
+.markdown-content h4 { font-size: 1.125rem; }
+
+.markdown-content p {
+ margin-bottom: var(--spacing-md);
+}
+
+.markdown-content ul,
+.markdown-content ol {
+ margin-bottom: var(--spacing-md);
+ padding-left: var(--spacing-xl);
+}
+
+.markdown-content li {
+ margin-bottom: var(--spacing-sm);
+}
+
+.markdown-content code {
+ background: var(--bg-elevated);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+}
+
+.markdown-content pre {
+ background: var(--bg-elevated);
+ padding: var(--spacing-lg);
+ border-radius: var(--radius-md);
+ overflow-x: auto;
+ margin-bottom: var(--spacing-md);
+}
+
+.markdown-content pre code {
+ background: none;
+ padding: 0;
+}
+
+.markdown-content blockquote {
+ border-left: 4px solid var(--color-primary);
+ padding-left: var(--spacing-lg);
+ margin: var(--spacing-lg) 0;
+ color: var(--text-secondary);
+}
+
+.markdown-content table {
+ margin: var(--spacing-lg) 0;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+.markdown-content th,
+.markdown-content td {
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: 1px solid var(--border-color);
+}
+
+.markdown-content th {
+ background: var(--bg-elevated);
+}
+
+.markdown-content hr {
+ border: none;
+ border-top: 1px solid var(--border-color);
+ margin: var(--spacing-xl) 0;
+}
+
+/* =====================
+ Empty State
+ ===================== */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-2xl);
+ text-align: center;
+ color: var(--text-secondary);
+}
+
+.empty-state svg {
+ width: 64px;
+ height: 64px;
+ color: var(--text-muted);
+ margin-bottom: var(--spacing-lg);
+}
+
+.empty-state-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-sm);
+}
+
+.empty-state-description {
+ font-size: 0.875rem;
+ max-width: 400px;
+}
+
+/* =====================
+ Tabs
+ ===================== */
+.tabs {
+ display: flex;
+ gap: var(--spacing-xs);
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: var(--spacing-xl);
+}
+
+.tab {
+ padding: var(--spacing-md) var(--spacing-lg);
+ font-weight: 500;
+ color: var(--text-secondary);
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: all var(--transition-fast);
+}
+
+.tab:hover {
+ color: var(--text-primary);
+}
+
+.tab.active {
+ color: var(--color-primary);
+ border-bottom-color: var(--color-primary);
+}
+
+/* =====================
+ Ratio Indicator
+ ===================== */
+.ratio-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.ratio-bar {
+ width: 60px;
+ height: 6px;
+ background: var(--bg-elevated);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+}
+
+.ratio-bar-fill {
+ height: 100%;
+ border-radius: var(--radius-full);
+ transition: width var(--transition-base);
+}
+
+.ratio-low .ratio-bar-fill { background: var(--color-danger); }
+.ratio-normal .ratio-bar-fill { background: var(--color-success); }
+.ratio-high .ratio-bar-fill { background: var(--color-warning); }
+
+/* =====================
+ Priority Badge
+ ===================== */
+.priority-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.priority-high {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--color-danger-light);
+}
+
+.priority-medium {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning-light);
+}
+
+.priority-low {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--color-success-light);
+}
+
+/* =====================
+ Back Button
+ ===================== */
+.back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ margin-bottom: var(--spacing-lg);
+ transition: color var(--transition-fast);
+}
+
+.back-link:hover {
+ color: var(--color-primary);
+}
+
+.back-link svg {
+ width: 18px;
+ height: 18px;
+}
+
+/* =====================
+ Report Sections
+ ===================== */
+.report-section {
+ padding: var(--spacing-lg);
+}
+
+.report-section .markdown-content {
+ line-height: 1.8;
+}
+
+.report-section .markdown-content h1,
+.report-section .markdown-content h2,
+.report-section .markdown-content h3 {
+ margin-top: var(--spacing-lg);
+ margin-bottom: var(--spacing-md);
+}
+
+.report-section .markdown-content p {
+ margin-bottom: var(--spacing-md);
+}
+
+.report-section .markdown-content ul,
+.report-section .markdown-content ol {
+ margin-bottom: var(--spacing-md);
+ padding-left: var(--spacing-xl);
+}
+
+.report-section .markdown-content li {
+ margin-bottom: var(--spacing-xs);
+}
+
+.report-section .markdown-content pre:empty,
+.report-section .markdown-content pre:blank {
+ display: none;
+}
+
+/* =====================
+ Part Tags
+ ===================== */
+.part-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ border-radius: var(--radius-full);
+ white-space: nowrap;
+}
+
+.tag-stagnant {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--color-danger-light);
+}
+
+.tag-low-freq {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning-light);
+}
+
+.tag-shortage {
+ background: rgba(249, 115, 22, 0.15);
+ color: #fb923c;
+}
+
+/* =====================
+ Timeline
+ ===================== */
+.timeline {
+ position: relative;
+ padding: var(--spacing-md) 0;
+}
+
+.timeline-item {
+ display: flex;
+ gap: var(--spacing-lg);
+ padding: var(--spacing-md) 0;
+}
+
+.timeline-marker {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.timeline-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+}
+
+.timeline-item-success .timeline-icon {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--color-success);
+}
+
+.timeline-item-error .timeline-icon {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--color-danger);
+}
+
+.timeline-icon svg {
+ width: 18px;
+ height: 18px;
+}
+
+.timeline-line {
+ width: 2px;
+ flex: 1;
+ min-height: 24px;
+ background: var(--border-color-light);
+ margin-top: var(--spacing-sm);
+}
+
+.timeline-content {
+ flex: 1;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: var(--spacing-md) var(--spacing-lg);
+ transition: all var(--transition-fast);
+}
+
+.timeline-content:hover {
+ border-color: var(--border-color-light);
+}
+
+.timeline-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md);
+ margin-bottom: var(--spacing-sm);
+}
+
+.timeline-title {
+ font-weight: 600;
+ font-size: 1rem;
+}
+
+.timeline-meta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--spacing-lg);
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.meta-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.meta-item svg {
+ width: 14px;
+ height: 14px;
+}
+
+.meta-warning {
+ color: var(--color-warning);
+}
+
+.timeline-error {
+ margin-top: var(--spacing-md);
+ padding: var(--spacing-md);
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+ border-radius: var(--radius-sm);
+ color: var(--color-danger-light);
+ font-size: 0.875rem;
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-sm);
+}
+
+.timeline-error svg {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+/* =====================
+ Responsive
+ ===================== */
+@media (max-width: 1024px) {
+ .sidebar {
+ transform: translateX(-100%);
+ box-shadow: var(--shadow-xl);
+ }
+
+ .main-content {
+ margin-left: 0;
+ }
+
+ .top-bar {
+ padding: 0 var(--spacing-md);
+ }
+
+ .page-container {
+ padding: var(--spacing-md);
+ }
+
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* 笔记本屏幕适配 (1024px - 1440px) */
+@media (min-width: 1024px) and (max-width: 1440px) {
+ .page-container {
+ padding: var(--spacing-lg);
+ }
+
+ .table-container {
+ overflow: hidden; /* 确保不撑开父容器 */
+ }
+
+ .table-wrapper {
+ width: 100%;
+ overflow-x: auto;
+ }
+
+ th, td {
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: 0.85rem;
+ }
+}
+
+@media (max-width: 768px) {
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .detail-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .table-wrapper {
+ overflow-x: auto;
+ }
+
+ th, td {
+ padding: var(--spacing-sm);
+ font-size: 0.8rem;
+ }
+
+ /* 隐藏次要列 */
+ @media (max-width: 640px) {
+ .table-cell-secondary {
+ display: none;
+ }
+ }
+
+ /* 调整面包屑显示 */
+ .breadcrumb-item:not(:last-child) {
+ display: none;
+ }
+ .breadcrumb-item:last-child::before {
+ content: '';
+ margin: 0;
+ }
+}
+
+/* =====================
+ Report JSON Sections
+ ===================== */
+.report-section-header {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ margin-bottom: var(--spacing-lg);
+}
+
+.report-section-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-md);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.report-section-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.report-section-icon.summary {
+ background: rgba(99, 102, 241, 0.15);
+ color: var(--color-primary);
+}
+
+.report-section-icon.analysis {
+ background: rgba(59, 130, 246, 0.15);
+ color: var(--color-info);
+}
+
+.report-section-icon.risk {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning);
+}
+
+.report-section-icon.recommendation {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--color-success);
+}
+
+.report-section-icon.optimization {
+ background: rgba(139, 92, 246, 0.15);
+ color: #a78bfa;
+}
+
+.report-section-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+/* Summary Items */
+.summary-items {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--spacing-md);
+ margin-bottom: var(--spacing-lg);
+}
+
+.summary-item {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: var(--spacing-md);
+}
+
+.summary-item-label {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ margin-bottom: var(--spacing-xs);
+}
+
+.summary-item-value {
+ font-size: 1.25rem;
+ font-weight: 600;
+}
+
+.summary-item.status-normal .summary-item-value { color: var(--text-primary); }
+.summary-item.status-highlight .summary-item-value { color: var(--color-primary-light); }
+.summary-item.status-warning .summary-item-value { color: var(--color-warning); }
+.summary-item.status-danger .summary-item-value { color: var(--color-danger); }
+
+.summary-text {
+ color: var(--text-secondary);
+ line-height: 1.6;
+}
+
+/* Risk Items */
+.risk-list {
+ display: grid;
+ gap: var(--spacing-md);
+}
+
+.risk-item {
+ display: flex;
+ gap: var(--spacing-md);
+ padding: var(--spacing-md);
+ background: var(--bg-surface);
+ border-radius: var(--radius-md);
+ border-left: 4px solid;
+}
+
+.risk-item.level-high { border-left-color: var(--color-danger); }
+.risk-item.level-medium { border-left-color: var(--color-warning); }
+.risk-item.level-low { border-left-color: var(--color-success); }
+
+.risk-level {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--radius-sm);
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ flex-shrink: 0;
+ height: fit-content;
+}
+
+.risk-level.high {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--color-danger-light);
+}
+
+.risk-level.medium {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning-light);
+}
+
+.risk-level.low {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--color-success-light);
+}
+
+.risk-content {
+ flex: 1;
+}
+
+.risk-category {
+ font-weight: 600;
+ margin-bottom: var(--spacing-xs);
+}
+
+.risk-description {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+/* Recommendation Items */
+.recommendation-list {
+ display: grid;
+ gap: var(--spacing-md);
+}
+
+.recommendation-item {
+ display: flex;
+ gap: var(--spacing-md);
+ padding: var(--spacing-md);
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+}
+
+.recommendation-priority {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.875rem;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+
+.recommendation-priority.priority-1 {
+ background: var(--color-danger);
+ color: white;
+}
+
+.recommendation-priority.priority-2 {
+ background: var(--color-warning);
+ color: white;
+}
+
+.recommendation-priority.priority-3 {
+ background: var(--color-info);
+ color: white;
+}
+
+.recommendation-content {
+ flex: 1;
+}
+
+.recommendation-action {
+ font-weight: 600;
+ margin-bottom: var(--spacing-xs);
+}
+
+.recommendation-reason {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+/* Suggestion Items */
+.suggestion-list {
+ display: grid;
+ gap: var(--spacing-sm);
+}
+
+.suggestion-item {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-md);
+ padding: var(--spacing-md);
+ background: var(--bg-surface);
+ border-radius: var(--radius-md);
+}
+
+.suggestion-icon {
+ color: var(--color-success);
+ flex-shrink: 0;
+}
+
+.suggestion-icon svg {
+ width: 18px;
+ height: 18px;
+}
+
+.suggestion-text {
+ flex: 1;
+ line-height: 1.5;
+}
+
+/* Highlight Items */
+.highlight-list {
+ display: grid;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-md);
+}
+
+.highlight-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: rgba(99, 102, 241, 0.1);
+ border-radius: var(--radius-sm);
+}
+
+.highlight-label {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.highlight-value {
+ font-weight: 600;
+ color: var(--color-primary-light);
+}
+
+/* Analysis Paragraphs */
+.analysis-paragraphs {
+ display: grid;
+ gap: var(--spacing-md);
+}
+
+.analysis-paragraph {
+ color: var(--text-secondary);
+ line-height: 1.7;
+}
+
+/* =====================
+ Risk Section
+ ===================== */
+.risk-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: var(--spacing-lg);
+ margin-top: var(--spacing-md);
+}
+
+.risk-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: var(--spacing-lg);
+ transition: all var(--transition-base);
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ position: relative;
+ overflow: hidden;
+}
+
+.risk-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 4px;
+ background: var(--border-color);
+ transition: background var(--transition-base);
+}
+
+.risk-card:hover {
+ transform: translateY(-4px);
+ box-shadow: var(--shadow-lg);
+ border-color: var(--border-color-light);
+}
+
+/* Severity Styles */
+.risk-card.level-high {
+ background: linear-gradient(180deg, rgba(239, 68, 68, 0.05) 0%, rgba(239, 68, 68, 0.01) 100%);
+ border-color: rgba(239, 68, 68, 0.2);
+}
+.risk-card.level-high::before { background: var(--color-danger); }
+
+.risk-card.level-medium {
+ background: linear-gradient(180deg, rgba(245, 158, 11, 0.05) 0%, rgba(245, 158, 11, 0.01) 100%);
+ border-color: rgba(245, 158, 11, 0.2);
+}
+.risk-card.level-medium::before { background: var(--color-warning); }
+
+.risk-card.level-low {
+ background: linear-gradient(180deg, rgba(59, 130, 246, 0.05) 0%, rgba(59, 130, 246, 0.01) 100%);
+ border-color: rgba(59, 130, 246, 0.2);
+}
+.risk-card.level-low::before { background: var(--color-info); }
+
+/* Header Elements */
+.risk-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--spacing-md);
+}
+
+.risk-badge {
+ font-size: 0.75rem;
+ font-weight: 700;
+ padding: 4px 10px;
+ border-radius: var(--radius-full);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.risk-badge::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.risk-badge.high {
+ color: var(--color-danger);
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+.risk-badge.medium {
+ color: var(--color-warning);
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.2);
+}
+
+.risk-badge.low {
+ color: var(--color-info);
+ background: rgba(59, 130, 246, 0.1);
+ border: 1px solid rgba(59, 130, 246, 0.2);
+}
+
+.risk-category-tag {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ background: var(--bg-elevated);
+ padding: 4px 8px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-color);
+}
+
+.risk-card-content {
+ flex: 1;
+}
+
+.risk-description {
+ font-size: 0.95rem;
+ color: var(--text-primary);
+ line-height: 1.6;
+}
+
+/* =====================
+ Analysis Section
+ ===================== */
+.analysis-highlights {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--spacing-md);
+ margin: var(--spacing-lg) 0;
+}
+
+.analysis-highlight-card {
+ background: var(--bg-elevated);
+ padding: var(--spacing-md);
+ border-radius: var(--radius-md);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.highlight-label {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.highlight-value {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.analysis-paragraphs {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.analysis-paragraph {
+ font-size: 0.95rem;
+ color: var(--text-secondary);
+ line-height: 1.6;
+ text-align: justify;
+}
+
+/* =====================
+ Table Sorting
+ ===================== */
+.sortable-th {
+ cursor: pointer;
+ user-select: none;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+
+.sortable-th:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.sort-icon {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ margin-left: var(--spacing-xs);
+}
+
+.sort-icon-inactive {
+ opacity: 0.3;
+}
+
+.sort-icon-active {
+ color: var(--color-primary);
+ opacity: 1;
+}
+
+.table-header-hint {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
diff --git b/ui/index.html a/ui/index.html
new file mode 100644
index 0000000..cae05c6
--- /dev/null
+++ a/ui/index.html
@@ -0,0 +1,111 @@
+
+
+
+
+
+ AI 补货建议系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 首页
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git b/ui/js/api.js a/ui/js/api.js
new file mode 100644
index 0000000..f146e91
--- /dev/null
+++ a/ui/js/api.js
@@ -0,0 +1,96 @@
+/**
+ * API 调用封装
+ */
+
+const API = {
+ baseUrl: '/api',
+
+ /**
+ * 通用请求方法
+ */
+ async request(endpoint, options = {}) {
+ const url = `${this.baseUrl}${endpoint}`;
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || `请求失败: ${response.status}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error('API Error:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * GET 请求
+ */
+ async get(endpoint, params = {}) {
+ const queryString = new URLSearchParams(params).toString();
+ const url = queryString ? `${endpoint}?${queryString}` : endpoint;
+ return this.request(url);
+ },
+
+ /**
+ * 获取任务列表
+ */
+ async getTasks(params = {}) {
+ return this.get('/tasks', params);
+ },
+
+ /**
+ * 获取任务详情
+ */
+ async getTask(taskNo) {
+ return this.get(`/tasks/${taskNo}`);
+ },
+
+ /**
+ * 获取任务配件明细
+ */
+ async getTaskDetails(taskNo, params = {}) {
+ return this.get(`/tasks/${taskNo}/details`, params);
+ },
+
+
+ /**
+ * 获取统计摘要
+ */
+ async getStatsSummary() {
+ return this.get('/stats/summary');
+ },
+
+ /**
+ * 获取任务执行日志
+ */
+ async getTaskLogs(taskNo) {
+ return this.get(`/tasks/${taskNo}/logs`);
+ },
+
+ /**
+ * 获取任务配件汇总列表
+ */
+ async getPartSummaries(taskNo, params = {}) {
+ return this.get(`/tasks/${taskNo}/part-summaries`, params);
+ },
+
+ /**
+ * 获取配件的门店明细
+ */
+ async getPartShopDetails(taskNo, partCode) {
+ return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`);
+ },
+};
+
+// 导出到全局
+window.API = API;
diff --git b/ui/js/app.js a/ui/js/app.js
new file mode 100644
index 0000000..f265a48
--- /dev/null
+++ a/ui/js/app.js
@@ -0,0 +1,1021 @@
+/**
+ * 主应用逻辑
+ */
+
+const App = {
+ currentPage: 'dashboard',
+ currentTaskNo: null,
+
+ // 配件汇总表排序状态
+ partSummarySort: {
+ sortBy: 'total_suggest_amount',
+ sortOrder: 'desc'
+ },
+
+ // 配件汇总表筛选状态
+ partSummaryFilters: {
+ page: 1,
+ page_size: 50,
+ part_code: '',
+ priority: ''
+ },
+
+ // 侧边栏折叠状态
+ isSidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
+
+ /**
+ * 切换侧边栏折叠状态
+ */
+ toggleSidebarCollapse() {
+ this.isSidebarCollapsed = !this.isSidebarCollapsed;
+ localStorage.setItem('sidebar-collapsed', this.isSidebarCollapsed);
+ this.applySidebarState();
+ },
+
+ /**
+ * 应用侧边栏状态
+ */
+ applySidebarState() {
+ const sidebar = document.querySelector('.sidebar');
+ // 主内容区通过 CSS 选择器 .sidebar.collapsed + ... .main-content 自动调整
+ // 或者我们需要手动给 main-content 加类,但 CSS 中用了兄弟选择器,
+ // 不过兄弟选择器在这里可能不生效,因为中间隔了 .sidebar-overlay
+ // 让我们看看 index.html 结构: sidebar, sidebar-overlay, main-content
+ // CSS 选择器是 .sidebar.collapsed + .sidebar-overlay + .main-content
+ // 这样是可以的。
+
+ if (this.isSidebarCollapsed) {
+ sidebar.classList.add('collapsed');
+ } else {
+ sidebar.classList.remove('collapsed');
+ }
+
+ // 触发 icon 刷新以确保显示正确(虽然 CSS 旋转已处理)
+ lucide.createIcons();
+ },
+
+ /**
+ * 切换侧边栏显示状态(移动端)
+ */
+ toggleSidebar(show) {
+ const sidebar = document.querySelector('.sidebar');
+ const overlay = document.getElementById('sidebar-overlay');
+
+ if (show) {
+ sidebar.classList.add('visible');
+ overlay.classList.add('active');
+ } else {
+ sidebar.classList.remove('visible');
+ overlay.classList.remove('active');
+ }
+ },
+
+ /**
+ * 应用配件筛选
+ */
+ applyPartFilters() {
+ const partCode = document.getElementById('filter-part-code')?.value || '';
+ const priorityElement = document.getElementById('filter-priority');
+ const priority = priorityElement ? priorityElement.value : '';
+
+ this.partSummaryFilters.part_code = partCode;
+ this.partSummaryFilters.priority = priority;
+ this.partSummaryFilters.page = 1; // 重置到第一页
+
+ this.loadPartSummaries();
+ },
+
+ /**
+ * 重置配件筛选
+ */
+ resetPartFilters() {
+ this.partSummaryFilters.part_code = '';
+ this.partSummaryFilters.priority = '';
+ this.partSummaryFilters.page = 1;
+
+ this.loadPartSummaries();
+ },
+
+ /**
+ * 初始化应用
+ */
+ init() {
+ this.bindEvents();
+ this.handleRoute();
+ this.applySidebarState(); // 初始化侧边栏状态
+ window.addEventListener('hashchange', () => this.handleRoute());
+ lucide.createIcons();
+ },
+
+ /**
+ * 绑定全局事件
+ */
+ bindEvents() {
+ // 刷新按钮
+ document.getElementById('refresh-btn').addEventListener('click', () => {
+ this.handleRoute();
+ });
+
+ // 模态框关闭
+ document.getElementById('modal-close').addEventListener('click', () => {
+ Components.closeModal();
+ });
+ document.getElementById('modal-overlay').addEventListener('click', (e) => {
+ if (e.target.id === 'modal-overlay') {
+ Components.closeModal();
+ }
+ });
+
+ // 侧边栏切换
+ const menuToggle = document.getElementById('menu-toggle');
+ const sidebarOverlay = document.getElementById('sidebar-overlay');
+
+ if (menuToggle) {
+ menuToggle.addEventListener('click', () => {
+ this.toggleSidebar(true);
+ });
+ }
+
+ if (sidebarOverlay) {
+ sidebarOverlay.addEventListener('click', () => {
+ this.toggleSidebar(false);
+ });
+ }
+
+ // 桌面端侧边栏折叠按钮
+ const collapseBtn = document.getElementById('sidebar-collapse-btn');
+ if (collapseBtn) {
+ collapseBtn.addEventListener('click', () => {
+ this.toggleSidebarCollapse();
+ });
+ }
+
+ // 导航点击自动关闭侧边栏(移动端)
+ document.querySelectorAll('.nav-item').forEach(item => {
+ item.addEventListener('click', () => {
+ if (window.innerWidth <= 1024) {
+ this.toggleSidebar(false);
+ }
+ });
+ });
+ },
+
+ /**
+ * 路由处理
+ */
+ handleRoute() {
+ const hash = window.location.hash || '#/';
+ const [, path, param] = hash.match(/#\/([^/]*)(?:\/(.*))?/) || [, '', ''];
+
+ // 更新导航状态
+ document.querySelectorAll('.nav-item').forEach(item => {
+ item.classList.remove('active');
+ if (item.dataset.page === (path || 'dashboard')) {
+ item.classList.add('active');
+ }
+ });
+
+ // 路由分发
+ switch (path) {
+ case 'tasks':
+ if (param) {
+ this.showTaskDetail(param);
+ } else {
+ this.showTaskList();
+ }
+ break;
+ case '':
+ default:
+ this.showDashboard();
+ break;
+ }
+ },
+
+ /**
+ * 更新面包屑
+ */
+ updateBreadcrumb(items) {
+ const breadcrumb = document.getElementById('breadcrumb');
+ breadcrumb.innerHTML = items.map((item, index) => {
+ if (item.href) {
+ return `${item.text}`;
+ }
+ return `${item.text}`;
+ }).join('');
+ },
+
+ /**
+ * 显示概览页面
+ */
+ async showDashboard() {
+ this.currentPage = 'dashboard';
+ this.updateBreadcrumb([{ text: '概览' }]);
+
+ const container = document.getElementById('page-container');
+ container.innerHTML = '';
+
+ try {
+ // 获取统计数据
+ const [stats, tasksData] = await Promise.all([
+ API.getStatsSummary().catch(() => ({})),
+ API.getTasks({ page: 1, page_size: 5 }).catch(() => ({ items: [] })),
+ ]);
+
+ // 渲染统计卡片
+ const statsGrid = document.getElementById('stats-grid');
+ statsGrid.innerHTML = `
+ ${Components.renderStatCard('list-checks', '总任务数', stats.total_tasks || 0, 'primary')}
+ ${Components.renderStatCard('check-circle', '成功任务', stats.success_tasks || 0, 'success')}
+ ${Components.renderStatCard('x-circle', '失败任务', stats.failed_tasks || 0, 'danger')}
+ ${Components.renderStatCard('package', '建议配件', stats.total_parts || 0, 'info')}
+ ${Components.renderStatCard('dollar-sign', '建议金额', Components.formatAmount(stats.total_suggest_amount), 'warning')}
+ `;
+
+ // 渲染最近任务
+ this.renderRecentTasks(tasksData.items || []);
+
+ lucide.createIcons();
+ } catch (error) {
+ Components.showToast('加载数据失败: ' + error.message, 'error');
+ }
+ },
+
+ /**
+ * 渲染最近任务
+ */
+ renderRecentTasks(tasks) {
+ const container = document.getElementById('recent-tasks');
+
+ if (!tasks.length) {
+ container.innerHTML = `
+
+
+ ${Components.renderEmptyState('inbox', '暂无任务', '还没有执行过任何补货建议任务')}
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+
+
+
+
+
+
+ | 任务编号 |
+ 商家组合 |
+ 状态 |
+ 配件数 |
+ 建议金额 |
+ 执行时间 |
+
+
+
+ ${tasks.map(task => `
+
+ |
+
+ ${task.task_no}
+
+ |
+ ${task.dealer_grouping_name || '-'} |
+ ${Components.getStatusBadge(task.status, task.status_text)} |
+ ${task.part_count} |
+ ${Components.formatAmount(task.actual_amount)} |
+ ${Components.formatDuration(task.duration_seconds)} |
+
+ `).join('')}
+
+
+
+
+ `;
+ },
+
+ /**
+ * 显示任务列表页面
+ */
+ async showTaskList(page = 1) {
+ this.currentPage = 'tasks';
+ this.updateBreadcrumb([{ text: '任务列表' }]);
+
+ const container = document.getElementById('page-container');
+ container.innerHTML = '';
+
+ try {
+ Components.showLoading();
+ const data = await API.getTasks({ page, page_size: 20 });
+ Components.hideLoading();
+
+ this.renderTaskList(data);
+ lucide.createIcons();
+ } catch (error) {
+ Components.hideLoading();
+ Components.showToast('加载任务列表失败: ' + error.message, 'error');
+ }
+ },
+
+ /**
+ * 渲染任务列表
+ */
+ renderTaskList(data) {
+ const container = document.getElementById('task-list-container');
+ const { items, total, page, page_size } = data;
+
+ if (!items.length) {
+ container.innerHTML = `
+
+ ${Components.renderEmptyState('inbox', '暂无任务', '还没有执行过任何补货建议任务')}
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+
+
+
+
+
+
+ | 任务编号 |
+ 商家组合 |
+ 状态 |
+ 配件数 |
+ 建议金额 |
+ 基准库销比 |
+ 统计日期 |
+ 执行时长 |
+ 操作 |
+
+
+
+ ${items.map(task => `
+
+ |
+ ${task.task_no}
+ |
+ ${task.dealer_grouping_name || '-'} |
+ ${Components.getStatusBadge(task.status, task.status_text)} |
+ ${task.part_count} |
+ ${Components.formatAmount(task.actual_amount)} |
+ ${Components.formatRatio(task.base_ratio)} |
+ ${task.statistics_date || '-'} |
+ ${Components.formatDuration(task.duration_seconds)} |
+
+
+
+ 查看
+
+ |
+
+ `).join('')}
+
+
+
+
+
+ `;
+
+ // 渲染分页
+ const paginationContainer = document.getElementById('pagination-container');
+ paginationContainer.innerHTML = Components.renderPagination(page, total, page_size);
+
+ // 绑定分页事件
+ paginationContainer.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const targetPage = parseInt(btn.dataset.page);
+ if (targetPage && targetPage !== page) {
+ this.showTaskList(targetPage);
+ }
+ });
+ });
+
+ lucide.createIcons();
+ },
+
+ /**
+ * 显示任务详情页面
+ */
+ async showTaskDetail(taskNo) {
+ this.currentPage = 'task-detail';
+ this.currentTaskNo = taskNo;
+ this.updateBreadcrumb([
+ { text: '任务列表', href: '#/tasks' },
+ { text: taskNo },
+ ]);
+
+ const container = document.getElementById('page-container');
+ container.innerHTML = '';
+
+ try {
+ Components.showLoading();
+
+ const [task, partSummaries, logs] = await Promise.all([
+ API.getTask(taskNo),
+ API.getPartSummaries(taskNo, { page: 1, page_size: 100 }).catch(() => ({ items: [], total: 0 })),
+ API.getTaskLogs(taskNo).catch(() => ({ items: [] })),
+ ]);
+
+ Components.hideLoading();
+ this.renderTaskDetail(task, partSummaries, logs);
+ lucide.createIcons();
+ } catch (error) {
+ Components.hideLoading();
+ Components.showToast('加载任务详情失败: ' + error.message, 'error');
+ }
+ },
+
+ /**
+ * 渲染任务详情
+ */
+ renderTaskDetail(task, partSummaries, logs) {
+ this._currentLogs = logs;
+ this._partSummaries = partSummaries;
+ const container = document.getElementById('task-detail-container');
+
+ container.innerHTML = `
+
+
+
+ 返回任务列表
+
+
+
+
+
+
+
+ ${Components.renderStatCard('package', '建议配件数', task.part_count, 'primary')}
+ ${Components.renderStatCard('dollar-sign', '建议金额', Components.formatAmount(task.actual_amount), 'success')}
+ ${Components.renderStatCard('percent', '基准库销比', Components.formatRatio(task.base_ratio), 'info')}
+ ${Components.renderStatCard('cpu', 'LLM Tokens', task.llm_total_tokens || 0, 'warning')}
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // 绑定标签页事件
+ const tabs = container.querySelectorAll('.tab');
+ tabs.forEach(tab => {
+ tab.addEventListener('click', () => {
+ tabs.forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+ this.renderTabContent(tab.dataset.tab, task, partSummaries);
+ });
+ });
+
+ // 默认显示配件汇总
+ this.renderTabContent('details', task, partSummaries);
+ },
+
+ /**
+ * 渲染标签页内容
+ */
+ renderTabContent(tabName, task, details) {
+ const container = document.getElementById('tab-content');
+
+ switch (tabName) {
+ case 'details':
+ this.renderDetailsTab(container, details);
+ break;
+
+ case 'logs':
+ this.renderLogsTab(container, this._currentLogs);
+ break;
+ case 'info':
+ this.renderInfoTab(container, task);
+ break;
+ }
+
+ lucide.createIcons();
+ },
+
+ /**
+ * 加载配件汇总数据(支持排序和筛选)
+ */
+ async loadPartSummaries() {
+ if (!this.currentTaskNo) return;
+
+ try {
+ const params = {
+ page: this.partSummaryFilters.page,
+ page_size: this.partSummaryFilters.page_size,
+ sort_by: this.partSummarySort.sortBy,
+ sort_order: this.partSummarySort.sortOrder,
+ part_code: this.partSummaryFilters.part_code,
+ priority: this.partSummaryFilters.priority
+ };
+
+ // 移除空值参数
+ Object.keys(params).forEach(key => {
+ if (params[key] === '' || params[key] === null || params[key] === undefined) {
+ delete params[key];
+ }
+ });
+
+ const data = await API.getPartSummaries(this.currentTaskNo, params);
+ this._partSummaries = data;
+
+ const container = document.getElementById('tab-content');
+ if (container) {
+ this.renderDetailsTab(container, data);
+ lucide.createIcons();
+ }
+ } catch (error) {
+ Components.showToast('加载配件数据失败: ' + error.message, 'error');
+ }
+ },
+
+ /**
+ * 切换配件汇总排序
+ */
+ togglePartSummarySort(field) {
+ if (this.partSummarySort.sortBy === field) {
+ this.partSummarySort.sortOrder = this.partSummarySort.sortOrder === 'desc' ? 'asc' : 'desc';
+ } else {
+ this.partSummarySort.sortBy = field;
+ this.partSummarySort.sortOrder = 'desc';
+ }
+ this.loadPartSummaries();
+ },
+
+ /**
+ * 获取排序图标
+ */
+ getSortIcon(field) {
+ if (this.partSummarySort.sortBy !== field) {
+ return '';
+ }
+ if (this.partSummarySort.sortOrder === 'desc') {
+ return '';
+ }
+ return '';
+ },
+
+ /**
+ * 渲染配件明细标签页
+ */
+ renderDetailsTab(container, partSummaries) {
+ const items = partSummaries.items || [];
+ const { total, page, page_size } = partSummaries;
+
+ container.innerHTML = `
+
+
+
+
+
+
+ |
+
+ 配件编码 ${this.getSortIcon('part_code')}
+ |
+ 配件名称 |
+
+ 成本价 ${this.getSortIcon('cost_price')}
+ |
+
+ 总库存 ${this.getSortIcon('total_storage_cnt')}
+ |
+
+ 总销量 ${this.getSortIcon('total_avg_sales_cnt')}
+ |
+
+ 商家组合库销比 ${this.getSortIcon('group_current_ratio')}
+ |
+
+ 计划后库销比 ${this.getSortIcon('group_post_plan_ratio')}
+ |
+
+ 门店数 ${this.getSortIcon('shop_count')}
+ |
+
+ 需补货门店 ${this.getSortIcon('need_replenishment_shop_count')}
+ |
+
+ 总建议数量 ${this.getSortIcon('total_suggest_cnt')}
+ |
+
+ 总建议金额 ${this.getSortIcon('total_suggest_amount')}
+ |
+
+
+
+ ${items.length > 0 ? items.map((item, index) => `
+
+ |
+
+ |
+ ${item.part_code} |
+ ${item.part_name || '-'} |
+ ${Components.formatAmount(item.cost_price)} |
+ ${Components.formatNumber(item.total_storage_cnt)} |
+ ${Components.formatNumber(item.total_avg_sales_cnt)} |
+ ${Components.getRatioIndicator(item.group_current_ratio, 1.1)} |
+ ${Components.formatRatio(item.group_post_plan_ratio)} |
+ ${item.shop_count} |
+ ${item.need_replenishment_shop_count} |
+ ${item.total_suggest_cnt} |
+ ${Components.formatAmount(item.total_suggest_amount)} |
+
+
+ |
+
+ |
+
+ `).join('') : `
+
+ |
+ 暂无符合条件的配件建议
+ |
+
+ `}
+
+
+
+
+
+
+
+ `;
+
+ // 渲染分页
+ const paginationContainer = document.getElementById('part-summary-pagination');
+ if (paginationContainer) {
+ paginationContainer.innerHTML = Components.renderPagination(page, total, page_size);
+
+ // 绑定分页事件
+ paginationContainer.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const targetPage = parseInt(btn.dataset.page);
+ if (targetPage && targetPage !== page) {
+ this.partSummaryFilters.page = targetPage;
+ this.loadPartSummaries();
+ }
+ });
+ });
+ }
+
+ // 绑定搜索框回车事件
+ const partCodeInput = document.getElementById('filter-part-code');
+ if (partCodeInput) {
+ partCodeInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ App.applyPartFilters();
+ }
+ });
+ }
+ },
+
+ /**
+ * 切换配件门店展开/收起
+ */
+ async togglePartShops(partCode, index) {
+ const row = document.getElementById(`shops-${index}`);
+ const container = document.getElementById(`shops-container-${index}`);
+ const btn = document.querySelector(`tr[data-index="${index}"] .expand-icon`);
+
+ if (row.style.display === 'none') {
+ row.style.display = 'table-row';
+ btn.classList.add('expanded');
+
+ try {
+ const data = await API.getPartShopDetails(this.currentTaskNo, partCode);
+ const partSummary = this._partSummaries.items.find(p => p.part_code === partCode);
+ this.renderPartShops(container, data.items, partSummary);
+ lucide.createIcons();
+ } catch (error) {
+ container.innerHTML = `加载失败: ${error.message}
`;
+ }
+ } else {
+ row.style.display = 'none';
+ btn.classList.remove('expanded');
+ }
+ },
+
+ /**
+ * 渲染配件门店明细
+ */
+ renderPartShops(container, shops, partSummary) {
+ if (!shops || shops.length === 0) {
+ container.innerHTML = '无门店建议数据
';
+ return;
+ }
+
+ container.innerHTML = `
+ ${partSummary && partSummary.part_decision_reason ? `
+
+ 配件补货理由:
+ ${partSummary.part_decision_reason}
+
+ ` : ''}
+
+
+
+ | 库房 |
+ 有效库存 |
+ 月均销量 |
+ 当前库销比 |
+ 计划后库销比 |
+ 建议数量 |
+ 建议金额 |
+ 建议理由 |
+
+
+
+ ${shops.map(shop => `
+
+ | ${shop.shop_name || '-'} |
+ ${Components.formatNumber(shop.valid_storage_cnt)} |
+ ${Components.formatNumber(shop.avg_sales_cnt)} |
+ ${Components.getRatioIndicator(shop.current_ratio, shop.base_ratio)} |
+ ${Components.formatRatio(shop.post_plan_ratio)} |
+ ${shop.suggest_cnt} |
+ ${Components.formatAmount(shop.suggest_amount)} |
+
+ ${shop.suggestion_reason || '-'}
+ |
+
+ `).join('')}
+
+
+ `;
+ },
+
+ /**
+ * 渲染执行日志标签页
+ */
+ renderLogsTab(container, logs) {
+ if (!logs || !logs.items || logs.items.length === 0) {
+ container.innerHTML = `
+
+ ${Components.renderEmptyState('activity', '暂无执行日志', '该任务没有执行日志记录')}
+
+ `;
+ return;
+ }
+
+ const items = logs.items;
+ const totalTokens = items.reduce((sum, item) => sum + (item.llm_tokens || 0), 0);
+ const totalTime = items.reduce((sum, item) => sum + (item.execution_time_ms || 0), 0);
+
+ container.innerHTML = `
+
+
+
+ ${items.map((log, index) => `
+
+
+
+ ${log.status === 1 ? '' :
+ log.status === 2 ? '' :
+ ''}
+
+ ${index < items.length - 1 ? '
' : ''}
+
+
+
+
+
+
+ ${log.execution_time_ms ? Components.formatDuration(log.execution_time_ms / 1000) : '-'}
+
+ ${log.llm_tokens > 0 ? `
+
+
+ ${log.llm_tokens} tokens
+
+ ` : ''}
+ ${log.retry_count > 0 ? `
+
+
+ 重试 ${log.retry_count} 次
+
+ ` : ''}
+
+ ${log.error_message ? `
+
+
+ ${log.error_message}
+
+ ` : ''}
+
+
+ `).join('')}
+
+
+ `;
+ },
+
+ /**
+ * 渲染任务信息标签页
+ */
+ renderInfoTab(container, task) {
+ container.innerHTML = `
+
+
+
+
+ ${Components.renderInfoItem('任务编号', task.task_no)}
+ ${Components.renderInfoItem('集团ID', task.group_id)}
+ ${Components.renderInfoItem('商家组合ID', task.dealer_grouping_id)}
+ ${Components.renderInfoItem('商家组合名称', task.dealer_grouping_name)}
+ ${Components.renderInfoItem('品牌组合ID', task.brand_grouping_id)}
+ ${Components.renderInfoItem('统计日期', task.statistics_date)}
+
+
+
+
+
+
+ ${Components.renderInfoItem('状态', Components.getStatusBadge(task.status, task.status_text))}
+ ${Components.renderInfoItem('开始时间', task.start_time)}
+ ${Components.renderInfoItem('结束时间', task.end_time)}
+ ${Components.renderInfoItem('执行时长', Components.formatDuration(task.duration_seconds))}
+ ${Components.renderInfoItem('创建时间', task.create_time)}
+
+
+
+
+
+
+ ${Components.renderInfoItem('LLM 提供商', task.llm_provider || '-')}
+ ${Components.renderInfoItem('模型名称', task.llm_model || '-')}
+ ${Components.renderInfoItem('Token 消耗', task.llm_total_tokens)}
+
+
+
+ ${task.error_message ? `
+
+
+
${task.error_message}
+
+ ` : ''}
+
+ `;
+ },
+};
+
+// DOM 加载完成后初始化
+document.addEventListener('DOMContentLoaded', () => {
+ App.init();
+});
+
+// 导出到全局
+window.App = App;
diff --git b/ui/js/components.js a/ui/js/components.js
new file mode 100644
index 0000000..e5f14f1
--- /dev/null
+++ a/ui/js/components.js
@@ -0,0 +1,529 @@
+/**
+ * UI 组件库
+ */
+
+const Components = {
+ /**
+ * 格式化金额
+ */
+ formatAmount(value) {
+ if (value === null || value === undefined) return '-';
+ return `¥${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+ },
+
+ /**
+ * 格式化数字
+ */
+ formatNumber(value, decimals = 2) {
+ if (value === null || value === undefined) return '-';
+ return Number(value).toLocaleString('zh-CN', {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals
+ });
+ },
+
+ /**
+ * 格式化库销比
+ */
+ formatRatio(value) {
+ if (value === null || value === undefined || value === 999) return '-';
+ if (value === 0) return '0.00';
+ return Number(value).toFixed(2);
+ },
+
+ /**
+ * 获取配件标签
+ * 呆滞:有库存,滚动90天没有销量
+ * 低频:无库存,滚动90天有出库,月均销量<1
+ * 缺货:无库存,滚动90天有出库,月均销量≥1
+ */
+ getPartTag(validStorage, avgSales) {
+ const storage = Number(validStorage) || 0;
+ const sales = Number(avgSales) || 0;
+
+ if (storage > 0 && sales === 0) {
+ return { type: 'stagnant', text: '呆滞', class: 'tag-stagnant' };
+ }
+ if (storage <= 0 && sales > 0 && sales < 1) {
+ return { type: 'low-freq', text: '低频', class: 'tag-low-freq' };
+ }
+ if (storage <= 0 && sales >= 1) {
+ return { type: 'shortage', text: '缺货', class: 'tag-shortage' };
+ }
+ return null;
+ },
+
+ /**
+ * 渲染配件标签HTML
+ */
+ renderPartTag(validStorage, avgSales) {
+ const tag = this.getPartTag(validStorage, avgSales);
+ if (!tag) return '';
+ return `${tag.text}`;
+ },
+
+ /**
+ * 获取步骤名称显示
+ */
+ getStepNameDisplay(stepName) {
+ const stepMap = {
+ 'fetch_part_ratio': '获取配件数据',
+ 'sql_agent': 'AI分析建议',
+ 'allocate_budget': '分配预算',
+ 'generate_report': '生成报告',
+ };
+ return stepMap[stepName] || stepName;
+ },
+
+ /**
+ * 获取日志状态徽章
+ */
+ getLogStatusBadge(status) {
+ const statusMap = {
+ 0: { class: 'badge-info', text: '运行中' },
+ 1: { class: 'badge-success', text: '成功' },
+ 2: { class: 'badge-danger', text: '失败' },
+ 3: { class: 'badge-warning', text: '跳过' },
+ };
+ const config = statusMap[status] || { class: 'badge-neutral', text: '未知' };
+ return `${config.text}`;
+ },
+
+ /**
+ * 格式化时长
+ */
+ formatDuration(seconds) {
+ if (!seconds) return '-';
+ seconds = Math.floor(seconds);
+ if (seconds < 60) return `${seconds}秒`;
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${minutes}分${secs}秒`;
+ },
+
+ /**
+ * 获取状态徽章 HTML
+ */
+ getStatusBadge(status, statusText) {
+ const statusMap = {
+ 0: { class: 'badge-info', icon: 'loader-2', text: statusText || '运行中' },
+ 1: { class: 'badge-success', icon: 'check-circle', text: statusText || '成功' },
+ 2: { class: 'badge-danger', icon: 'x-circle', text: statusText || '失败' },
+ };
+ const config = statusMap[status] || statusMap[0];
+ return `
+
+
+ ${config.text}
+
+ `;
+ },
+
+ /**
+ * 获取优先级徽章 HTML
+ */
+ getPriorityBadge(priority) {
+ const priorityMap = {
+ 1: { class: 'priority-high', text: '高' },
+ 2: { class: 'priority-medium', text: '中' },
+ 3: { class: 'priority-low', text: '低' },
+ };
+ const config = priorityMap[priority] || priorityMap[2];
+ return `${config.text}`;
+ },
+
+ /**
+ * 获取库销比指示器 HTML
+ */
+ getRatioIndicator(current, base) {
+ if (current === null || current === undefined || current === 999) {
+ return '-';
+ }
+
+ const percentage = Math.min((current / (base || 1.5)) * 100, 100);
+ let className = 'ratio-normal';
+ if (current < 0.5) className = 'ratio-low';
+ else if (current > 2) className = 'ratio-high';
+
+ return `
+
+
${this.formatRatio(current)}
+
+
+ `;
+ },
+
+ /**
+ * 渲染分页控件
+ */
+ renderPagination(current, total, pageSize, onChange) {
+ const totalPages = Math.ceil(total / pageSize);
+ const start = (current - 1) * pageSize + 1;
+ const end = Math.min(current * pageSize, total);
+
+ return `
+
+ `;
+ },
+
+ /**
+ * 获取分页数字
+ */
+ getPaginationNumbers(current, total) {
+ const pages = [];
+ const maxVisible = 5;
+
+ let start = Math.max(1, current - Math.floor(maxVisible / 2));
+ let end = Math.min(total, start + maxVisible - 1);
+ start = Math.max(1, end - maxVisible + 1);
+
+ for (let i = start; i <= end; i++) {
+ pages.push(`
+
+ `);
+ }
+
+ return pages.join('');
+ },
+
+ /**
+ * 渲染统计卡片
+ */
+ renderStatCard(icon, label, value, iconClass = 'primary', change = null) {
+ let changeHtml = '';
+ if (change !== null) {
+ const changeClass = change >= 0 ? 'positive' : 'negative';
+ const changeIcon = change >= 0 ? 'trending-up' : 'trending-down';
+ changeHtml = `
+
+
+ ${Math.abs(change)}%
+
+ `;
+ }
+
+ return `
+
+
+
+
+
+
${label}
+
${value}
+ ${changeHtml}
+
+
+ `;
+ },
+
+ /**
+ * 渲染空状态
+ */
+ renderEmptyState(icon = 'inbox', title = '暂无数据', description = '') {
+ return `
+
+
+
${title}
+ ${description ? `
${description}
` : ''}
+
+ `;
+ },
+
+ /**
+ * 渲染信息列表项
+ */
+ renderInfoItem(label, value) {
+ return `
+
+ ${label}
+ ${value || '-'}
+
+ `;
+ },
+
+ /**
+ * 渲染 Markdown 内容
+ */
+ renderMarkdown(content) {
+ if (!content) return '';
+ if (typeof marked !== 'undefined') {
+ let html = marked.parse(content);
+ html = html.replace(/\s*]*>[\s\\n]*<\/code>\s*<\/pre>/gi, '');
+ html = html.replace(/[\s\\n]*<\/pre>/gi, '');
+ html = html.replace(/\s*]*>\n<\/code>\s*<\/pre>/gi, '');
+ return `${html}
`;
+ }
+ return ``;
+ },
+
+ /**
+ * 渲染结构化报告 Section
+ */
+ renderReportSection(section) {
+ if (!section) return '';
+
+ const iconMap = {
+ 'executive_summary': { icon: 'file-text', class: 'summary' },
+ 'inventory_analysis': { icon: 'bar-chart-2', class: 'analysis' },
+ 'risk_assessment': { icon: 'alert-triangle', class: 'risk' },
+ 'purchase_recommendations': { icon: 'shopping-cart', class: 'recommendation' },
+ 'optimization_suggestions': { icon: 'lightbulb', class: 'optimization' },
+ };
+
+ const config = iconMap[section.type] || { icon: 'file', class: 'summary' };
+
+ let contentHtml = '';
+
+ switch (section.type) {
+ case 'executive_summary':
+ contentHtml = this.renderSummarySection(section);
+ break;
+ case 'inventory_analysis':
+ contentHtml = this.renderAnalysisSection(section);
+ break;
+ case 'risk_assessment':
+ contentHtml = this.renderRiskSection(section);
+ break;
+ case 'purchase_recommendations':
+ contentHtml = this.renderRecommendationSection(section);
+ break;
+ case 'optimization_suggestions':
+ contentHtml = this.renderSuggestionSection(section);
+ break;
+ default:
+ contentHtml = `${JSON.stringify(section)}
`;
+ }
+
+ return `
+
+ `;
+ },
+
+ /**
+ * 渲染执行摘要
+ */
+ renderSummarySection(section) {
+ const items = section.items || [];
+ const text = section.text || '';
+
+ const itemsHtml = items.length > 0 ? `
+
+ ${items.map(item => `
+
+
${item.label || ''}
+
${item.value || ''}
+
+ `).join('')}
+
+ ` : '';
+
+ const textHtml = text ? `${text}
` : '';
+
+ return itemsHtml + textHtml;
+ },
+
+ /**
+ * 渲染库存分析
+ */
+ renderAnalysisSection(section) {
+ const paragraphs = section.paragraphs || [];
+ const highlights = section.highlights || [];
+
+ const highlightsHtml = highlights.length > 0 ? `
+
+ ${highlights.map(h => `
+
+ ${h.label || ''}
+ ${h.value || ''}
+
+ `).join('')}
+
+ ` : '';
+
+ const paragraphsHtml = paragraphs.length > 0 ? `
+
+ ${paragraphs.map(p => `
+
+ ${p}
+
+ `).join('')}
+
+ ` : '';
+
+ // Put highlights first for better visibility
+ return highlightsHtml + paragraphsHtml;
+ },
+
+ /**
+ * 渲染风险评估
+ */
+ renderRiskSection(section) {
+ const risks = section.risks || [];
+
+ if (risks.length === 0) {
+ return '暂无风险评估
';
+ }
+
+ const levelText = { high: '高', medium: '中', low: '低' };
+
+ // 按风险等级排序:high > medium > low
+ const sortOrder = { high: 0, medium: 1, low: 2 };
+ const sortedRisks = [...risks].sort((a, b) => {
+ const orderA = sortOrder[a.level] || 99;
+ const orderB = sortOrder[b.level] || 99;
+ return orderA - orderB;
+ });
+
+ return `
+
+ ${sortedRisks.map(risk => `
+
+
+
+
${risk.description || ''}
+
+
+ `).join('')}
+
+ `;
+ },
+
+ /**
+ * 渲染采购建议
+ */
+ renderRecommendationSection(section) {
+ const recommendations = section.recommendations || [];
+
+ if (recommendations.length === 0) {
+ return '暂无采购建议
';
+ }
+
+ return `
+
+ ${recommendations.map(rec => `
+
+
${rec.priority || 3}
+
+
${rec.action || ''}
+
${rec.reason || ''}
+
+
+ `).join('')}
+
+ `;
+ },
+
+ /**
+ * 渲染优化建议
+ */
+ renderSuggestionSection(section) {
+ const suggestions = section.suggestions || [];
+
+ if (suggestions.length === 0) {
+ return '暂无优化建议
';
+ }
+
+ return `
+
+ ${suggestions.map(s => `
+
+ `).join('')}
+
+ `;
+ },
+
+ /**
+ * 显示 Toast 通知
+ */
+ showToast(message, type = 'info') {
+ const container = document.getElementById('toast-container');
+ const icons = {
+ success: 'check-circle',
+ error: 'x-circle',
+ warning: 'alert-triangle',
+ info: 'info',
+ };
+
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+ toast.innerHTML = `
+
+ ${message}
+ `;
+
+ container.appendChild(toast);
+ lucide.createIcons({ icons: { [icons[type]]: lucide.icons[icons[type]] }, attrs: {} });
+
+ setTimeout(() => {
+ toast.style.animation = 'slideIn 0.25s ease reverse';
+ setTimeout(() => toast.remove(), 250);
+ }, 3000);
+ },
+
+ /**
+ * 显示加载遮罩
+ */
+ showLoading() {
+ document.getElementById('loading-overlay').classList.add('active');
+ },
+
+ /**
+ * 隐藏加载遮罩
+ */
+ hideLoading() {
+ document.getElementById('loading-overlay').classList.remove('active');
+ },
+
+ /**
+ * 显示模态框
+ */
+ showModal(title, content) {
+ document.getElementById('modal-title').textContent = title;
+ document.getElementById('modal-body').innerHTML = content;
+ document.getElementById('modal-overlay').classList.add('active');
+ lucide.createIcons();
+ },
+
+ /**
+ * 关闭模态框
+ */
+ closeModal() {
+ document.getElementById('modal-overlay').classList.remove('active');
+ },
+};
+
+// 导出到全局
+window.Components = Components;